fix(discord): reject unsafe retry-after delays

This commit is contained in:
Peter Steinberger
2026-05-29 10:58:36 -04:00
parent f2ba23424e
commit fb37811b65
4 changed files with 63 additions and 10 deletions

View File

@@ -103,6 +103,8 @@ describe("fetchDiscord", () => {
it.each([
["hex", "0x10"],
["fractional", "1.5"],
["unsafe-ms", "9007199254741"],
["unsafe-integer", "9007199254740993"],
["overflow", `1${"0".repeat(309)}`],
])("rejects invalid Retry-After header values: %s", async (_label, header) => {
const fetcher = withFetchPreconnect(
@@ -126,6 +128,33 @@ describe("fetchDiscord", () => {
expect((error as DiscordApiError).retryAfter).toBe(60);
});
it("ignores unsafe retry_after body values and falls back to Retry-After", async () => {
const fetcher = withFetchPreconnect(
async () =>
new Response(
JSON.stringify({
message: "You are being rate limited.",
retry_after: 9_007_199_254_741,
global: false,
}),
{ status: 429, headers: { "retry-after": "7" } },
),
);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect((error as DiscordApiError).retryAfter).toBe(7);
expect(String(error)).not.toContain("retry after");
});
it("retries rate limits before succeeding", async () => {
let calls = 0;
const fetcher = withFetchPreconnect(async () => {

View File

@@ -5,7 +5,7 @@ import {
type RetryConfig,
} from "openclaw/plugin-sdk/retry-runtime";
import { isDiscordHtmlResponseBody, summarizeDiscordResponseBody } from "./error-body.js";
import { parseRetryAfterHeaderSeconds } from "./retry-after.js";
import { parseDiscordRetryAfterBodySeconds, parseRetryAfterHeaderSeconds } from "./retry-after.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const DISCORD_API_RETRY_DEFAULTS = {
@@ -41,10 +41,7 @@ function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | nul
function parseRetryAfterSeconds(text: string, response: Response): number | undefined {
const payload = parseDiscordApiErrorPayload(text);
const retryAfter =
payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after)
? payload.retry_after
: undefined;
const retryAfter = parseDiscordRetryAfterBodySeconds(payload?.retry_after);
if (retryAfter !== undefined) {
return retryAfter;
}
@@ -88,7 +85,7 @@ function formatDiscordApiErrorText(text: string, response: Response): string | u
? payload.message.trim()
: "unknown error";
const retryAfter = formatRetryAfterSeconds(
typeof payload.retry_after === "number" ? payload.retry_after : undefined,
parseDiscordRetryAfterBodySeconds(payload.retry_after),
);
return retryAfter ? `${message} (retry after ${retryAfter})` : message;
}

View File

@@ -620,9 +620,27 @@ describe("RequestClient", () => {
await expectRateLimitError(client.get("/channels/c1/messages"), { retryAfter: 7 });
});
it("falls back to Retry-After when the rate limit body value is unsafe", async () => {
const client = new RequestClient("test-token", {
queueRequests: false,
fetch: async () =>
new Response(
JSON.stringify({ message: "Slow down", retry_after: "9007199254741", global: false }),
{
status: 429,
headers: { "Retry-After": "7" },
},
),
});
await expectRateLimitError(client.get("/channels/c1/messages"), { retryAfter: 7 });
});
it.each([
["hex", "0x10"],
["fractional", "1.5"],
["unsafe-ms", "9007199254741"],
["unsafe-integer", "9007199254740993"],
["overflow", `1${"0".repeat(309)}`],
])("rejects invalid Retry-After numeric strings: %s", async (_label, header) => {
const client = new RequestClient("test-token", {

View File

@@ -1,7 +1,14 @@
import {
asFiniteNumberInRange,
parseStrictFiniteNumber,
parseStrictNonNegativeInteger,
} from "openclaw/plugin-sdk/number-runtime";
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$/;
const MAX_SAFE_RETRY_AFTER_SECONDS = Number.MAX_SAFE_INTEGER / 1000;
export function parseRetryAfterHeaderSeconds(
value: string | null | undefined,
@@ -12,8 +19,10 @@ export function parseRetryAfterHeaderSeconds(
}
const trimmed = value.trim();
if (RETRY_AFTER_HEADER_DELAY_RE.test(trimmed)) {
const delaySeconds = Number(trimmed);
return Number.isFinite(delaySeconds) ? delaySeconds : undefined;
return asFiniteNumberInRange(parseStrictNonNegativeInteger(trimmed), {
min: 0,
max: MAX_SAFE_RETRY_AFTER_SECONDS,
});
}
if (!RETRY_AFTER_HTTP_DATE_RE.test(trimmed)) {
return undefined;
@@ -27,7 +36,7 @@ export function parseDiscordRetryAfterBodySeconds(value: unknown): number | unde
typeof value === "number"
? value
: typeof value === "string" && RETRY_AFTER_BODY_SECONDS_RE.test(value.trim())
? Number(value.trim())
? parseStrictFiniteNumber(value.trim())
: undefined;
return seconds !== undefined && Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
return asFiniteNumberInRange(seconds, { min: 0, max: MAX_SAFE_RETRY_AFTER_SECONDS });
}