fix: pretty-print ChatGPT usage_limit_reached errors with reset time (#1177)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
jnjpng
2026-02-26 17:15:40 -08:00
committed by GitHub
parent 462d14edce
commit 932147b642
4 changed files with 193 additions and 1 deletions

View File

@@ -60,6 +60,7 @@ const NON_RETRYABLE_429_REASONS = [
const NON_RETRYABLE_QUOTA_DETAIL_PATTERNS = [
"hosted model usage limit",
"out of credits",
"usage_limit_reached",
];
const NON_RETRYABLE_4XX_PATTERN = /Error code:\s*4(0[0-8]|1\d|2\d|3\d|4\d|51)/i;
const RETRYABLE_429_PATTERN = /Error code:\s*429|rate limit|too many requests/i;

View File

@@ -180,6 +180,93 @@ function getTierUsageLimitMessage(reasons: string[]): string | undefined {
return undefined;
}
const CHATGPT_USAGE_LIMIT_HINT =
"Switch models with /model, or connect your own provider keys with /connect.";
/**
* Check if a string contains a ChatGPT usage_limit_reached error with optional
* reset timing, and return a friendly message.
*
* ChatGPT wraps the error as embedded JSON inside a detail string like:
* RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached",...}}
*/
export function checkChatGptUsageLimitError(text: string): string | undefined {
if (!text.includes("usage_limit_reached")) return undefined;
// Try to extract the embedded JSON object
const jsonStart = text.indexOf("{");
if (jsonStart < 0) {
return `ChatGPT usage limit reached. ${CHATGPT_USAGE_LIMIT_HINT}`;
}
try {
const parsed = JSON.parse(text.slice(jsonStart));
const errorObj = parsed.error || parsed;
if (errorObj.type !== "usage_limit_reached") return undefined;
// Extract plan type
const planType = errorObj.plan_type;
const planInfo = planType ? ` (${planType} plan)` : "";
// Extract reset timing — prefer resets_in_seconds, fall back to resets_at
let resetInfo = "Try again later";
if (
typeof errorObj.resets_in_seconds === "number" &&
errorObj.resets_in_seconds > 0
) {
resetInfo = formatResetTime(errorObj.resets_in_seconds * 1000);
} else if (typeof errorObj.resets_at === "number") {
const resetMs = errorObj.resets_at * 1000 - Date.now();
if (resetMs > 0) {
resetInfo = formatResetTime(resetMs);
}
}
return `ChatGPT usage limit reached${planInfo}. ${resetInfo}.\n${CHATGPT_USAGE_LIMIT_HINT}`;
} catch {
// JSON parse failed — return generic message
return `ChatGPT usage limit reached. ${CHATGPT_USAGE_LIMIT_HINT}`;
}
}
/**
* Walk an error object to find a ChatGPT usage_limit_reached detail string
* and format it. Handles APIError, nested run-metadata objects, and strings.
*/
function findAndFormatChatGptUsageLimit(e: unknown): string | undefined {
// Direct string
if (typeof e === "string") return checkChatGptUsageLimitError(e);
if (typeof e !== "object" || e === null) return undefined;
// APIError or Error — check .message
if (e instanceof Error) {
const msg = checkChatGptUsageLimitError(e.message);
if (msg) return msg;
}
const obj = e as Record<string, unknown>;
// Check e.error.error.detail (run-metadata shape)
if (obj.error && typeof obj.error === "object") {
const errObj = obj.error as Record<string, unknown>;
if (errObj.error && typeof errObj.error === "object") {
const inner = errObj.error as Record<string, unknown>;
if (typeof inner.detail === "string") {
const msg = checkChatGptUsageLimitError(inner.detail);
if (msg) return msg;
}
}
// Check e.error.detail
if (typeof errObj.detail === "string") {
const msg = checkChatGptUsageLimitError(errObj.detail);
if (msg) return msg;
}
}
return undefined;
}
const ENCRYPTED_CONTENT_HINT = [
"",
"This occurs when the conversation contains messages with encrypted",
@@ -307,6 +394,12 @@ export function formatErrorDetails(
const encryptedContentMsg = checkEncryptedContentError(e);
if (encryptedContentMsg) return encryptedContentMsg;
// Check for ChatGPT usage limit errors — walk nested error objects like
// checkEncryptedContentError does, since these arrive both as APIError
// and as plain run-metadata objects ({error: {error: {detail: "..."}}})
const chatGptUsageLimitMsg = findAndFormatChatGptUsageLimit(e);
if (chatGptUsageLimitMsg) return chatGptUsageLimitMsg;
// Check for Z.ai provider errors (wrapped in generic "OpenAI" messages)
const errorText =
e instanceof APIError

View File

@@ -4,7 +4,10 @@ import {
clearErrorContext,
setErrorContext,
} from "../../cli/helpers/errorContext";
import { formatErrorDetails } from "../../cli/helpers/errorFormatter";
import {
checkChatGptUsageLimitError,
formatErrorDetails,
} from "../../cli/helpers/errorFormatter";
describe("formatErrorDetails", () => {
beforeEach(() => {
@@ -236,6 +239,91 @@ describe("formatErrorDetails", () => {
expect(message).toContain("/model");
});
describe("ChatGPT usage_limit_reached", () => {
const chatGptRateLimitDetail =
'RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"team","resets_at":1772074086,"eligible_promo":null,"resets_in_seconds":3032}}';
test("pretty-prints with reset time and plan type", () => {
const result = checkChatGptUsageLimitError(chatGptRateLimitDetail);
expect(result).toBeDefined();
expect(result).toContain("ChatGPT usage limit reached");
expect(result).toContain("team plan");
expect(result).toContain("Resets at");
expect(result).toContain("/model");
expect(result).toContain("/connect");
// Should NOT contain raw JSON
expect(result).not.toContain('"type"');
expect(result).not.toContain("RATE_LIMIT_EXCEEDED");
});
test("handles error with only resets_at (no resets_in_seconds)", () => {
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
const detail = `RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"plus","resets_at":${futureTimestamp}}}`;
const result = checkChatGptUsageLimitError(detail);
expect(result).toBeDefined();
expect(result).toContain("ChatGPT usage limit reached");
expect(result).toContain("plus plan");
expect(result).toContain("Resets at");
});
test("handles error with no reset info gracefully", () => {
const detail =
'RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}';
const result = checkChatGptUsageLimitError(detail);
expect(result).toBeDefined();
expect(result).toContain("ChatGPT usage limit reached");
expect(result).toContain("Try again later");
expect(result).toContain("/model");
});
test("handles malformed JSON gracefully", () => {
const detail =
"RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: usage_limit_reached {broken json";
const result = checkChatGptUsageLimitError(detail);
expect(result).toBeDefined();
expect(result).toContain("ChatGPT usage limit reached");
});
test("returns undefined for non-matching errors", () => {
const result = checkChatGptUsageLimitError(
"ChatGPT API error: some other error",
);
expect(result).toBeUndefined();
});
test("formats correctly via formatErrorDetails from run metadata object", () => {
// Shape constructed in App.tsx from run.metadata.error
const errorObject = {
error: {
error: {
message_type: "error_message",
run_id: "run-abc123",
error_type: "llm_error",
message: "An error occurred during agent execution.",
detail: chatGptRateLimitDetail,
},
run_id: "run-abc123",
},
};
const result = formatErrorDetails(errorObject);
expect(result).toContain("ChatGPT usage limit reached");
expect(result).toContain("team plan");
expect(result).toContain("/model");
// Should NOT contain the raw detail
expect(result).not.toContain("RATE_LIMIT_EXCEEDED");
expect(result).not.toContain("[usage_limit_reached]");
});
});
test("formats Z.ai error from APIError with embedded error code", () => {
const error = new APIError(
429,

View File

@@ -211,6 +211,16 @@ describe("provider detail retry helpers", () => {
).toBe(false);
});
test("ChatGPT usage_limit_reached is non-retryable", () => {
const detail =
'RATE_LIMIT_EXCEEDED: ChatGPT rate limit exceeded: {"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"team","resets_at":1772074086,"resets_in_seconds":3032}}';
expect(shouldRetryRunMetadataError("llm_error", detail)).toBe(false);
expect(shouldRetryPreStreamTransientError({ status: 429, detail })).toBe(
false,
);
});
test("pre-stream transient classifier handles status and detail", () => {
expect(
shouldRetryPreStreamTransientError({