fix: surface provider-specific rate limit error message (#54433) (#54512)

Merged via squash.

Prepared head SHA: 755cff833c
Co-authored-by: bugkill3r <2924124+bugkill3r@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
Saurabh Mishra
2026-03-26 17:46:06 +05:30
committed by GitHub
parent 2383daf5c4
commit 6fbe9dd935
3 changed files with 97 additions and 1 deletions

View File

@@ -121,6 +121,58 @@ describe("formatAssistantErrorText", () => {
expect(formatAssistantErrorText(msg)).toContain("rate limit reached");
});
it("surfaces provider-specific rate limit message with reset time (#54433)", () => {
const msg = makeAssistantError(
"You have hit your ChatGPT usage limit (go plan). Try again in ~4381 min.",
);
const result = formatAssistantErrorText(msg);
expect(result).toContain("4381 min");
expect(result).toContain("go plan");
expect(result).not.toBe("⚠️ API rate limit reached. Please try again later.");
});
it("surfaces provider-specific rate limit message from JSON payload (#54433)", () => {
const msg = makeAssistantError(
'429 {"type":"error","error":{"type":"rate_limit_error","message":"Rate limit reached. Try again in 30 seconds."}}',
);
const result = formatAssistantErrorText(msg);
expect(result).toContain("30 seconds");
expect(result).not.toBe("⚠️ API rate limit reached. Please try again later.");
});
it("returns generic rate limit message when no specific details are present", () => {
const msg = makeAssistantError("429 Too Many Requests");
expect(formatAssistantErrorText(msg)).toBe(
"⚠️ API rate limit reached. Please try again later.",
);
});
it("strips leading HTTP status code prefix from non-JSON rate limit messages", () => {
const msg = makeAssistantError("429 Your quota has been exhausted, try again in 24 hours");
const result = formatAssistantErrorText(msg);
expect(result).toContain("try again in 24 hours");
expect(result).not.toMatch(/^⚠️ 429\b/);
expect(result).toBe("⚠️ Your quota has been exhausted, try again in 24 hours");
});
it("falls back to generic copy for HTML quota pages", () => {
const msg = makeAssistantError(
"429 <!DOCTYPE html><html><body>Your quota is exhausted</body></html>",
);
expect(formatAssistantErrorText(msg)).toBe(
"⚠️ API rate limit reached. Please try again later.",
);
});
it("falls back to generic copy for prefixed HTML rate-limit pages", () => {
const msg = makeAssistantError(
"Error: 521 <!DOCTYPE html><html><body>rate limit</body></html>",
);
expect(formatAssistantErrorText(msg)).toBe(
"⚠️ API rate limit reached. Please try again later.",
);
});
it("returns a friendly message for empty stream chunk errors", () => {
const msg = makeAssistantError("request ended without sending any chunks");
expect(formatAssistantErrorText(msg)).toBe("LLM request timed out.");

View File

@@ -5,6 +5,7 @@ import {
extractLeadingHttpStatus,
formatRawAssistantErrorForUi,
isCloudflareOrHtmlErrorPage,
parseApiErrorInfo,
parseApiErrorPayload,
} from "../../shared/assistant-error-format.js";
export {
@@ -55,9 +56,51 @@ const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try
const OVERLOADED_ERROR_USER_MESSAGE =
"The AI service is temporarily overloaded. Please try again in a moment.";
/**
* Check whether the raw rate-limit error contains provider-specific details
* worth surfacing (e.g. reset times, plan names, quota info). Bare status
* codes like "429" or generic phrases like "rate limit exceeded" are not
* considered specific enough.
*/
const RATE_LIMIT_SPECIFIC_HINT_RE =
/\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\breset\b|\bplan\b|\bquota\b/i;
function extractProviderRateLimitMessage(raw: string): string | undefined {
const withoutPrefix = raw.replace(ERROR_PREFIX_RE, "").trim();
// Try to pull a human-readable message out of a JSON error payload first.
const info = parseApiErrorInfo(raw) ?? parseApiErrorInfo(withoutPrefix);
// When the raw string is not a JSON payload, strip any leading HTTP status
// code (e.g. "429 ") so the surfaced message stays clean.
const candidate =
info?.message ?? (extractLeadingHttpStatus(withoutPrefix)?.rest || withoutPrefix);
if (!candidate || !RATE_LIMIT_SPECIFIC_HINT_RE.test(candidate)) {
return undefined;
}
// Skip HTML/Cloudflare error pages even if the body mentions quota/plan text.
if (isCloudflareOrHtmlErrorPage(withoutPrefix)) {
return undefined;
}
// Avoid surfacing very long or clearly non-human-readable blobs.
const trimmed = candidate.trim();
if (
trimmed.length > 300 ||
trimmed.startsWith("{") ||
/^(?:<!doctype\s+html\b|<html\b)/i.test(trimmed)
) {
return undefined;
}
return `⚠️ ${trimmed}`;
}
function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined {
if (isRateLimitErrorMessage(raw)) {
return RATE_LIMIT_ERROR_USER_MESSAGE;
// Surface the provider's specific message when it contains actionable
// details (reset time, plan name, quota info) instead of the generic copy.
return extractProviderRateLimitMessage(raw) ?? RATE_LIMIT_ERROR_USER_MESSAGE;
}
if (isOverloadedErrorMessage(raw)) {
return OVERLOADED_ERROR_USER_MESSAGE;