Skip to content

Commit 19d8871

Browse files
Yigtwxxsteipete
andauthored
fix(ai): ChatGPT Responses retries errors it classified as non-retryable (#110655)
* fix(ai): ChatGPT Responses retries errors it classified as non-retryable The retry loop builds its friendly error and throws it from inside the same try block whose catch treats unknown failures as retryable network errors. Authentication and bad-request failures were therefore resent up to maxRetries times, adding 7s of backoff before surfacing the same message. Tag the already-classified failure with a private error type and rethrow it unchanged from the catch, leaving the retryable paths untouched. * test(ai): move retry classification cases to a dedicated file The provider test file was already near the 1000-line oxlint max-lines budget, so the new cases pushed it over. Split them out following the existing <module>.<topic>.test.ts convention. * refactor(ai): separate HTTP and transport retries Co-authored-by: Yigtwxx <[email protected]> * test(ai): clarify synthetic JWT fixture Co-authored-by: Yigtwxx <[email protected]> --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 01c6479 commit 19d8871

2 files changed

Lines changed: 170 additions & 48 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Covers which ChatGPT Responses failures the SSE transport retries.
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { configureAiTransportHost } from "../host.js";
4+
import type { Context, Model } from "../types.js";
5+
import {
6+
closeOpenAICodexWebSocketSessions,
7+
resetOpenAICodexWebSocketStateForTest,
8+
streamOpenAICodexResponses,
9+
} from "./openai-chatgpt-responses.js";
10+
11+
function createTestJwt(payload: Record<string, unknown>): string {
12+
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
13+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
14+
return `${header}.${body}.signature`;
15+
}
16+
17+
describe("streamOpenAICodexResponses retry classification", () => {
18+
afterEach(() => {
19+
closeOpenAICodexWebSocketSessions();
20+
vi.restoreAllMocks();
21+
vi.unstubAllGlobals();
22+
vi.useRealTimers();
23+
resetOpenAICodexWebSocketStateForTest();
24+
configureAiTransportHost({});
25+
});
26+
27+
const model = {
28+
id: "gpt-5.5",
29+
name: "GPT-5.5",
30+
api: "openai-chatgpt-responses",
31+
provider: "openai",
32+
baseUrl: "https://chatgpt.test/backend-api",
33+
reasoning: true,
34+
input: ["text"],
35+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
36+
contextWindow: 128_000,
37+
maxTokens: 16_000,
38+
} satisfies Model<"openai-chatgpt-responses">;
39+
40+
const context = {
41+
messages: [{ role: "user", content: "hi", timestamp: 1 }],
42+
} satisfies Context;
43+
44+
const jwt = createTestJwt({
45+
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
46+
});
47+
48+
it.each([
49+
{ status: 401, statusText: "Unauthorized", message: "Invalid credentials" },
50+
{ status: 403, statusText: "Forbidden", message: "Account is not authorized" },
51+
{ status: 400, statusText: "Bad Request", message: "Unsupported parameter" },
52+
])(
53+
"does not retry non-retryable ChatGPT responses: $status",
54+
async ({ status, statusText, message }) => {
55+
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
56+
new Response(JSON.stringify({ error: { message } }), {
57+
status,
58+
statusText,
59+
}),
60+
);
61+
vi.stubGlobal("fetch", fetchMock);
62+
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
63+
64+
const result = await streamOpenAICodexResponses(model, context, {
65+
apiKey: jwt,
66+
transport: "sse",
67+
}).result();
68+
69+
expect(result.stopReason).toBe("error");
70+
expect(fetchMock).toHaveBeenCalledTimes(1);
71+
expect(setTimeoutSpy).not.toHaveBeenCalled();
72+
},
73+
);
74+
75+
it("still retries retryable ChatGPT responses", async () => {
76+
const fetchMock = vi
77+
.fn<typeof fetch>()
78+
.mockResolvedValueOnce(new Response("overloaded", { status: 503 }))
79+
.mockResolvedValueOnce(
80+
new Response(JSON.stringify({ error: { message: "Invalid credentials" } }), {
81+
status: 401,
82+
}),
83+
);
84+
vi.stubGlobal("fetch", fetchMock);
85+
vi.spyOn(globalThis, "setTimeout").mockImplementation((callback: TimerHandler) => {
86+
if (typeof callback === "function") {
87+
callback();
88+
}
89+
return 0 as unknown as ReturnType<typeof setTimeout>;
90+
});
91+
92+
const result = await streamOpenAICodexResponses(model, context, {
93+
apiKey: jwt,
94+
transport: "sse",
95+
}).result();
96+
97+
expect(result.stopReason).toBe("error");
98+
expect(fetchMock).toHaveBeenCalledTimes(2);
99+
});
100+
101+
it("does not retry a bodyless 304 response", async () => {
102+
const fetchMock = vi
103+
.fn<typeof fetch>()
104+
.mockResolvedValue(new Response(null, { status: 304, statusText: "Not Modified" }));
105+
vi.stubGlobal("fetch", fetchMock);
106+
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
107+
108+
const result = await streamOpenAICodexResponses(model, context, {
109+
apiKey: jwt,
110+
transport: "sse",
111+
}).result();
112+
113+
expect(result.stopReason).toBe("error");
114+
expect(result.errorMessage).toBe("Not Modified");
115+
expect(fetchMock).toHaveBeenCalledTimes(1);
116+
expect(setTimeoutSpy).not.toHaveBeenCalled();
117+
});
118+
});

packages/ai/src/providers/openai-chatgpt-responses.ts

Lines changed: 52 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,32 @@ function isRetryableError(status: number, errorText: string): boolean {
144144
);
145145
}
146146

147+
function resolveHttpRetryDelayMs(response: Response, attempt: number): number {
148+
const fallbackMs = BASE_DELAY_MS * 2 ** attempt;
149+
const retryAfterMs = response.headers.get("retry-after-ms");
150+
if (retryAfterMs !== null) {
151+
const trimmed = retryAfterMs.trim();
152+
const millis = Number(trimmed);
153+
return /^\d+(?:\.\d+)?$/.test(trimmed) && Number.isFinite(millis)
154+
? (clampTimerTimeoutMs(millis, 0) ?? fallbackMs)
155+
: fallbackMs;
156+
}
157+
158+
const retryAfter = response.headers.get("retry-after");
159+
if (!retryAfter) {
160+
return fallbackMs;
161+
}
162+
const trimmed = retryAfter.trim();
163+
const seconds = Number(trimmed);
164+
if (/^\d+$/.test(trimmed) && Number.isFinite(seconds)) {
165+
return clampTimerTimeoutMs(seconds * 1000, 0) ?? fallbackMs;
166+
}
167+
const retryAt = parseRetryAfterHttpDateMs(trimmed);
168+
return retryAt === undefined
169+
? fallbackMs
170+
: (clampTimerTimeoutMs(retryAt - Date.now(), 0) ?? fallbackMs);
171+
}
172+
147173
function resolveRequestTimeoutMs(options?: OpenAICodexResponsesOptions): number | undefined {
148174
const timeoutMs = options?.timeoutMs;
149175
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
@@ -373,60 +399,25 @@ export const streamOpenAICodexResponses: StreamFunction<
373399
throw new Error("Request was aborted");
374400
}
375401

402+
let attemptResponse: Response;
403+
let errorText: string;
376404
try {
377-
response = await fetch(resolveCodexUrl(model.baseUrl), {
405+
attemptResponse = await fetch(resolveCodexUrl(model.baseUrl), {
378406
method: "POST",
379407
headers: sseHeaders,
380408
body: sseBody,
381409
signal: activeSignal,
382410
});
411+
response = attemptResponse;
383412
await options?.onResponse?.(
384-
{ status: response.status, headers: headersToRecord(response.headers) },
413+
{ status: attemptResponse.status, headers: headersToRecord(attemptResponse.headers) },
385414
model,
386415
);
387416

388-
if (response.ok) {
417+
if (attemptResponse.ok) {
389418
break;
390419
}
391-
392-
const errorText = await readChatGptResponsesErrorTextLimited(response);
393-
if (attempt < maxRetries && isRetryableError(response.status, errorText)) {
394-
let delayMs = BASE_DELAY_MS * 2 ** attempt;
395-
396-
const retryAfterMs = response.headers.get("retry-after-ms");
397-
if (retryAfterMs !== null) {
398-
const trimmedRetryAfterMs = retryAfterMs.trim();
399-
const millis = Number(trimmedRetryAfterMs);
400-
if (/^\d+(?:\.\d+)?$/.test(trimmedRetryAfterMs) && Number.isFinite(millis)) {
401-
delayMs = clampTimerTimeoutMs(millis, 0) ?? delayMs;
402-
}
403-
} else {
404-
const retryAfter = response.headers.get("retry-after");
405-
if (retryAfter) {
406-
const trimmedRetryAfter = retryAfter.trim();
407-
const seconds = Number(trimmedRetryAfter);
408-
if (/^\d+$/.test(trimmedRetryAfter) && Number.isFinite(seconds)) {
409-
delayMs = clampTimerTimeoutMs(seconds * 1000, 0) ?? delayMs;
410-
} else {
411-
const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfter);
412-
if (retryAt !== undefined) {
413-
delayMs = clampTimerTimeoutMs(retryAt - Date.now(), 0) ?? delayMs;
414-
}
415-
}
416-
}
417-
}
418-
419-
await sleepWithAbort(delayMs, activeSignal);
420-
continue;
421-
}
422-
423-
// Parse error for friendly message on final attempt or non-retryable error
424-
const fakeResponse = new Response(errorText, {
425-
status: response.status,
426-
statusText: response.statusText,
427-
});
428-
const info = await parseErrorResponse(fakeResponse);
429-
throw new Error(info.friendlyMessage || info.message);
420+
errorText = await readChatGptResponsesErrorTextLimited(attemptResponse);
430421
} catch (error) {
431422
if (error instanceof Error) {
432423
if (
@@ -456,6 +447,18 @@ export const streamOpenAICodexResponses: StreamFunction<
456447
}
457448
throw lastError;
458449
}
450+
451+
if (attempt < maxRetries && isRetryableError(attemptResponse.status, errorText)) {
452+
await sleepWithAbort(resolveHttpRetryDelayMs(attemptResponse, attempt), activeSignal);
453+
continue;
454+
}
455+
456+
const info = parseErrorResponseText(
457+
errorText,
458+
attemptResponse.status,
459+
attemptResponse.statusText,
460+
);
461+
throw new Error(info.friendlyMessage || info.message);
459462
}
460463

461464
if (!response?.ok) {
@@ -1579,11 +1582,12 @@ async function readChatGptResponsesErrorTextLimited(response: Response): Promise
15791582
return text;
15801583
}
15811584

1582-
async function parseErrorResponse(
1583-
response: Response,
1584-
): Promise<{ message: string; friendlyMessage?: string }> {
1585-
const raw = await readChatGptResponsesErrorTextLimited(response);
1586-
let message = raw || response.statusText || "Request failed";
1585+
function parseErrorResponseText(
1586+
raw: string,
1587+
status: number,
1588+
statusText: string,
1589+
): { message: string; friendlyMessage?: string } {
1590+
let message = raw || statusText || "Request failed";
15871591
let friendlyMessage: string | undefined;
15881592

15891593
try {
@@ -1601,7 +1605,7 @@ async function parseErrorResponse(
16011605
const code = err.code || err.type || "";
16021606
if (
16031607
/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) ||
1604-
response.status === 429
1608+
status === 429
16051609
) {
16061610
const plan = err.plan_type ? ` (${err.plan_type.toLowerCase()} plan)` : "";
16071611
const mins = err.resets_at

0 commit comments

Comments
 (0)