From 5eee488d9307946dfc6cf4b06af6b2cfa1a7a148 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 28 May 2026 14:12:33 -0400 Subject: [PATCH] fix: parse discord api retry headers strictly --- extensions/discord/src/api.test.ts | 26 +++++++++++++++ extensions/discord/src/api.ts | 11 ++----- .../discord/src/internal/rest-errors.ts | 31 +++-------------- extensions/discord/src/internal/rest.test.ts | 8 +++-- extensions/discord/src/retry-after.ts | 33 +++++++++++++++++++ 5 files changed, 71 insertions(+), 38 deletions(-) create mode 100644 extensions/discord/src/retry-after.ts 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; +}