mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-26 14:31:12 +00:00
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 <yigiterdogan023@gmail.com> * test(ai): clarify synthetic JWT fixture Co-authored-by: Yigtwxx <yigiterdogan023@gmail.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
118
packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts
Normal file
118
packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// Covers which ChatGPT Responses failures the SSE transport retries.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configureAiTransportHost } from "../host.js";
|
||||
import type { Context, Model } from "../types.js";
|
||||
import {
|
||||
closeOpenAICodexWebSocketSessions,
|
||||
resetOpenAICodexWebSocketStateForTest,
|
||||
streamOpenAICodexResponses,
|
||||
} from "./openai-chatgpt-responses.js";
|
||||
|
||||
function createTestJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
|
||||
describe("streamOpenAICodexResponses retry classification", () => {
|
||||
afterEach(() => {
|
||||
closeOpenAICodexWebSocketSessions();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
resetOpenAICodexWebSocketStateForTest();
|
||||
configureAiTransportHost({});
|
||||
});
|
||||
|
||||
const model = {
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://chatgpt.test/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_000,
|
||||
} satisfies Model<"openai-chatgpt-responses">;
|
||||
|
||||
const context = {
|
||||
messages: [{ role: "user", content: "hi", timestamp: 1 }],
|
||||
} satisfies Context;
|
||||
|
||||
const jwt = createTestJwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 401, statusText: "Unauthorized", message: "Invalid credentials" },
|
||||
{ status: 403, statusText: "Forbidden", message: "Account is not authorized" },
|
||||
{ status: 400, statusText: "Bad Request", message: "Unsupported parameter" },
|
||||
])(
|
||||
"does not retry non-retryable ChatGPT responses: $status",
|
||||
async ({ status, statusText, message }) => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: { message } }), {
|
||||
status,
|
||||
statusText,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: jwt,
|
||||
transport: "sse",
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("still retries retryable ChatGPT responses", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response("overloaded", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: { message: "Invalid credentials" } }), {
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(globalThis, "setTimeout").mockImplementation((callback: TimerHandler) => {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: jwt,
|
||||
transport: "sse",
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry a bodyless 304 response", async () => {
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response(null, { status: 304, statusText: "Not Modified" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: jwt,
|
||||
transport: "sse",
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe("Not Modified");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -144,6 +144,32 @@ 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) {
|
||||
const trimmed = retryAfterMs.trim();
|
||||
const millis = Number(trimmed);
|
||||
return /^\d+(?:\.\d+)?$/.test(trimmed) && Number.isFinite(millis)
|
||||
? (clampTimerTimeoutMs(millis, 0) ?? fallbackMs)
|
||||
: fallbackMs;
|
||||
}
|
||||
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
if (!retryAfter) {
|
||||
return fallbackMs;
|
||||
}
|
||||
const trimmed = retryAfter.trim();
|
||||
const seconds = Number(trimmed);
|
||||
if (/^\d+$/.test(trimmed) && Number.isFinite(seconds)) {
|
||||
return clampTimerTimeoutMs(seconds * 1000, 0) ?? fallbackMs;
|
||||
}
|
||||
const retryAt = parseRetryAfterHttpDateMs(trimmed);
|
||||
return retryAt === undefined
|
||||
? fallbackMs
|
||||
: (clampTimerTimeoutMs(retryAt - Date.now(), 0) ?? fallbackMs);
|
||||
}
|
||||
|
||||
function resolveRequestTimeoutMs(options?: OpenAICodexResponsesOptions): number | undefined {
|
||||
const timeoutMs = options?.timeoutMs;
|
||||
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
@@ -373,60 +399,25 @@ export const streamOpenAICodexResponses: StreamFunction<
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
let attemptResponse: Response;
|
||||
let errorText: string;
|
||||
try {
|
||||
response = await fetch(resolveCodexUrl(model.baseUrl), {
|
||||
attemptResponse = await fetch(resolveCodexUrl(model.baseUrl), {
|
||||
method: "POST",
|
||||
headers: sseHeaders,
|
||||
body: sseBody,
|
||||
signal: activeSignal,
|
||||
});
|
||||
response = attemptResponse;
|
||||
await options?.onResponse?.(
|
||||
{ status: response.status, headers: headersToRecord(response.headers) },
|
||||
{ status: attemptResponse.status, headers: headersToRecord(attemptResponse.headers) },
|
||||
model,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
if (attemptResponse.ok) {
|
||||
break;
|
||||
}
|
||||
|
||||
const errorText = await readChatGptResponsesErrorTextLimited(response);
|
||||
if (attempt < maxRetries && isRetryableError(response.status, errorText)) {
|
||||
let delayMs = BASE_DELAY_MS * 2 ** attempt;
|
||||
|
||||
const retryAfterMs = response.headers.get("retry-after-ms");
|
||||
if (retryAfterMs !== null) {
|
||||
const trimmedRetryAfterMs = retryAfterMs.trim();
|
||||
const millis = Number(trimmedRetryAfterMs);
|
||||
if (/^\d+(?:\.\d+)?$/.test(trimmedRetryAfterMs) && Number.isFinite(millis)) {
|
||||
delayMs = clampTimerTimeoutMs(millis, 0) ?? delayMs;
|
||||
}
|
||||
} else {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
if (retryAfter) {
|
||||
const trimmedRetryAfter = retryAfter.trim();
|
||||
const seconds = Number(trimmedRetryAfter);
|
||||
if (/^\d+$/.test(trimmedRetryAfter) && Number.isFinite(seconds)) {
|
||||
delayMs = clampTimerTimeoutMs(seconds * 1000, 0) ?? delayMs;
|
||||
} else {
|
||||
const retryAt = parseRetryAfterHttpDateMs(trimmedRetryAfter);
|
||||
if (retryAt !== undefined) {
|
||||
delayMs = clampTimerTimeoutMs(retryAt - Date.now(), 0) ?? delayMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sleepWithAbort(delayMs, activeSignal);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse error for friendly message on final attempt or non-retryable error
|
||||
const fakeResponse = new Response(errorText, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
const info = await parseErrorResponse(fakeResponse);
|
||||
throw new Error(info.friendlyMessage || info.message);
|
||||
errorText = await readChatGptResponsesErrorTextLimited(attemptResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
@@ -456,6 +447,18 @@ export const streamOpenAICodexResponses: StreamFunction<
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries && isRetryableError(attemptResponse.status, errorText)) {
|
||||
await sleepWithAbort(resolveHttpRetryDelayMs(attemptResponse, attempt), activeSignal);
|
||||
continue;
|
||||
}
|
||||
|
||||
const info = parseErrorResponseText(
|
||||
errorText,
|
||||
attemptResponse.status,
|
||||
attemptResponse.statusText,
|
||||
);
|
||||
throw new Error(info.friendlyMessage || info.message);
|
||||
}
|
||||
|
||||
if (!response?.ok) {
|
||||
@@ -1579,11 +1582,12 @@ async function readChatGptResponsesErrorTextLimited(response: Response): Promise
|
||||
return text;
|
||||
}
|
||||
|
||||
async function parseErrorResponse(
|
||||
response: Response,
|
||||
): Promise<{ message: string; friendlyMessage?: string }> {
|
||||
const raw = await readChatGptResponsesErrorTextLimited(response);
|
||||
let message = raw || response.statusText || "Request failed";
|
||||
function parseErrorResponseText(
|
||||
raw: string,
|
||||
status: number,
|
||||
statusText: string,
|
||||
): { message: string; friendlyMessage?: string } {
|
||||
let message = raw || statusText || "Request failed";
|
||||
let friendlyMessage: string | undefined;
|
||||
|
||||
try {
|
||||
@@ -1601,7 +1605,7 @@ async function parseErrorResponse(
|
||||
const code = err.code || err.type || "";
|
||||
if (
|
||||
/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) ||
|
||||
response.status === 429
|
||||
status === 429
|
||||
) {
|
||||
const plan = err.plan_type ? ` (${err.plan_type.toLowerCase()} plan)` : "";
|
||||
const mins = err.resets_at
|
||||
|
||||
Reference in New Issue
Block a user