Skip to content

Commit 198afce

Browse files
Merge pull request #710 from corbitsdev/cl-7173-treat-codex-short-429s-as-retryable-rate-limits
Remap Codex short 429s to retryable rate limits
2 parents 664f92a + 99fff4f commit 198afce

6 files changed

Lines changed: 207 additions & 23 deletions

src/agent/retry-policy.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,23 @@ describe("createCorbitsRetryPolicy", () => {
6969
raw: { error: { message: "Too Many Requests" } },
7070
},
7171
});
72-
// Remapped to retryable → default backoff, not abort on moderate Retry-After.
72+
// Remapped to retryable -> default backoff, not abort on moderate Retry-After.
73+
expect(decision).toEqual({ kind: "retry", delayMs: 500 });
74+
});
75+
76+
test("stamped Codex usage-limit 429 retries as retryable, not long-quota abort", async () => {
77+
const policy = createCorbitsRetryPolicy({ providerId: "codex/abk-labs" });
78+
const decision = await policy({
79+
attempt: 1,
80+
elapsedMs: 0,
81+
error: {
82+
category: "quota_exhausted",
83+
message: "You have hit your ChatGPT usage limit",
84+
statusCode: 429,
85+
retryAfterMs: 45_000,
86+
raw: "You have hit your ChatGPT usage limit",
87+
},
88+
});
7389
expect(decision).toEqual({ kind: "retry", delayMs: 500 });
7490
});
7591

src/inference-error-message.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,27 @@ describe("inferenceErrorMessage", () => {
6565
});
6666
expect(line).toBe("Quota exhausted — usage limit reached.");
6767
});
68+
69+
test("known-Codex short 429 shows rate-limit line, not usage-limit copy", () => {
70+
const line = inferenceErrorMessage({
71+
category: "quota_exhausted",
72+
message: "You have hit your ChatGPT usage limit",
73+
statusCode: 429,
74+
providerId: "codex/abk-labs",
75+
raw: "You have hit your ChatGPT usage limit",
76+
});
77+
expect(line.toLowerCase()).toMatch(/rate limit/);
78+
expect(line).not.toContain("Quota exhausted");
79+
expect(line.toLowerCase()).not.toContain("the usage limit has been reached");
80+
expect(line.toLowerCase()).not.toContain("usage limit reached");
81+
});
82+
83+
test("credential_failure tells the user to log in again", () => {
84+
const line = inferenceErrorMessage({
85+
category: "credential_failure",
86+
message: '{"error":{"code":401}}',
87+
});
88+
expect(line.toLowerCase()).not.toContain("re-authenticating");
89+
expect(line.toLowerCase()).toMatch(/log in again|sign in again/);
90+
});
6891
});

src/inference-error-message.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ import {
1313
import { codexProfileFromProviderName, isCodexProviderName } from "./config/codex-providers.js";
1414
import {
1515
gatewayOverloadUserMessage,
16+
isCodexShortRateLimitInferenceError,
1617
isGatewayOverloadInferenceError,
1718
isXaiShortRateLimitInferenceError,
18-
XAI_RATE_LIMIT_USER_MESSAGE,
19+
RATE_LIMIT_USER_MESSAGE,
1920
type InferenceErrorLike,
2021
} from "./inference-gateway-error.js";
2122

2223
const FRIENDLY_BY_CATEGORY: Record<string, string> = {
23-
// Re-authentication runs on its own; keep the transcript line short and free
24-
// of the provider's raw 401 JSON.
25-
credential_failure: "Session expired — re-authenticating…",
24+
// Committed auth death — do not claim a refresh is in flight.
25+
credential_failure: "Authentication failed — log in again.",
2626
quota_exhausted: "Quota exhausted — usage limit reached.",
2727
context_overflow:
2828
"Context window full — compaction could not keep up. Try /clear to start fresh.",
@@ -93,8 +93,10 @@ function codexUsageLimitLine(error: InferenceErrorLike): string | undefined {
9393
export function inferenceErrorMessage(error: InferenceErrorLike): string {
9494
if (isGatewayOverloadInferenceError(error)) return gatewayOverloadUserMessage(error);
9595
// Dual-path: harness may still emit intx's quota_exhausted for a known-xAI
96-
// short 429; FRIENDLY_BY_CATEGORY would otherwise say "Quota exhausted".
97-
if (isXaiShortRateLimitInferenceError(error)) return XAI_RATE_LIMIT_USER_MESSAGE;
96+
// or known-Codex short 429; FRIENDLY_BY_CATEGORY would otherwise say
97+
// "Quota exhausted".
98+
if (isXaiShortRateLimitInferenceError(error) || isCodexShortRateLimitInferenceError(error))
99+
return RATE_LIMIT_USER_MESSAGE;
98100

99101
const category = classifyInferenceErrorCategory(error);
100102
if (category === "quota_exhausted") {

src/inference-gateway-error.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,4 +318,70 @@ describe("normalizeInferenceErrorForRetry", () => {
318318
};
319319
expect(normalizeInferenceErrorForRetry(err)).toEqual(err);
320320
});
321+
322+
test("known-Codex bare 429 without usage_limit_reached remaps to retryable", () => {
323+
const bare = {
324+
category: "quota_exhausted" as const,
325+
message: "Too Many Requests",
326+
statusCode: 429,
327+
retryAfterMs: 5_000,
328+
raw: { error: { message: "Too Many Requests" } },
329+
};
330+
331+
expect(normalizeInferenceErrorForRetry(bare)).toEqual(bare);
332+
333+
const viaProviderId = normalizeInferenceErrorForRetry({
334+
...bare,
335+
providerId: "codex/abk-labs",
336+
});
337+
expect(viaProviderId.category).toBe("retryable");
338+
expect(viaProviderId.retryAfterMs).toBe(5_000);
339+
expect(viaProviderId.message.toLowerCase()).toMatch(/rate limit/);
340+
expect(viaProviderId.message.toLowerCase()).not.toMatch(/quota exhausted|usage limit reached/);
341+
});
342+
343+
test("known-Codex 429 with ChatGPT usage-limit prose remaps to retryable", () => {
344+
const normalized = normalizeInferenceErrorForRetry({
345+
category: "quota_exhausted",
346+
message: "You have hit your ChatGPT usage limit",
347+
statusCode: 429,
348+
providerId: "codex/abk-labs",
349+
raw: "You have hit your ChatGPT usage limit",
350+
});
351+
expect(normalized.category).toBe("retryable");
352+
expect(normalized.message.toLowerCase()).toMatch(/rate limit/);
353+
expect(normalized.message.toLowerCase()).not.toMatch(/quota exhausted|usage limit reached/);
354+
});
355+
356+
test("known-Codex 429 with empty body remaps to retryable", () => {
357+
const normalized = normalizeInferenceErrorForRetry({
358+
category: "quota_exhausted",
359+
message: "Too Many Requests",
360+
statusCode: 429,
361+
providerId: "codex/abk-labs",
362+
});
363+
expect(normalized.category).toBe("retryable");
364+
});
365+
366+
test("known-Codex usage_limit_reached 429 stays quota_exhausted", () => {
367+
const normalized = normalizeInferenceErrorForRetry({
368+
category: "quota_exhausted",
369+
message: "Too Many Requests",
370+
statusCode: 429,
371+
providerId: "codex/abk-labs",
372+
raw: {
373+
detail: {
374+
error: {
375+
code: "usage_limit_reached",
376+
message: "You have reached your usage limit. Try again later.",
377+
plan_type: "workspace_member",
378+
resets_in_seconds: 3435,
379+
},
380+
},
381+
},
382+
});
383+
expect(normalized.category).toBe("quota_exhausted");
384+
expect(normalized.retryAfterMs).toBe(3_435_000);
385+
expect(normalized.message).toContain('Codex profile "abk-labs"');
386+
});
321387
});

src/inference-gateway-error.ts

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ const GATEWAY_OVERLOAD_TEXT_MARKERS = [
4949
/** User-visible line while the harness retries a transient gateway overload. */
5050
export const GATEWAY_OVERLOAD_USER_MESSAGE = "Inference gateway overloaded — retrying…";
5151

52-
/** User-visible line while the harness retries a short known-xAI HTTP 429. */
53-
export const XAI_RATE_LIMIT_USER_MESSAGE = "Rate limited — retrying…";
52+
/** User-visible line while the harness retries a short known-provider HTTP 429. */
53+
export const RATE_LIMIT_USER_MESSAGE = "Rate limited — retrying…";
5454

5555
/** Body markers that mean a real usage/quota window, not a short rate limit. */
5656
const XAI_QUOTA_BODY_MARKERS = [
@@ -246,7 +246,67 @@ export function normalizeXaiRateLimitError(error: InferenceErrorWithGoContext):
246246

247247
return {
248248
category: "retryable",
249-
message: XAI_RATE_LIMIT_USER_MESSAGE,
249+
message: RATE_LIMIT_USER_MESSAGE,
250+
statusCode: 429,
251+
...(error.raw !== undefined ? { raw: error.raw } : {}),
252+
...(error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
253+
};
254+
}
255+
256+
function parseCodexUsageLimitFromError(
257+
error: InferenceErrorLike,
258+
): ReturnType<typeof parseCodexUsageLimitError> {
259+
const candidates: unknown[] = [];
260+
if (error.raw !== undefined) candidates.push(error.raw);
261+
if (typeof error.message === "string" && error.message.trim().startsWith("{")) {
262+
candidates.push(error.message);
263+
}
264+
265+
for (const candidate of candidates) {
266+
const parsed = parseCodexUsageLimitError(candidate);
267+
if (parsed !== undefined) return parsed;
268+
}
269+
return undefined;
270+
}
271+
272+
function isKnownCodexProviderId(providerId: string | undefined): boolean {
273+
return providerId !== undefined && isCodexProviderName(providerId);
274+
}
275+
276+
/**
277+
* True when a known-Codex HTTP 429 looks like a short rate limit rather than a
278+
* `usage_limit_reached` window. Used by both retry normalization and transcript
279+
* copy — FRIENDLY_BY_CATEGORY would otherwise paint every quota_exhausted 429 as
280+
* "Quota exhausted" even when the policy remaps it to retryable.
281+
*
282+
* Discrimination is the existing Codex usage-limit parser, not Retry-After length
283+
* and not ChatGPT usage-limit prose without `usage_limit_reached`.
284+
*/
285+
export function isCodexShortRateLimitInferenceError(error: InferenceErrorLike): boolean {
286+
if (!isKnownCodexProviderId(error.providerId)) return false;
287+
if (error.statusCode !== 429) return false;
288+
if (error.category !== "quota_exhausted" && error.category !== "retryable") return false;
289+
if (parseCodexUsageLimitFromError(error) !== undefined) return false;
290+
return true;
291+
}
292+
293+
/**
294+
* intx defaults bare 429 → quota_exhausted. For known-Codex contexts a bare 429
295+
* (or usage-limit prose without `usage_limit_reached`) reclassifies as retryable
296+
* so short ChatGPT 429s are not painted as a committed usage-limit window.
297+
*
298+
* Nested `detail.error.code === usage_limit_reached` stays quota_exhausted via
299+
* `normalizeCodexUsageLimitError`. Unknown / non-Codex providers are never remapped.
300+
*/
301+
export function normalizeCodexRateLimitError(error: InferenceErrorWithGoContext): InferenceError {
302+
if (error.statusCode !== 429) return error;
303+
if (error.category !== "quota_exhausted") return error;
304+
if (!isKnownCodexProviderId(error.providerId)) return error;
305+
if (parseCodexUsageLimitFromError(error) !== undefined) return error;
306+
307+
return {
308+
category: "retryable",
309+
message: RATE_LIMIT_USER_MESSAGE,
250310
statusCode: 429,
251311
...(error.raw !== undefined ? { raw: error.raw } : {}),
252312
...(error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
@@ -266,17 +326,7 @@ function normalizeCodexUsageLimitError(error: InferenceErrorWithGoContext): Infe
266326
return error;
267327
}
268328

269-
const candidates: unknown[] = [];
270-
if (error.raw !== undefined) candidates.push(error.raw);
271-
if (typeof error.message === "string" && error.message.trim().startsWith("{")) {
272-
candidates.push(error.message);
273-
}
274-
275-
let parsed = undefined as ReturnType<typeof parseCodexUsageLimitError>;
276-
for (const candidate of candidates) {
277-
parsed = parseCodexUsageLimitError(candidate);
278-
if (parsed !== undefined) break;
279-
}
329+
const parsed = parseCodexUsageLimitFromError(error);
280330
if (parsed === undefined) return error;
281331

282332
const profile =
@@ -298,7 +348,8 @@ function normalizeCodexUsageLimitError(error: InferenceErrorWithGoContext): Infe
298348
* Reclassify gateway overload errors so the default retry policy treats them as
299349
* transient instead of aborting on protocol_mismatch. Also normalizes OpenCode
300350
* Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short
301-
* 429s, and Codex usage limits (nested detail.error with resets_in_seconds).
351+
* 429s, Codex usage limits (nested detail.error with resets_in_seconds), and
352+
* known-Codex short 429s that are not usage_limit_reached.
302353
*/
303354
export function normalizeInferenceErrorForRetry(
304355
error: InferenceErrorWithGoContext,
@@ -312,6 +363,9 @@ export function normalizeInferenceErrorForRetry(
312363
const codexNormalized = normalizeCodexUsageLimitError(error);
313364
if (codexNormalized !== error) return codexNormalized;
314365

366+
const codexRateLimit = normalizeCodexRateLimitError(error);
367+
if (codexRateLimit !== error) return codexRateLimit;
368+
315369
if (!isGatewayOverloadInferenceError(error)) return error;
316370
if (error.category === "retryable" || error.category === "timeout") return error;
317371

src/tui/stream-event-map.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ describe("inference.error text", () => {
314314

315315
test("a classified failure gets its written line, not the provider body", () => {
316316
expect(message({ category: "credential_failure", message: '{"error":{"code":401}}' })).toBe(
317-
"Session expiredre-authenticating…",
317+
"Authentication failedlog in again.",
318318
);
319319
expect(message({ category: "quota_exhausted", message: "429" })).toBe(
320320
"Quota exhausted — usage limit reached.",
@@ -371,6 +371,29 @@ describe("inference.error text", () => {
371371
expect(event.message).not.toContain("Quota exhausted");
372372
});
373373

374+
test("ctx.providerId Codex + ChatGPT usage-limit 429 shows rate-limit copy", () => {
375+
const ctx = createStreamMapContext({ providerId: "codex/abk-labs" });
376+
const [event] = mapProductionEvent(
377+
{
378+
type: "inference.error",
379+
data: {
380+
error: {
381+
category: "quota_exhausted",
382+
message: "You have hit your ChatGPT usage limit",
383+
statusCode: 429,
384+
raw: "You have hit your ChatGPT usage limit",
385+
},
386+
},
387+
},
388+
ctx,
389+
);
390+
expect(event?.type).toBe("error");
391+
if (event?.type !== "error") return;
392+
expect(event.message.toLowerCase()).toMatch(/rate limit/);
393+
expect(event.message).not.toContain("Quota exhausted");
394+
expect(event.message.toLowerCase()).not.toContain("usage limit reached");
395+
});
396+
374397
test("bare quota_exhausted 429 without ctx/provider still shows Quota exhausted", () => {
375398
expect(
376399
message({

0 commit comments

Comments
 (0)