From be2c43ee3e7c047c452c5226982c90e61be89339 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 02:17:30 -0400 Subject: [PATCH] fix(llm): cap codex retry delays --- .../providers/openai-codex-responses.test.ts | 36 +++++++++++++++++++ src/llm/providers/openai-codex-responses.ts | 8 ++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/llm/providers/openai-codex-responses.test.ts b/src/llm/providers/openai-codex-responses.test.ts index b7a0d7cc5fc8..2102d578cf4c 100644 --- a/src/llm/providers/openai-codex-responses.test.ts +++ b/src/llm/providers/openai-codex-responses.test.ts @@ -304,4 +304,40 @@ describe("streamOpenAICodexResponses transport", () => { expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000); }, ); + + it("caps oversized Retry-After delays before sleeping", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response("rate limited", { + status: 429, + headers: { "retry-after": String(Number.MAX_SAFE_INTEGER) }, + }), + ) + .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); + }); }); diff --git a/src/llm/providers/openai-codex-responses.ts b/src/llm/providers/openai-codex-responses.ts index c08d592e16e1..d1bfda08da53 100644 --- a/src/llm/providers/openai-codex-responses.ts +++ b/src/llm/providers/openai-codex-responses.ts @@ -20,7 +20,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js"; +import { resolveTimerTimeoutMs, clampTimerTimeoutMs } from "../../shared/number-coercion.js"; import { getEnvApiKey } from "../env-api-keys.js"; import { clampThinkingLevel } from "../model-utils.js"; import { registerSessionResourceCleanup } from "../session-resources.js"; @@ -343,7 +343,7 @@ export const streamOpenAICodexResponses: StreamFunction< const trimmedRetryAfterMs = retryAfterMs.trim(); const millis = Number(trimmedRetryAfterMs); if (/^\d+(?:\.\d+)?$/.test(trimmedRetryAfterMs) && Number.isFinite(millis)) { - delayMs = Math.max(0, millis); + delayMs = clampTimerTimeoutMs(millis, 0) ?? delayMs; } } else { const retryAfter = response.headers.get("retry-after"); @@ -351,11 +351,11 @@ export const streamOpenAICodexResponses: StreamFunction< const trimmedRetryAfter = retryAfter.trim(); const seconds = Number(trimmedRetryAfter); if (/^\d+$/.test(trimmedRetryAfter) && Number.isFinite(seconds)) { - delayMs = Math.max(0, seconds * 1000); + 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 = Math.max(0, date - Date.now()); + delayMs = clampTimerTimeoutMs(date - Date.now(), 0) ?? delayMs; } } }