From 8b041eec2a5966bf94303043621a25c62ab820e3 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 28 Aug 2026 10:17:01 +0800 Subject: [PATCH 1/2] fix: honor structured retry delays --- src/llm/provider/openai/openai-http.test.ts | 149 +++++++++++++++++++- src/llm/provider/openai/openai-http.ts | 32 ++++- 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/src/llm/provider/openai/openai-http.test.ts b/src/llm/provider/openai/openai-http.test.ts index eecd66a9..10e503d0 100644 --- a/src/llm/provider/openai/openai-http.test.ts +++ b/src/llm/provider/openai/openai-http.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { OpenAiHttpError, @@ -36,6 +36,11 @@ function errorResponse( return new Response(body, { status, headers }); } +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + describe("openAiPostJson", () => { it("returns parsed JSON on success without retrying", async () => { const fetchImpl = vi.fn(async () => jsonResponse({ ok: true })); @@ -115,6 +120,148 @@ describe("openAiPostJson", () => { expect(fetchImpl).toHaveBeenCalledTimes(2); }); + it.each([ + { status: 429, retryDelay: "1.5s", expectedMs: 1_500 }, + { status: 503, retryDelay: "2s", expectedMs: 2_000 }, + ])( + "honors structured retryDelay for $status responses", + async ({ status, retryDelay, expectedMs }) => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + errorResponse( + status, + JSON.stringify({ + error: { + code: status, + details: [ + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + retryDelay, + }, + ], + }, + }), + ), + ) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + + const pending = openAiPostJson( + depsWith(fetchImpl as unknown as typeof fetch), + "/x", + {}, + {}, + ); + await vi.advanceTimersByTimeAsync(expectedMs - 1); + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }, + ); + + it.each(["not-a-duration", "-1s"])( + "ignores malformed structured retryDelay %s", + async (retryDelay) => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + errorResponse( + 429, + JSON.stringify({ error: { details: [{ retryDelay }] } }), + ), + ) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + + const pending = openAiPostJson( + depsWith(fetchImpl as unknown as typeof fetch), + "/x", + {}, + {}, + ); + await vi.advanceTimersByTimeAsync(149); + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }, + ); + + it("prefers a valid Retry-After header over structured retryDelay", async () => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + errorResponse( + 429, + JSON.stringify({ error: { details: [{ retryDelay: "5s" }] } }), + { "retry-after": "0.5" }, + ), + ) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + + const pending = openAiPostJson( + depsWith(fetchImpl as unknown as typeof fetch), + "/x", + {}, + {}, + ); + await vi.advanceTimersByTimeAsync(499); + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("caps a structured retryDelay at the interactive retry limit", async () => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + errorResponse(429, JSON.stringify({ error: { details: [{ retryDelay: "30s" }] } })), + ) + .mockResolvedValueOnce(jsonResponse({ ok: true })); + + const pending = openAiPostJson( + depsWith(fetchImpl as unknown as typeof fetch), + "/x", + {}, + {}, + ); + await vi.advanceTimersByTimeAsync(4_999); + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("lets caller cancellation interrupt a structured retry wait", async () => { + vi.useFakeTimers(); + vi.spyOn(Math, "random").mockReturnValue(0.5); + const controller = new AbortController(); + const fetchImpl = vi.fn().mockResolvedValueOnce( + errorResponse(429, JSON.stringify({ error: { details: [{ retryDelay: "39s" }] } })), + ); + + const pending = openAiPostJson( + depsWith(fetchImpl as unknown as typeof fetch), + "/x", + {}, + { signal: controller.signal }, + ); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledTimes(1); + controller.abort(); + await expect(pending).rejects.toMatchObject({ status: null }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it("wraps network failures as status null and retries them", async () => { const fetchImpl = vi .fn() diff --git a/src/llm/provider/openai/openai-http.ts b/src/llm/provider/openai/openai-http.ts index 85fa1055..1dd4e90c 100644 --- a/src/llm/provider/openai/openai-http.ts +++ b/src/llm/provider/openai/openai-http.ts @@ -262,12 +262,17 @@ async function httpErrorFromResponse( res: Response, ): Promise { const text = await res.text().catch(() => ""); + const retryAfterMs = + parseRetryAfterMs(res.headers.get("retry-after")) ?? + (res.status === 429 || res.status === 503 + ? parseStructuredRetryDelayMs(text) + : null); return new OpenAiHttpError( `openai provider ${res.status}: ${text.slice(0, OPENAI_ERROR_DETAIL_MAX_LEN)}`, res.status, `${deps.baseUrl}${path}`, false, - parseRetryAfterMs(res.headers.get("retry-after")), + retryAfterMs, deps.label, ); } @@ -346,6 +351,31 @@ function parseRetryAfterMs(header: string | null): number | null { return null; } +function parseStructuredRetryDelayMs(body: string): number | null { + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch { + return null; + } + if (!isRecord(parsed) || !isRecord(parsed.error)) return null; + const details = parsed.error.details; + if (!Array.isArray(details)) return null; + + for (const detail of details) { + if (!isRecord(detail) || typeof detail.retryDelay !== "string") continue; + const match = /^(\d+(?:\.\d+)?)s$/.exec(detail.retryDelay); + if (!match) continue; + const milliseconds = Number(match[1]) * 1000; + if (Number.isFinite(milliseconds)) return Math.round(milliseconds); + } + return null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + async function sleep(ms: number, signal?: AbortSignal): Promise { if (ms <= 0) return; await new Promise((resolve) => { From 7946eca100184b73873cf3d684108cef9be85d7a Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Fri, 28 Aug 2026 11:19:47 +0800 Subject: [PATCH 2/2] docs: describe structured retry delays --- src/llm/provider/openai/openai-http.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/llm/provider/openai/openai-http.ts b/src/llm/provider/openai/openai-http.ts index 1dd4e90c..44c396b4 100644 --- a/src/llm/provider/openai/openai-http.ts +++ b/src/llm/provider/openai/openai-http.ts @@ -28,8 +28,9 @@ export type OpenAiHttpDeps = { * opposed to the transport failing or the caller cancelling. Both * surface as aborts, but a timeout is "the provider is slower than * the budget" — replaying it just burns another full timeout. - * - `retryAfterMs` is populated from a `retry-after` header when the - * provider sent one (429/503), so the retry loop can honor it. + * - `retryAfterMs` is populated from a valid `retry-after` header or + * structured retry metadata on 429/503 responses, so the retry loop can + * honor the provider's requested delay. */ export class OpenAiHttpError extends Error { constructor(