diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts
index 3f90477fd89..04a9ac5fa1d 100644
--- a/extensions/discord/src/api.test.ts
+++ b/extensions/discord/src/api.test.ts
@@ -100,6 +100,32 @@ describe("fetchDiscord", () => {
expect(message).not.toContain(" {
+ const fetcher = withFetchPreconnect(
+ async () =>
+ new Response("
Error 1015rate limited", {
+ status: 429,
+ headers: { "content-type": "text/html", "retry-after": header },
+ }),
+ );
+
+ let error: unknown;
+ try {
+ await fetchDiscord("/oauth2/applications/@me", "test", fetcher, {
+ retry: { attempts: 1 },
+ });
+ } catch (err) {
+ error = err;
+ }
+
+ expect(error).toBeInstanceOf(DiscordApiError);
+ expect((error as DiscordApiError).retryAfter).toBe(60);
+ });
+
it("retries rate limits before succeeding", async () => {
let calls = 0;
const fetcher = withFetchPreconnect(async () => {
diff --git a/extensions/discord/src/api.ts b/extensions/discord/src/api.ts
index 6daa72751b3..be9fe7ad832 100644
--- a/extensions/discord/src/api.ts
+++ b/extensions/discord/src/api.ts
@@ -5,6 +5,7 @@ import {
type RetryConfig,
} from "openclaw/plugin-sdk/retry-runtime";
import { isDiscordHtmlResponseBody, summarizeDiscordResponseBody } from "./error-body.js";
+import { parseRetryAfterHeaderSeconds } from "./retry-after.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const DISCORD_API_RETRY_DEFAULTS = {
@@ -51,15 +52,7 @@ function parseRetryAfterSeconds(text: string, response: Response): number | unde
if (!header) {
return undefined;
}
- const parsed = Number(header);
- if (Number.isFinite(parsed) && parsed >= 0) {
- return parsed;
- }
- const retryAt = Date.parse(header);
- if (!Number.isFinite(retryAt)) {
- return undefined;
- }
- return Math.max(0, (retryAt - Date.now()) / 1000);
+ return parseRetryAfterHeaderSeconds(header);
}
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
diff --git a/extensions/discord/src/internal/rest-errors.ts b/extensions/discord/src/internal/rest-errors.ts
index a24c07e8a9f..9be3eabeb42 100644
--- a/extensions/discord/src/internal/rest-errors.ts
+++ b/extensions/discord/src/internal/rest-errors.ts
@@ -1,3 +1,5 @@
+import { parseDiscordRetryAfterBodySeconds, parseRetryAfterHeaderSeconds } from "../retry-after.js";
+
export function readDiscordCode(body: unknown): number | undefined {
const value =
body && typeof body === "object" && "code" in body
@@ -12,9 +14,6 @@ export function readDiscordCode(body: unknown): number | undefined {
return undefined;
}
-const RETRY_AFTER_HEADER_DELAY_RE = /^\d+$/;
-const RETRY_AFTER_BODY_SECONDS_RE = /^(?:\d+\.?\d*|\.\d+)$/;
-
export function readDiscordMessage(body: unknown, fallback: string): string {
const value =
body && typeof body === "object" && "message" in body
@@ -23,36 +22,14 @@ export function readDiscordMessage(body: unknown, fallback: string): string {
return typeof value === "string" && value.trim() ? value : fallback;
}
-function readRetryAfterHeader(value: string | null, now = Date.now()): number | undefined {
- if (!value) {
- return undefined;
- }
- const trimmed = value.trim();
- if (RETRY_AFTER_HEADER_DELAY_RE.test(trimmed)) {
- return Number(trimmed);
- }
- const retryAt = Date.parse(trimmed);
- return Number.isFinite(retryAt) ? (retryAt - now) / 1000 : undefined;
-}
-
-function coerceRetryAfterSeconds(value: unknown): number | undefined {
- const seconds =
- typeof value === "number"
- ? value
- : typeof value === "string" && RETRY_AFTER_BODY_SECONDS_RE.test(value.trim())
- ? Number(value.trim())
- : undefined;
- return Number.isFinite(seconds) && seconds >= 0 ? Math.max(0, seconds) : undefined;
-}
-
export function readRetryAfter(body: unknown, response: Response, fallbackSeconds = 0): number {
const bodyValue =
body && typeof body === "object" && "retry_after" in body
? (body as { retry_after?: unknown }).retry_after
: undefined;
return (
- coerceRetryAfterSeconds(bodyValue) ??
- coerceRetryAfterSeconds(readRetryAfterHeader(response.headers.get("Retry-After"))) ??
+ parseDiscordRetryAfterBodySeconds(bodyValue) ??
+ parseRetryAfterHeaderSeconds(response.headers.get("Retry-After")) ??
fallbackSeconds
);
}
diff --git a/extensions/discord/src/internal/rest.test.ts b/extensions/discord/src/internal/rest.test.ts
index d5bd342c048..f3f18feadc6 100644
--- a/extensions/discord/src/internal/rest.test.ts
+++ b/extensions/discord/src/internal/rest.test.ts
@@ -532,13 +532,17 @@ describe("RequestClient", () => {
await expectRateLimitError(client.get("/channels/c1/messages"), { retryAfter: 7 });
});
- it("rejects non-decimal Retry-After numeric strings", async () => {
+ it.each([
+ ["hex", "0x10"],
+ ["fractional", "1.5"],
+ ["overflow", `1${"0".repeat(309)}`],
+ ])("rejects invalid Retry-After numeric strings: %s", async (_label, header) => {
const client = new RequestClient("test-token", {
queueRequests: false,
fetch: async () =>
new Response(JSON.stringify({ message: "Slow down", retry_after: "1e3", global: false }), {
status: 429,
- headers: { "Retry-After": "0x10" },
+ headers: { "Retry-After": header },
}),
});
diff --git a/extensions/discord/src/retry-after.ts b/extensions/discord/src/retry-after.ts
new file mode 100644
index 00000000000..92ea42019ce
--- /dev/null
+++ b/extensions/discord/src/retry-after.ts
@@ -0,0 +1,33 @@
+const RETRY_AFTER_HEADER_DELAY_RE = /^\d+$/;
+const RETRY_AFTER_BODY_SECONDS_RE = /^(?:\d+\.?\d*|\.\d+)$/;
+const RETRY_AFTER_HTTP_DATE_RE =
+ /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
+
+export function parseRetryAfterHeaderSeconds(
+ value: string | null | undefined,
+ now = Date.now(),
+): number | undefined {
+ if (!value) {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ if (RETRY_AFTER_HEADER_DELAY_RE.test(trimmed)) {
+ const delaySeconds = Number(trimmed);
+ return Number.isFinite(delaySeconds) ? delaySeconds : undefined;
+ }
+ if (!RETRY_AFTER_HTTP_DATE_RE.test(trimmed)) {
+ return undefined;
+ }
+ const retryAt = Date.parse(trimmed);
+ return Number.isFinite(retryAt) ? Math.max(0, (retryAt - now) / 1000) : undefined;
+}
+
+export function parseDiscordRetryAfterBodySeconds(value: unknown): number | undefined {
+ const seconds =
+ typeof value === "number"
+ ? value
+ : typeof value === "string" && RETRY_AFTER_BODY_SECONDS_RE.test(value.trim())
+ ? Number(value.trim())
+ : undefined;
+ return seconds !== undefined && Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
+}