mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 02:22:46 +00:00
* fix(feishu): add retry with linear backoff for send rate-limit errors When Feishu returns code 230020 (per-chat rate limit), requestFeishuApi now retries up to 2 times with linear backoff (500ms, 1000ms). The reply path (im.message.reply) is also covered via the same retry helper. Confirmed by a real 20-concurrent-send stress test: all 20 messages succeed after retry. Closes #70879 * ci: retrigger CI * fix(feishu): retry HTTP 429 and code 11232 for message send rate limits Feishu Open API has three send-time rate limit signals: HTTP 429 (gateway-wide quota), business code 11232 (tenant-level message service: 100/min, 5/sec), and 230020 (per-chat). Previously only 230020 was retried; HTTP 429 and 11232 propagated as fatal errors. - Add 11232 to FEISHU_SEND_RATE_LIMIT_CODES. - In getFeishuSendRateLimitCode, recognize HTTP 429 before reading the body code so gateway-level limits enter the retry loop. - Update doc comment listing both gateway and business sources. * test(feishu): add focused retry coverage for 11232 and HTTP 429 The previous send.retry.test.ts only exercised 230020 / 230006 / non-rate codes / plain errors. After expanding the retry policy in 90c787096 to cover code 11232 (tenant-level message rate limit) and gateway-level HTTP 429, ClawSweeper review #89659 (P2) flagged the tests as no longer matching the production behavior. - getFeishuSendRateLimitCode: assert 11232 returns 11232, HTTP 429 returns 429, and HTTP 429 wins over body code when both are present. - requestFeishuApi: cover 11232 retry-then-success, 429 retry-then-success, exhaustion paths for both, and a mixed 230020 → 11232 → ok recovery. * fix(feishu): retry on fulfilled rate-limit response bodies (no-throw) The Feishu node SDK sometimes resolves a non-throwing response that carries a rate-limit code in its body (e.g. { code: 11232, msg: ... }) instead of rejecting. requestFeishuApi previously returned that body straight away and downstream assertFeishuMessageApiSuccess failed once with no retry — the same shape that issue #28157 fixed earlier on the typing/reaction path via getBackoffCodeFromResponse. ClawSweeper review on #89659 (P1, comment-shared.ts:140) flagged the gap. Mirror the typing-path pattern for the send helper: - Add getFeishuSendRateLimitCodeFromResponse to classify fulfilled bodies against FEISHU_SEND_RATE_LIMIT_CODES (230020, 11232). - In requestFeishuApi, after each fulfilled await, classify before returning. If the body is a retryable rate limit and there are attempts left, continue the loop. After exhaustion, wrap the last fulfilled body into a synthetic AxiosError-shaped error so callers see the same error shape as the throw path. - Add 11 focused tests covering fulfilled 11232/230020 retry-then-ok, exhaustion, mixed throw → fulfilled → ok recovery, and pass-through for code 0 / non-rate-limit codes. * fix(feishu): break loop on final-attempt fulfilled rate-limit body ClawSweeper review on dc8d3be7d (P1, comment-shared.ts:166) caught a real bug: when the final retry attempt also fulfilled with a rate-limit body (e.g. { code: 11232, ... }), the guard `attempt < FEISHU_SEND_MAX_RETRIES` was false so control fell through to `return result` — bypassing the synthetic-error exhaustion path and handing the rate-limit body to the caller as if it were a successful response. The fulfilled-exhaustion test missed this because Vitest's local fs module cache served the pre-fix shape; running with a fresh cache reproduces the failure. Split the fulfilled-rate-limit branch so the body is always captured, then continue on a non-final attempt or break on the final attempt. Breaking falls through to the synthetic AxiosError-shaped throw below, which is exactly what the existing exhaustion test asserts. * fix(feishu): retry on send rate-limit errors 230020/11232/429 (#89659) (thanks @ladygege) --------- Co-authored-by: marshall.m <marshall.m@binance.com> Co-authored-by: sliverp <870080352@qq.com>
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
/**
|
||
* Concurrent Feishu message send stress tests.
|
||
*
|
||
* Verifies that sendMessageFeishu behaves correctly under concurrent load,
|
||
* including the rate-limit error code (230020) the Feishu API returns when
|
||
* the per-chat request frequency is too high. Related: issue #70879.
|
||
*/
|
||
|
||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||
|
||
const {
|
||
mockClientCreate,
|
||
mockCreateFeishuClient,
|
||
mockResolveFeishuAccount,
|
||
mockConvertMarkdownTables,
|
||
mockResolveMarkdownTableMode,
|
||
} = vi.hoisted(() => ({
|
||
mockClientCreate: vi.fn(),
|
||
mockCreateFeishuClient: vi.fn(),
|
||
mockResolveFeishuAccount: vi.fn(),
|
||
mockConvertMarkdownTables: vi.fn((text: string) => text),
|
||
mockResolveMarkdownTableMode: vi.fn(() => "preserve"),
|
||
}));
|
||
|
||
vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient }));
|
||
vi.mock("./accounts.js", () => ({
|
||
resolveFeishuAccount: mockResolveFeishuAccount,
|
||
resolveFeishuRuntimeAccount: mockResolveFeishuAccount,
|
||
}));
|
||
vi.mock("openclaw/plugin-sdk/markdown-table-runtime", () => ({
|
||
resolveMarkdownTableMode: mockResolveMarkdownTableMode,
|
||
}));
|
||
vi.mock("openclaw/plugin-sdk/text-chunking", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-chunking")>();
|
||
return { ...actual, convertMarkdownTables: mockConvertMarkdownTables };
|
||
});
|
||
vi.mock("./runtime.js", () => ({
|
||
getFeishuRuntime: () => ({
|
||
channel: {
|
||
text: {
|
||
resolveMarkdownTableMode: vi.fn(() => "preserve"),
|
||
convertMarkdownTables: vi.fn((text: string) => text),
|
||
},
|
||
},
|
||
}),
|
||
}));
|
||
|
||
let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu;
|
||
|
||
const MOCK_CFG = {} as ClawdbotConfig;
|
||
|
||
/** Build a successful send response. */
|
||
function okResponse(messageId: string) {
|
||
return { code: 0, data: { message_id: messageId } };
|
||
}
|
||
|
||
/**
|
||
* Build an AxiosError-shaped object for a Feishu rate-limit HTTP 400 response.
|
||
* Mirrors what @larksuiteoapi/node-sdk throws when the server returns code 230020.
|
||
*/
|
||
function axiosRateLimitError(code = 230020) {
|
||
return Object.assign(new Error("Request failed with status code 400"), {
|
||
response: {
|
||
status: 400,
|
||
data: {
|
||
code,
|
||
msg: "This operation triggers the frequency limit, ext=chat rate limit",
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
({ sendMessageFeishu } = await import("./send.js"));
|
||
});
|
||
|
||
afterAll(() => {
|
||
vi.resetModules();
|
||
});
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
mockResolveFeishuAccount.mockReturnValue({ accountId: "default", configured: true });
|
||
mockResolveMarkdownTableMode.mockReturnValue("preserve");
|
||
mockConvertMarkdownTables.mockImplementation((text: string) => text);
|
||
mockCreateFeishuClient.mockReturnValue({
|
||
im: { message: { create: mockClientCreate } },
|
||
});
|
||
});
|
||
|
||
describe("Concurrent Feishu sends — happy path", () => {
|
||
it("all concurrent sends succeed when API responds without errors", async () => {
|
||
const CONCURRENCY = 10;
|
||
let n = 0;
|
||
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_happy_${n++}`)));
|
||
|
||
const results = await Promise.all(
|
||
Array.from({ length: CONCURRENCY }, (_, i) =>
|
||
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_chat_${i}`, text: `Message ${i}` }),
|
||
),
|
||
);
|
||
|
||
expect(results).toHaveLength(CONCURRENCY);
|
||
for (const result of results) {
|
||
expect(result.messageId).toBeTruthy();
|
||
expect(result.receipt).toBeDefined();
|
||
}
|
||
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY);
|
||
});
|
||
|
||
it("sends 20 messages concurrently and all resolve independently", async () => {
|
||
const CONCURRENCY = 20;
|
||
let n = 0;
|
||
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_concurrent_${n++}`)));
|
||
|
||
const results = await Promise.all(
|
||
Array.from({ length: CONCURRENCY }, (_, i) =>
|
||
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_stress", text: `stress-${i}` }),
|
||
),
|
||
);
|
||
|
||
expect(results).toHaveLength(CONCURRENCY);
|
||
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY);
|
||
|
||
// All message IDs should be unique
|
||
const messageIds = results.map((r) => r.messageId);
|
||
expect(new Set(messageIds).size).toBe(CONCURRENCY);
|
||
});
|
||
});
|
||
|
||
describe("Concurrent Feishu sends — rate-limit behavior (code 230020)", () => {
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it("throws on rate-limit code 230020 after exhausting retries", async () => {
|
||
vi.useFakeTimers();
|
||
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
|
||
|
||
// Promise.allSettled attaches a rejection handler synchronously, so when
|
||
// vi.runAllTimersAsync advances timers and fires the rejection, it is
|
||
// already handled. Using expect().rejects.toThrow() would defer the
|
||
// attachment via Promise.resolve().then(), causing an unhandled-rejection
|
||
// warning before the handler is registered.
|
||
const settled = Promise.allSettled([
|
||
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_rl", text: "rate limited" }),
|
||
]);
|
||
await vi.runAllTimersAsync();
|
||
const [result] = await settled;
|
||
|
||
expect(result.status).toBe("rejected");
|
||
// 1 initial attempt + 2 retries = 3 total calls
|
||
expect(mockClientCreate).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("some concurrent sends fail with rate-limit while others succeed", async () => {
|
||
vi.useFakeTimers();
|
||
const HALF = 4;
|
||
let n = 0;
|
||
|
||
// Distinguish sends by receive_id: targets containing "fail" always rate-limit.
|
||
mockClientCreate.mockImplementation((params: { data?: { receive_id?: string } }) => {
|
||
const target = params?.data?.receive_id ?? "";
|
||
if (target.includes("fail")) {
|
||
return Promise.reject(axiosRateLimitError());
|
||
}
|
||
return Promise.resolve(okResponse(`om_ok_${n++}`));
|
||
});
|
||
|
||
const settled = Promise.allSettled([
|
||
...Array.from({ length: HALF }, (_, i) =>
|
||
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_fail_${i}`, text: `fail-${i}` }),
|
||
),
|
||
...Array.from({ length: HALF }, (_, i) =>
|
||
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_ok_${i}`, text: `ok-${i}` }),
|
||
),
|
||
]);
|
||
|
||
await vi.runAllTimersAsync();
|
||
const results = await settled;
|
||
|
||
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(HALF);
|
||
expect(results.filter((r) => r.status === "rejected")).toHaveLength(HALF);
|
||
// Rate-limited sends: HALF × 3 calls (1 + 2 retries); successful sends: HALF × 1 call
|
||
expect(mockClientCreate).toHaveBeenCalledTimes(HALF * 3 + HALF);
|
||
});
|
||
|
||
it("all concurrent sends fail gracefully when API consistently rate-limits", async () => {
|
||
vi.useFakeTimers();
|
||
const CONCURRENCY = 5;
|
||
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
|
||
|
||
const settled = Promise.allSettled(
|
||
Array.from({ length: CONCURRENCY }, (_, i) =>
|
||
sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_all_fail", text: `msg-${i}` }),
|
||
),
|
||
);
|
||
|
||
await vi.runAllTimersAsync();
|
||
const results = await settled;
|
||
|
||
expect(results.every((r) => r.status === "rejected")).toBe(true);
|
||
// Each send retries twice: CONCURRENCY × 3 total calls
|
||
expect(mockClientCreate).toHaveBeenCalledTimes(CONCURRENCY * 3);
|
||
});
|
||
|
||
it("recovers when API rate-limits once then succeeds", async () => {
|
||
vi.useFakeTimers();
|
||
let n = 0;
|
||
mockClientCreate
|
||
.mockRejectedValueOnce(axiosRateLimitError(230020))
|
||
.mockImplementation(() => Promise.resolve(okResponse(`om_recovered_${n++}`)));
|
||
|
||
const sendPromise = sendMessageFeishu({ cfg: MOCK_CFG, to: "oc_recover", text: "recover" });
|
||
await vi.runAllTimersAsync();
|
||
|
||
const result = await sendPromise;
|
||
expect(result.messageId).toMatch(/^om_recovered_/);
|
||
// 1 rate-limited call + 1 successful retry
|
||
expect(mockClientCreate).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("rate-limit error message surfaces feishu_code for caller detection", async () => {
|
||
vi.useFakeTimers();
|
||
mockClientCreate.mockRejectedValue(axiosRateLimitError(230020));
|
||
|
||
// Same pattern: allSettled attaches the handler synchronously before timers advance.
|
||
const settled = Promise.allSettled([
|
||
sendMessageFeishu({
|
||
cfg: MOCK_CFG,
|
||
to: "oc_err_msg",
|
||
text: "check error message",
|
||
}),
|
||
]);
|
||
await vi.runAllTimersAsync();
|
||
const [result] = await settled;
|
||
|
||
expect(result.status).toBe("rejected");
|
||
const error = result.status === "rejected" ? result.reason : null;
|
||
expect(error).toBeInstanceOf(Error);
|
||
// Error message must carry feishu_code so retry/circuit-breaker logic upstream can identify it
|
||
expect((error as Error).message).toMatch(/230020/);
|
||
});
|
||
});
|
||
|
||
describe("Concurrent Feishu sends — timing and ordering", () => {
|
||
it("concurrent sends complete faster than sequential would (all fire in parallel)", async () => {
|
||
const CONCURRENCY = 5;
|
||
const SIMULATED_DELAY_MS = 20;
|
||
let n = 0;
|
||
|
||
mockClientCreate.mockImplementation(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
setTimeout(() => resolve(okResponse(`om_timed_${n++}`)), SIMULATED_DELAY_MS);
|
||
}),
|
||
);
|
||
|
||
const start = Date.now();
|
||
const results = await Promise.all(
|
||
Array.from({ length: CONCURRENCY }, (_, i) =>
|
||
sendMessageFeishu({ cfg: MOCK_CFG, to: `oc_timed_${i}`, text: `msg ${i}` }),
|
||
),
|
||
);
|
||
const elapsed = Date.now() - start;
|
||
|
||
expect(results).toHaveLength(CONCURRENCY);
|
||
// Concurrent: should complete in roughly 1x delay, not CONCURRENCY * delay
|
||
expect(elapsed).toBeLessThan(SIMULATED_DELAY_MS * CONCURRENCY);
|
||
});
|
||
|
||
it("sends to multiple distinct targets resolve independently", async () => {
|
||
const targets = ["oc_alpha", "oc_beta", "oc_gamma"];
|
||
let n = 0;
|
||
mockClientCreate.mockImplementation(() => Promise.resolve(okResponse(`om_target_${n++}`)));
|
||
|
||
const results = await Promise.all(
|
||
targets.map((to) => sendMessageFeishu({ cfg: MOCK_CFG, to, text: "hello" })),
|
||
);
|
||
|
||
expect(results).toHaveLength(targets.length);
|
||
for (const result of results) {
|
||
expect(result.messageId).toBeTruthy();
|
||
}
|
||
expect(mockClientCreate).toHaveBeenCalledTimes(targets.length);
|
||
});
|
||
});
|