fix: parse discord api retry headers strictly

This commit is contained in:
Peter Steinberger
2026-05-28 14:12:33 -04:00
parent 1d28dd87a5
commit 5eee488d93
5 changed files with 71 additions and 38 deletions

View File

@@ -100,6 +100,32 @@ describe("fetchDiscord", () => {
expect(message).not.toContain("<html");
});
it.each([
["hex", "0x10"],
["fractional", "1.5"],
["overflow", `1${"0".repeat(309)}`],
])("rejects invalid Retry-After header values: %s", async (_label, header) => {
const fetcher = withFetchPreconnect(
async () =>
new Response("<html><title>Error 1015</title><body>rate limited</body></html>", {
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 () => {

View File

@@ -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 {

View File

@@ -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
);
}

View File

@@ -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 },
}),
});

View File

@@ -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;
}