From 7859ee396eaddab275aa0c2ef0f95cad77130b4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 28 May 2026 14:17:36 -0400 Subject: [PATCH] fix: parse provider retry dates strictly --- src/agents/provider-transport-fetch.test.ts | 94 +++++++++++++++++---- src/agents/provider-transport-fetch.ts | 9 +- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/agents/provider-transport-fetch.test.ts b/src/agents/provider-transport-fetch.test.ts index 53799f922f5..6735ffec57c 100644 --- a/src/agents/provider-transport-fetch.test.ts +++ b/src/agents/provider-transport-fetch.test.ts @@ -1075,6 +1075,67 @@ describe("buildGuardedModelFetch", () => { expect(response.headers.get("x-should-retry")).toBe("false"); }); + function formatObsoleteHttpDates(date: Date): Array<[string, string]> { + const dayNames = [ + ["Sun", "Sunday"], + ["Mon", "Monday"], + ["Tue", "Tuesday"], + ["Wed", "Wednesday"], + ["Thu", "Thursday"], + ["Fri", "Friday"], + ["Sat", "Saturday"], + ] as const; + const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ] as const; + const [shortDay, longDay] = dayNames[date.getUTCDay()] ?? dayNames[0]; + const month = monthNames[date.getUTCMonth()] ?? monthNames[0]; + const day = String(date.getUTCDate()).padStart(2, "0"); + const shortYear = String(date.getUTCFullYear() % 100).padStart(2, "0"); + const hours = String(date.getUTCHours()).padStart(2, "0"); + const minutes = String(date.getUTCMinutes()).padStart(2, "0"); + const seconds = String(date.getUTCSeconds()).padStart(2, "0"); + const time = `${hours}:${minutes}:${seconds}`; + return [ + ["RFC 850", `${longDay}, ${day}-${month}-${shortYear} ${time} GMT`], + [ + "asctime", + `${shortDay} ${month} ${day.padStart(2, " ")} ${time} ${date.getUTCFullYear()}`, + ], + ]; + } + + it.each([...formatObsoleteHttpDates(new Date(Date.now() + 120_000))])( + "parses obsolete HTTP-date retry-after values: %s", + async (_label, 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")).toBe("false"); + }, + ); + it("respects OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS", async () => { process.env.OPENCLAW_SDK_RETRY_MAX_WAIT_SECONDS = "10"; fetchWithSsrFGuardMock.mockResolvedValue({ @@ -1203,22 +1264,25 @@ describe("buildGuardedModelFetch", () => { expect(response.headers.get("x-should-retry")).toBeNull(); }); - it("treats malformed 429 retry-after values as terminal", async () => { - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(null, { - status: 429, - headers: { "retry-after": "soon" }, - }), - 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" }, - ); + it.each(["soon", "1.5", "0x10"])( + "treats malformed 429 retry-after values as terminal: %s", + async (retryAfter) => { + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(null, { + status: 429, + 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")).toBe("false"); - }); + expect(response.headers.get("x-should-retry")).toBe("false"); + }, + ); it("ignores retry-after on non-retryable responses", async () => { fetchWithSsrFGuardMock.mockResolvedValue({ diff --git a/src/agents/provider-transport-fetch.ts b/src/agents/provider-transport-fetch.ts index ff904028b80..519571d92ec 100644 --- a/src/agents/provider-transport-fetch.ts +++ b/src/agents/provider-transport-fetch.ts @@ -34,6 +34,8 @@ const DEFAULT_MAX_SDK_RETRY_WAIT_SECONDS = 60; const log = createSubsystemLogger("provider-transport-fetch"); 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})$/; function hasReadableSseData(block: string): boolean { const dataLines = block @@ -252,7 +254,12 @@ function parseRetryAfterSeconds(headers: Headers): number | undefined { return seconds; } - const retryAt = Date.parse(retryAfter); + const trimmedRetryAfter = retryAfter.trim(); + if (!RETRY_AFTER_HTTP_DATE_RE.test(trimmedRetryAfter)) { + return undefined; + } + + const retryAt = Date.parse(trimmedRetryAfter); if (Number.isNaN(retryAt)) { return undefined; }