Files
openclaw/extensions/feishu/src/send.concurrent.test.ts
T-800 84acb74a6a fix(feishu): retry on send rate-limit errors (230020/230006) (#89659)
* 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>
2026-06-09 11:34:21 +08:00

289 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});