fix: parse provider retry dates strictly

This commit is contained in:
Peter Steinberger
2026-05-28 14:17:36 -04:00
parent 5eee488d93
commit 7859ee396e
2 changed files with 87 additions and 16 deletions

View File

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

View File

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