From 2fd0f88f62e5f33fb9ec4f0842906f9a23336074 Mon Sep 17 00:00:00 2001 From: qingminlong Date: Fri, 10 Jul 2026 11:02:44 +0800 Subject: [PATCH] fix: ignore invalid Retry-After HTTP dates (#102987) * fix: ignore invalid Retry-After HTTP dates * fix(ai): centralize strict Retry-After dates --------- Co-authored-by: Peter Steinberger Co-authored-by: Peter Steinberger --- packages/ai/src/internal/retry-after.test.ts | 58 +++++++ packages/ai/src/internal/retry-after.ts | 150 ++++++++++++++++++ .../openai-chatgpt-responses.test.ts | 145 +++++++++++++---- .../src/providers/openai-chatgpt-responses.ts | 11 +- src/agents/provider-transport-fetch.test.ts | 44 +++++ src/agents/provider-transport-fetch.ts | 60 +------ src/plugin-sdk/root-alias.cjs | 4 + .../contracts/plugin-sdk-root-alias.test.ts | 3 + .../plugin-sdk-native-resolver.test.ts | 10 ++ src/plugins/plugin-sdk-native-resolver.ts | 1 + tsdown.ai.config.ts | 1 + 11 files changed, 390 insertions(+), 97 deletions(-) create mode 100644 packages/ai/src/internal/retry-after.test.ts create mode 100644 packages/ai/src/internal/retry-after.ts diff --git a/packages/ai/src/internal/retry-after.test.ts b/packages/ai/src/internal/retry-after.test.ts new file mode 100644 index 00000000000..f8399fa8c3c --- /dev/null +++ b/packages/ai/src/internal/retry-after.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { parseRetryAfterHttpDateMs } from "./retry-after.js"; + +const EXAMPLE_TIMESTAMP = Date.UTC(1994, 10, 6, 8, 49, 37); + +describe("parseRetryAfterHttpDateMs", () => { + it.each([ + ["IMF-fixdate", "Sun, 06 Nov 1994 08:49:37 GMT"], + ["RFC 850", "Sunday, 06-Nov-94 08:49:37 GMT"], + ["asctime single-digit day", "Sun Nov 6 08:49:37 1994"], + ["asctime two-digit day", "Sun Nov 06 08:49:37 1994"], + ])("parses %s", (_label, value) => { + expect(parseRetryAfterHttpDateMs(value, Date.UTC(1994, 0, 1))).toBe(EXAMPLE_TIMESTAMP); + }); + + it.each([ + "Sun, 31 Feb 2027 00:00:00 GMT", + "Sunday, 31-Feb-27 00:00:00 GMT", + "Sun Feb 31 00:00:00 2027", + "Thu, 29 Feb 2027 00:00:00 GMT", + ])("rejects an invalid calendar date: %s", (value) => { + expect(parseRetryAfterHttpDateMs(value)).toBeUndefined(); + }); + + it.each([ + "Mon, 06 Nov 1994 08:49:37 GMT", + "Monday, 06-Nov-94 08:49:37 GMT", + "Mon Nov 6 08:49:37 1994", + ])("rejects a weekday that does not match the date: %s", (value) => { + expect(parseRetryAfterHttpDateMs(value, Date.UTC(1994, 0, 1))).toBeUndefined(); + }); + + it("accepts the HTTP-date leap-second range", () => { + expect(parseRetryAfterHttpDateMs("Sat, 31 Dec 2016 23:59:60 GMT")).toBe(Date.UTC(2017, 0, 1)); + }); + + it("uses the RFC 850 rolling 50-year rule before validating the weekday", () => { + const now = Date.UTC(2026, 10, 6); + expect(parseRetryAfterHttpDateMs("Sunday, 06-Nov-50 00:00:00 GMT", now)).toBe( + Date.UTC(2050, 10, 6), + ); + expect(parseRetryAfterHttpDateMs("Sunday, 06-Nov-77 00:00:00 GMT", now)).toBe( + Date.UTC(1977, 10, 6), + ); + }); + + it.each([ + "sun, 06 Nov 1994 08:49:37 GMT", + "Sun, 06 Nov 1899 08:49:37 GMT", + "Sun, 06 Nov 1994 24:00:00 GMT", + "Sun, 06 Nov 1994 08:60:00 GMT", + "Sun, 06 Nov 1994 08:49:61 GMT", + "Sun, 6 Nov 1994 08:49:37 GMT", + "Sun Nov 6 08:49:37 1994", + ])("rejects a value outside the HTTP-date grammar: %s", (value) => { + expect(parseRetryAfterHttpDateMs(value)).toBeUndefined(); + }); +}); diff --git a/packages/ai/src/internal/retry-after.ts b/packages/ai/src/internal/retry-after.ts new file mode 100644 index 00000000000..3606abdbbcc --- /dev/null +++ b/packages/ai/src/internal/retry-after.ts @@ -0,0 +1,150 @@ +const HTTP_DATE_MONTH_INDEX = new Map( + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].map( + (month, index) => [month, index], + ), +); +const HTTP_DATE_SHORT_WEEKDAY_INDEX = new Map( + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((weekday, index) => [weekday, index]), +); +const HTTP_DATE_LONG_WEEKDAY_INDEX = new Map( + ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].map( + (weekday, index) => [weekday, index], + ), +); + +const IMF_FIXDATE_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 OBSOLETE_RFC850_DATE_RE = + /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/; +const OBSOLETE_ASCTIME_DATE_RE = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{2}| \d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; + +type HttpDateComponents = { + weekday: number | undefined; + year: number; + month: number | undefined; + day: number; + hours: number; + minutes: number; + seconds: number; +}; + +/** Parses the three HTTP-date forms accepted for Retry-After without Date.parse normalization. */ +export function parseRetryAfterHttpDateMs(value: string, nowMs = Date.now()): number | undefined { + const imfFixdate = IMF_FIXDATE_RE.exec(value); + if (imfFixdate) { + return parseHttpDateComponentsMs({ + weekday: HTTP_DATE_SHORT_WEEKDAY_INDEX.get(imfFixdate[1] ?? ""), + year: Number.parseInt(imfFixdate[4] ?? "", 10), + month: HTTP_DATE_MONTH_INDEX.get(imfFixdate[3] ?? ""), + day: Number.parseInt(imfFixdate[2] ?? "", 10), + hours: Number.parseInt(imfFixdate[5] ?? "", 10), + minutes: Number.parseInt(imfFixdate[6] ?? "", 10), + seconds: Number.parseInt(imfFixdate[7] ?? "", 10), + }); + } + + const rfc850Date = OBSOLETE_RFC850_DATE_RE.exec(value); + if (rfc850Date) { + const now = new Date(nowMs); + if (Number.isNaN(now.getTime())) { + return undefined; + } + const shortYear = Number.parseInt(rfc850Date[4] ?? "", 10); + const candidateYear = Math.floor(now.getUTCFullYear() / 100) * 100 + shortYear; + const components = { + weekday: HTTP_DATE_LONG_WEEKDAY_INDEX.get(rfc850Date[1] ?? ""), + month: HTTP_DATE_MONTH_INDEX.get(rfc850Date[3] ?? ""), + day: Number.parseInt(rfc850Date[2] ?? "", 10), + hours: Number.parseInt(rfc850Date[5] ?? "", 10), + minutes: Number.parseInt(rfc850Date[6] ?? "", 10), + seconds: Number.parseInt(rfc850Date[7] ?? "", 10), + }; + const candidate = parseHttpDateCalendarMs({ year: candidateYear, ...components }); + if (candidate === undefined) { + return undefined; + } + // RFC 9110 resolves two-digit years against the current century, then rolls + // dates more than 50 years ahead back by 100 years. + const fiftyYearsFromNow = Date.UTC( + now.getUTCFullYear() + 50, + now.getUTCMonth(), + now.getUTCDate(), + now.getUTCHours(), + now.getUTCMinutes(), + now.getUTCSeconds(), + now.getUTCMilliseconds(), + ); + const resolvedYear = candidate > fiftyYearsFromNow ? candidateYear - 100 : candidateYear; + return parseHttpDateComponentsMs({ year: resolvedYear, ...components }); + } + + const asctimeDate = OBSOLETE_ASCTIME_DATE_RE.exec(value); + if (asctimeDate) { + return parseHttpDateComponentsMs({ + weekday: HTTP_DATE_SHORT_WEEKDAY_INDEX.get(asctimeDate[1] ?? ""), + year: Number.parseInt(asctimeDate[7] ?? "", 10), + month: HTTP_DATE_MONTH_INDEX.get(asctimeDate[2] ?? ""), + day: Number.parseInt((asctimeDate[3] ?? "").trim(), 10), + hours: Number.parseInt(asctimeDate[4] ?? "", 10), + minutes: Number.parseInt(asctimeDate[5] ?? "", 10), + seconds: Number.parseInt(asctimeDate[6] ?? "", 10), + }); + } + + return undefined; +} + +function parseHttpDateComponentsMs(components: HttpDateComponents): number | undefined { + const timestamp = parseHttpDateCalendarMs(components); + if (timestamp === undefined) { + return undefined; + } + const weekdayTimestamp = components.seconds === 60 ? timestamp - 1_000 : timestamp; + if (new Date(weekdayTimestamp).getUTCDay() !== components.weekday) { + return undefined; + } + return timestamp; +} + +function parseHttpDateCalendarMs( + components: Omit, +): number | undefined { + const { year, month, day, hours, minutes, seconds } = components; + if ( + month === undefined || + !Number.isInteger(year) || + year < 1900 || + !Number.isInteger(day) || + day < 1 || + day > 31 || + !Number.isInteger(hours) || + hours < 0 || + hours > 23 || + !Number.isInteger(minutes) || + minutes < 0 || + minutes > 59 || + !Number.isInteger(seconds) || + seconds < 0 || + seconds > 60 + ) { + return undefined; + } + + const calendarSecond = Math.min(seconds, 59); + // JavaScript Date has no :60 representation. Validate the stated calendar + // second against :59, then advance to the leap-second instant. + const timestamp = Date.UTC(year, month, day, hours, minutes, calendarSecond); + const parsedDate = new Date(timestamp); + if ( + parsedDate.getUTCFullYear() !== year || + parsedDate.getUTCMonth() !== month || + parsedDate.getUTCDate() !== day || + parsedDate.getUTCHours() !== hours || + parsedDate.getUTCMinutes() !== minutes || + parsedDate.getUTCSeconds() !== calendarSecond + ) { + return undefined; + } + return seconds === 60 ? timestamp + 1_000 : timestamp; +} diff --git a/packages/ai/src/providers/openai-chatgpt-responses.test.ts b/packages/ai/src/providers/openai-chatgpt-responses.test.ts index 20974b189b8..b428b794a44 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.test.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.test.ts @@ -822,44 +822,121 @@ describe("streamOpenAICodexResponses transport", () => { expect(payload).toMatchObject({ prompt_cache_key: "stable-cache-key" }); }); - it.each(["1.5", "0x10"])( - "ignores invalid Retry-After header delay values: %s", - async (retryAfter) => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response("rate limited", { - status: 429, - headers: { "retry-after": retryAfter }, - }), - ) - .mockRejectedValueOnce(new Error("usage limit: stop after retry delay")); - vi.stubGlobal("fetch", fetchMock); - const setTimeoutSpy = vi - .spyOn(globalThis, "setTimeout") - .mockImplementation((callback: TimerHandler) => { - if (typeof callback === "function") { - callback(); - } - return 0 as unknown as ReturnType; - }); - - const stream = streamOpenAICodexResponses(model, context, { - apiKey: createJwt({ - "https://api.openai.com/auth": { - chatgpt_account_id: "acct-1", - }, + it.each([ + "1.5", + "0x10", + "Sun, 31 Feb 2027 00:00:00 GMT", + "Sunday, 31-Feb-27 00:00:00 GMT", + "Mon, 06 Nov 1994 08:49:37 GMT", + "Monday, 06-Nov-94 08:49:37 GMT", + ])("ignores invalid Retry-After header delay values: %s", async (retryAfter) => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response("rate limited", { + status: 429, + headers: { "retry-after": retryAfter }, }), - transport: "sse", + ) + .mockRejectedValueOnce(new Error("usage limit: stop after retry delay")); + vi.stubGlobal("fetch", fetchMock); + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback: TimerHandler) => { + if (typeof callback === "function") { + callback(); + } + return 0 as unknown as ReturnType; }); - const result = await stream.result(); + const stream = streamOpenAICodexResponses(model, context, { + apiKey: createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-1", + }, + }), + transport: "sse", + }); - expect(result.stopReason).toBe("error"); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000); - }, - ); + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it("honors retry-after-ms ahead of Retry-After", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response("rate limited", { + status: 429, + headers: { "retry-after-ms": "1250", "retry-after": "9" }, + }), + ) + .mockRejectedValueOnce(new Error("usage limit: stop after retry delay")); + vi.stubGlobal("fetch", fetchMock); + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback: TimerHandler) => { + if (typeof callback === "function") { + callback(); + } + return 0 as unknown as ReturnType; + }); + + const stream = streamOpenAICodexResponses(model, context, { + apiKey: createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-1", + }, + }), + transport: "sse", + }); + + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1250); + }); + + it("honors RFC 850 Retry-After years within the 50-year future window", async () => { + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-11-06T00:00:00.000Z")); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response("rate limited", { + status: 429, + headers: { "retry-after": "Sunday, 06-Nov-50 00:00:00 GMT" }, + }), + ) + .mockRejectedValueOnce(new Error("usage limit: stop after retry delay")); + vi.stubGlobal("fetch", fetchMock); + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((callback: TimerHandler) => { + if (typeof callback === "function") { + callback(); + } + return 0 as unknown as ReturnType; + }); + + const stream = streamOpenAICodexResponses(model, context, { + apiKey: createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-1", + }, + }), + transport: "sse", + }); + + const result = await stream.result(); + + expect(result.stopReason).toBe("error"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); + }); it("caps oversized Retry-After delays before sleeping", async () => { const fetchMock = vi diff --git a/packages/ai/src/providers/openai-chatgpt-responses.ts b/packages/ai/src/providers/openai-chatgpt-responses.ts index e973257b4ce..15f9ec3bd66 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.ts @@ -32,6 +32,7 @@ import { } from "@openclaw/normalization-core/number-coercion"; import { getEnvApiKey } from "../env-api-keys.js"; import { getAiTransportHost, resolveAiTransportHeaderSentinels } from "../host.js"; +import { parseRetryAfterHttpDateMs } from "../internal/retry-after.js"; import { registerSessionResourceCleanup } from "../session-resources.js"; import type { Api, @@ -75,8 +76,6 @@ const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; const REQUEST_COMPRESSION_ZSTD_LEVEL = 3; -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|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/; const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "opencode"]); const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009; const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"; @@ -428,10 +427,10 @@ export const streamOpenAICodexResponses: StreamFunction< const seconds = Number(trimmedRetryAfter); if (/^\d+$/.test(trimmedRetryAfter) && Number.isFinite(seconds)) { delayMs = clampTimerTimeoutMs(seconds * 1000, 0) ?? delayMs; - } else if (RETRY_AFTER_HTTP_DATE_RE.test(trimmedRetryAfter)) { - const date = Date.parse(trimmedRetryAfter); - if (!Number.isNaN(date)) { - delayMs = clampTimerTimeoutMs(date - Date.now(), 0) ?? delayMs; + } else { + const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfter); + if (retryAt !== undefined) { + delayMs = clampTimerTimeoutMs(retryAt - Date.now(), 0) ?? delayMs; } } } diff --git a/src/agents/provider-transport-fetch.test.ts b/src/agents/provider-transport-fetch.test.ts index 7a7950a9a0c..703f5bb83d4 100644 --- a/src/agents/provider-transport-fetch.test.ts +++ b/src/agents/provider-transport-fetch.test.ts @@ -1981,6 +1981,50 @@ describe("buildGuardedModelFetch", () => { expect(response.headers.get("x-should-retry")).toBeNull(); }); + it.each([ + "Sun, 31 Feb 2027 00:00:00 GMT", + "Sunday, 31-Feb-27 00:00:00 GMT", + "Mon, 06 Nov 1994 08:49:37 GMT", + "Monday, 06-Nov-94 08:49:37 GMT", + ])("ignores invalid HTTP-date retry-after values: %s", async (retryAfter) => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(null, { + status: 503, + headers: { "retry-after": retryAfter }, + }), + finalUrl: "https://api.anthropic.com/v1/messages", + release: vi.fn(async () => undefined), + }); + const response = await buildGuardedModelFetch(anthropicModel)( + "https://api.anthropic.com/v1/messages", + { method: "POST" }, + ); + + expect(response.headers.get("x-should-retry")).toBeNull(); + }); + + it("interprets RFC 850 retry-after years within the 50-year future window", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-11-06T00:00:00.000Z")); + try { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(null, { + status: 503, + headers: { "retry-after": "Sunday, 06-Nov-50 00:00:00 GMT" }, + }), + finalUrl: "https://api.anthropic.com/v1/messages", + release: vi.fn(async () => undefined), + }); + const response = await buildGuardedModelFetch(anthropicModel)( + "https://api.anthropic.com/v1/messages", + { method: "POST" }, + ); + + expect(response.headers.get("x-should-retry")).toBe("false"); + } finally { + nowSpy.mockRestore(); + } + }); + it("respects OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS", async () => { process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS = "10"; fetchWithSsrFGuardMock.mockResolvedValue({ diff --git a/src/agents/provider-transport-fetch.ts b/src/agents/provider-transport-fetch.ts index 8a666afbfb7..79746afcc21 100644 --- a/src/agents/provider-transport-fetch.ts +++ b/src/agents/provider-transport-fetch.ts @@ -3,6 +3,7 @@ * * Applies request timeouts, proxy/TLS overrides, SSRF policy, local-service leases, retry hints, and SSE normalization. */ +import { parseRetryAfterHttpDateMs } from "@openclaw/ai/internal/retry-after"; import { isCloudMetadataIpAddress, isLinkLocalIpAddress, @@ -66,15 +67,6 @@ const SSE_SANITIZE_BUFFER_MAX_CHARS = 16 * 1024 * 1024; const BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS = new Set(["instance-data"]); const PLAIN_DECIMAL_NUMBER_RE = /^\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|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/; -const HTTP_DATE_MONTH_INDEX = new Map( - ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].map( - (month, index) => [month, index], - ), -); -const OBSOLETE_ASCTIME_HTTP_DATE_RE = - /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; function hasReadableSseData(block: string): boolean { const dataLines = block @@ -483,60 +475,14 @@ function parseRetryAfterSeconds(headers: Headers): number | undefined { return parseStrictNonNegativeInteger(trimmedRetryAfterSeconds) ?? Number.POSITIVE_INFINITY; } - const trimmedRetryAfter = trimmedRetryAfterSeconds; - if (!RETRY_AFTER_HTTP_DATE_RE.test(trimmedRetryAfter)) { - return undefined; - } - - const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfter); - if (Number.isNaN(retryAt)) { + const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfterSeconds); + if (retryAt === undefined) { return undefined; } return Math.max(0, (retryAt - Date.now()) / 1000); } -function parseRetryAfterHttpDateMs(value: string): number { - const match = OBSOLETE_ASCTIME_HTTP_DATE_RE.exec(value); - if (match) { - const month = HTTP_DATE_MONTH_INDEX.get(match[1] ?? ""); - if (month === undefined) { - return Number.NaN; - } - const year = Number.parseInt(match[6] ?? "", 10); - const day = Number.parseInt((match[2] ?? "").trim(), 10); - const hours = Number.parseInt(match[3] ?? "", 10); - const minutes = Number.parseInt(match[4] ?? "", 10); - const seconds = Number.parseInt(match[5] ?? "", 10); - if ( - day < 1 || - day > 31 || - hours > 23 || - minutes > 59 || - seconds > 59 || - [year, day, hours, minutes, seconds].some((component) => !Number.isFinite(component)) - ) { - return Number.NaN; - } - const timestamp = Date.UTC(year, month, day, hours, minutes, seconds); - const parsedDate = new Date(timestamp); - return parsedDate.getUTCFullYear() === year && - parsedDate.getUTCMonth() === month && - parsedDate.getUTCDate() === day && - parsedDate.getUTCHours() === hours && - parsedDate.getUTCMinutes() === minutes && - parsedDate.getUTCSeconds() === seconds - ? timestamp - : Number.NaN; - } - - const parsed = Date.parse(value); - if (!Number.isNaN(parsed)) { - return parsed; - } - return Number.NaN; -} - function resolveMaxSdkRetryWaitSeconds(): number | undefined { const raw = process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS?.trim(); if (!raw) { diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index c8ff1bb7de9..a0c5b87cd8c 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -45,6 +45,10 @@ const workspacePackageAliasEntries = { srcFile: "src/internal/openai.ts", distFile: "dist/internal/openai.mjs", }, + "internal/retry-after": { + srcFile: "src/internal/retry-after.ts", + distFile: "dist/internal/retry-after.mjs", + }, "internal/runtime": { srcFile: "src/internal/runtime.ts", distFile: "dist/internal/runtime.mjs", diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts index 17e1f58e1f5..43b18947600 100644 --- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts +++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts @@ -507,6 +507,7 @@ describe("plugin-sdk root alias", () => { it("keeps AI runtime transitive package imports on the source graph", () => { const packageRoot = path.dirname(path.dirname(path.dirname(rootAliasPath))); const sourcePaths = { + aiRetryAfter: path.join(packageRoot, "packages", "ai", "src", "internal", "retry-after.ts"), aiRuntime: path.join(packageRoot, "packages", "ai", "src", "internal", "runtime.ts"), codeSpans: path.join(packageRoot, "packages", "markdown-core", "src", "code-spans.ts"), fences: path.join(packageRoot, "packages", "markdown-core", "src", "fences.ts"), @@ -525,6 +526,7 @@ describe("plugin-sdk root alias", () => { expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded"); const aliasMap = (lazyModule.createJitiOptions.at(-1)?.alias ?? {}) as Record; + expect(aliasMap["@openclaw/ai/internal/retry-after"]).toBe(sourcePaths.aiRetryAfter); expect(aliasMap["@openclaw/ai/internal/runtime"]).toBe(sourcePaths.aiRuntime); expect(aliasMap["@openclaw/markdown-core/code-spans"]).toBe(sourcePaths.codeSpans); expect(aliasMap["@openclaw/markdown-core/fences"]).toBe(sourcePaths.fences); @@ -570,6 +572,7 @@ describe("plugin-sdk root alias", () => { "@openclaw/ai/validation", "@openclaw/ai/internal/anthropic", "@openclaw/ai/internal/openai", + "@openclaw/ai/internal/retry-after", "@openclaw/ai/internal/runtime", "@openclaw/ai/internal/shared", "@openclaw/markdown-core", diff --git a/src/plugins/plugin-sdk-native-resolver.test.ts b/src/plugins/plugin-sdk-native-resolver.test.ts index 415884c4af3..3f0b682e843 100644 --- a/src/plugins/plugin-sdk-native-resolver.test.ts +++ b/src/plugins/plugin-sdk-native-resolver.test.ts @@ -400,6 +400,11 @@ describe("installOpenClawPluginSdkNativeResolver", () => { "ai", path.join("internal", "runtime.ts"), ); + const aiRetryAfterSource = writeInternalCorePackageSource( + root, + "ai", + path.join("internal", "retry-after.ts"), + ); const acpCoreSource = writeInternalCorePackageSource( root, "acp-core", @@ -421,6 +426,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => { expect(installedAliases).toContain("@openclaw/normalization-core/boolean-coercion"); expect(installedAliases).toContain("@openclaw/media-core/mime"); expect(installedAliases).toContain("@openclaw/markdown-core/code-spans"); + expect(installedAliases).toContain("@openclaw/ai/internal/retry-after"); expect(installedAliases).toContain("@openclaw/ai/internal/runtime"); expect(installedAliases).toContain("@openclaw/acp-core/runtime/types"); expect(installedAliases).toContain("@openclaw/llm-core"); @@ -440,6 +446,9 @@ describe("installOpenClawPluginSdkNativeResolver", () => { expect( fs.realpathSync(requireFromCoreSource.resolve("@openclaw/markdown-core/code-spans")), ).toBe(fs.realpathSync(markdownCoreSource)); + expect( + fs.realpathSync(requireFromCoreSource.resolve("@openclaw/ai/internal/retry-after")), + ).toBe(fs.realpathSync(aiRetryAfterSource)); expect(fs.realpathSync(requireFromCoreSource.resolve("@openclaw/ai/internal/runtime"))).toBe( fs.realpathSync(aiRuntimeSource), ); @@ -455,6 +464,7 @@ describe("installOpenClawPluginSdkNativeResolver", () => { ).toThrow(); expect(() => requireFromPlugin.resolve("@openclaw/media-core/mime")).toThrow(); expect(() => requireFromPlugin.resolve("@openclaw/markdown-core/code-spans")).toThrow(); + expect(() => requireFromPlugin.resolve("@openclaw/ai/internal/retry-after")).toThrow(); expect(() => requireFromPlugin.resolve("@openclaw/ai/internal/runtime")).toThrow(); expect(() => requireFromPlugin.resolve("@openclaw/acp-core/runtime/types")).toThrow(); expect(() => requireFromPlugin.resolve("@openclaw/llm-core")).toThrow(); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 644b19871cb..730fefcc33d 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -96,6 +96,7 @@ const INTERNAL_CORE_PACKAGE_ALIASES = [ ["validation", "validation.ts"], ["internal/anthropic", path.join("internal", "anthropic.ts")], ["internal/openai", path.join("internal", "openai.ts")], + ["internal/retry-after", path.join("internal", "retry-after.ts")], ["internal/runtime", path.join("internal", "runtime.ts")], ["internal/shared", path.join("internal", "shared.ts")], ], diff --git a/tsdown.ai.config.ts b/tsdown.ai.config.ts index 5d01c08ba9e..0751727b527 100644 --- a/tsdown.ai.config.ts +++ b/tsdown.ai.config.ts @@ -22,6 +22,7 @@ const config = { validation: "packages/ai/src/validation.ts", "internal/anthropic": "packages/ai/src/internal/anthropic.ts", "internal/openai": "packages/ai/src/internal/openai.ts", + "internal/retry-after": "packages/ai/src/internal/retry-after.ts", "internal/runtime": "packages/ai/src/internal/runtime.ts", "internal/shared": "packages/ai/src/internal/shared.ts", },