From 6c9b31e4d5604a82f2d9cfba64bb8a7cbb0e2de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Sun, 19 Jul 2026 19:26:47 +0300 Subject: [PATCH] fix(ai): honor Retry-After when retry-after-ms is unparseable (#111353) * fix(ai): honor Retry-After when retry-after-ms is unparseable The ChatGPT Responses retry path returned as soon as `retry-after-ms` was present, so a malformed or empty value discarded a valid `Retry-After` header sent alongside it and the client fell back to blind exponential backoff. The sibling parser in `provider-transport-fetch.ts` treats the two headers as ordered preferences and falls through on a parse failure; this aligns the Responses path with that contract. * test(ai): deduplicate retry header coverage --------- Co-authored-by: Peter Steinberger --- .../openai-chatgpt-responses.retry.test.ts | 32 +++++++++++++++++++ .../src/providers/openai-chatgpt-responses.ts | 8 ++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts b/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts index 48acf2dde03d..8c3852261bee 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts @@ -98,6 +98,38 @@ describe("streamOpenAICodexResponses retry classification", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it.each([ + { label: "unparseable", retryAfterMs: "not-a-number" }, + { label: "empty", retryAfterMs: "" }, + ])("honors retry-after when retry-after-ms is $label", async ({ retryAfterMs }) => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response("rate limited", { + status: 429, + headers: { "retry-after-ms": retryAfterMs, "retry-after": "7" }, + }), + ) + .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; + }); + + await streamOpenAICodexResponses(model, context, { + apiKey: jwt, + transport: "sse", + }).result(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 7_000); + }); + it("does not retry a bodyless 304 response", async () => { const fetchMock = vi .fn() diff --git a/packages/ai/src/providers/openai-chatgpt-responses.ts b/packages/ai/src/providers/openai-chatgpt-responses.ts index f2ea78210a9a..81e6002d3cca 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.ts @@ -147,12 +147,12 @@ function isRetryableError(status: number, errorText: string): boolean { function resolveHttpRetryDelayMs(response: Response, attempt: number): number { const fallbackMs = BASE_DELAY_MS * 2 ** attempt; const retryAfterMs = response.headers.get("retry-after-ms"); - if (retryAfterMs !== null) { + if (retryAfterMs) { const trimmed = retryAfterMs.trim(); const millis = Number(trimmed); - return /^\d+(?:\.\d+)?$/.test(trimmed) && Number.isFinite(millis) - ? (clampTimerTimeoutMs(millis, 0) ?? fallbackMs) - : fallbackMs; + if (/^\d+(?:\.\d+)?$/.test(trimmed) && Number.isFinite(millis)) { + return clampTimerTimeoutMs(millis, 0) ?? fallbackMs; + } } const retryAfter = response.headers.get("retry-after");