diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index ec4c1fd3e..3b4854228 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -325,6 +325,43 @@ describe("normalizeInferenceErrorForRetry", () => { expect(normalized.retryAfterMs).toBe(7_000); }); + test.each([{ resets_in_seconds: 0 }, { resets_in_seconds: -5 }])( + "Codex 429 with a zero/negative body reset window keeps the header retry-after (CL-10758)", + (window) => { + const normalized = normalizeInferenceErrorForRetry({ + ...BARE_429, + providerId: "codex/acme-labs", + raw: { + error: { + code: "rate_limit_exceeded", + message: "Too Many Requests", + ...window, + }, + }, + }); + expect(normalized.category).toBe("retryable"); + expect(normalized.retryAfterMs).toBe(45_000); + }, + ); + + test("Codex 429 with a zero retry_after body key keeps the header retry-after (CL-10758)", () => { + // retry_after is not a Codex body signal (only resets_in_seconds et al.); + // a zero there must never clobber the header floor either. + const normalized = normalizeInferenceErrorForRetry({ + ...BARE_429, + providerId: "codex/acme-labs", + raw: { + error: { + code: "rate_limit_exceeded", + message: "Too Many Requests", + retry_after: 0, + }, + }, + }); + expect(normalized.category).toBe("retryable"); + expect(normalized.retryAfterMs).toBe(45_000); + }); + test("non-Codex 429s keep the header retry-after; a body reset is not read (CL-10664)", () => { const normalized = normalizeInferenceErrorForRetry({ ...BARE_429, diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index b84a04d66..75ad70c93 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -434,14 +434,16 @@ export function isCodexShortRateLimitInferenceError( /** * Codex sends its reset window in the 429 body, not a header; when a bare * rate-limit body carries one, retry on that window instead of the policy - * default. Exact usage-limit codes never reach here (the skeleton vetoes). + * default. A zero/negative window is not a real ETA — returning 0 would win + * over the header via `??` in the skeleton and retry immediately — so it + * falls through to the header/default floor. Exact usage-limit codes never + * reach here (the skeleton vetoes). */ function codexRetryFromBody(error: InferenceErrorLike): number | undefined { const node = readErrorNode(coerceBody(error.raw)); if (node === undefined) return undefined; const seconds = resetSecondsFromNode(node); - if (seconds === undefined) return undefined; - if (seconds <= 0) return 0; + if (seconds === undefined || seconds <= 0) return undefined; return seconds * 1000; }