fix(ai): ChatGPT Responses retries errors it classified as non-retryable#110655
Conversation
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.
|
Codex review: needs maintainer review before merge. Reviewed July 18, 2026, 5:44 PM ET / 21:44 UTC. Summary PR surface: Source +4, Tests +118. Total +122 across 2 files. Reproducibility: yes. at source level: a non-retryable HTTP response previously reached the transport retry catch, and the supplied local-server trace demonstrates the four-request baseline versus one-request fixed behavior. The review did not independently execute the harness. Review metrics: none identified. Stored data model Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land the focused HTTP-versus-transport retry separation once the assigned reviewer confirms the clean merge result and current-head required checks remain green. Do we have a high-confidence way to reproduce the issue? Yes, at source level: a non-retryable HTTP response previously reached the transport retry catch, and the supplied local-server trace demonstrates the four-request baseline versus one-request fixed behavior. The review did not independently execute the harness. Is this the best way to solve the issue? Yes. Separating classified HTTP response failures from fetch/transport exceptions is narrower and more maintainable than adding more message-based exclusions to the generic retry catch. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against fa9ae68c3423. Label changesLabel justifications:
Evidence reviewedPR surface: Source +4, Tests +118. Total +122 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (5 earlier review cycles)
|
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.
|
Real correctness fix, surgical implementation. The Two test-coverage concerns worth addressing:
The fix itself is correct for the documented case. These are hardening asks, not blockers. |
|
@knoal Checked both. Neither is reachable as described, so no change here — details below in case I'm missing something. 1. The async function parseErrorResponse(
response: Response,
): Promise<{ message: string; friendlyMessage?: string }> {
const raw = await readChatGptResponsesErrorTextLimited(response);
let message = raw || response.statusText || "Request failed";
let friendlyMessage: string | undefined;
try {
const parsed = JSON.parse(raw) as { ... };
...
} catch {}
return { message, friendlyMessage };
}A malformed JSON body returns The control-flow shape you describe is right (the throw is inside the try whose catch is the retry catch), there's just no way to trigger it from this path. 2. The 5xx retry test is already in this PR. You looked at 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,
}),
);
...
expect(fetchMock).toHaveBeenCalledTimes(2);
});That's exactly the "new re-throw branch didn't widen" regression guard — a 503 still retries, then the following 401 stops it at 2 calls. One real instance of the shape you're pointing at, but it predates this PR. Two lines above const fakeResponse = new Response(errorText, {
status: response.status,
...
|
…-non-retryable-retry
Co-authored-by: Yigtwxx <[email protected]>
Co-authored-by: Yigtwxx <[email protected]>
…-non-retryable-retry
|
Merged via squash.
|
…ble (openclaw#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]>
Additional instructions
MUST: Keep Allow edits from maintainers enabled for this PR so maintainers
can help update the branch when needed.
What Problem This Solves
Fixes an issue where users running a ChatGPT/Codex model with an invalid or unauthorized credential would wait roughly 7 seconds before seeing the error, when the failure is known immediately.
The SSE retry loop in the ChatGPT Responses transport classifies each non-OK response with
isRetryableError. When that check says the failure is not retryable, the loop builds the friendly error message and throws it — but thethrowsits inside the sametryblock whosecatchtreats any error as a retryable network failure. The throw is swallowed and the request is sent again.An invalid API key, a revoked or unauthorized account, or a malformed request is therefore sent
maxRetriesextra times (4 requests total by default) with 1s + 2s + 4s of backoff in between, before the original message finally surfaces.Two signals show this was not intentional:
!lastError.message.includes("usage limit")string check. That only makes sense if friendly errors were known to leak into the network-retry path — one case was patched, the remaining non-retryable statuses were not.openai-chatgpt-responses.test.ts) assertstoHaveBeenCalledTimes(1)on a 400 response, but it has to use a body starting with"usage limit "for that assertion to hold. Any other body would have retried.Why This Change Was Made
The classification already exists and is correct; only the control flow discards it. The already-classified failure is now thrown as a private
NonRetryableCodexResponseError, and thecatchrethrows that type unchanged before its network-retry handling.Deliberately out of scope: the
"usage limit"string check is left in place. It also guards the path wherefetchitself rejects, and four existing tests depend on it. Removing it would turn this into a behavior-changing refactor.User Impact
Authentication and bad-request failures against ChatGPT/Codex models surface immediately instead of after ~7 seconds of pointless backoff, and the provider receives one request instead of four. The error message itself is unchanged, and genuinely retryable failures (429/5xx, rate-limit and overload bodies) still retry exactly as before.
Evidence
Runtime before/after against a real local HTTP server that answers
401on every request. Nofetchstubbing — real sockets, real responses.origin/mainwas materialized side by side with this branch's HEAD and both were driven through the publicstreamOpenAICodexResponsesexport:Same surfaced error, one request instead of four, 7093ms down to 3ms.
Proof harness
The
.mainproof.tscopy was produced withgit show origin/main:packages/ai/src/providers/openai-chatgpt-responses.tsand removed afterwards.Regression coverage
Four tests added to
openai-chatgpt-responses.test.ts:401,403and400each send exactly one request and never schedule a backoff timer.503followed by a401still retries once, then stops — confirming the retryable path is untouched.Against
origin/mainthese fail withexpected "vi.fn()" to be called 1 times, but got 4 times, each case taking ~7s. With the fix the full file passes 38/38, and its runtime drops from 22.98s to 2.05s.Local checks:
run-vitest38/38 pass,run-tsgoexit 0,check-max-lines-ratchetOK.