mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-01 21:25:52 +00:00
fix: parse discord api retry headers strictly
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
33
extensions/discord/src/retry-after.ts
Normal file
33
extensions/discord/src/retry-after.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user