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 <steipete@gmail.com>
This commit is contained in:
Yiğit ERDOĞAN
2026-07-19 19:26:47 +03:00
committed by GitHub
parent fe9e018045
commit 6c9b31e4d5
2 changed files with 36 additions and 4 deletions

View File

@@ -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<typeof fetch>()
.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<typeof setTimeout>;
});
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<typeof fetch>()

View File

@@ -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");