fix(errors): guard HTML rate limit copy

This commit is contained in:
Altay 2026-03-26 13:25:18 +03:00
parent e97efdd732
commit 755cff833c
3 changed files with 33 additions and 3 deletions

View File

@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp: fix infinite echo loop in self-chat DM mode where the bot's own outbound replies were re-processed as new inbound user messages. (#54570) Thanks @joelnishanth
- Discord/reconnect: drain stale gateway sockets, clear cached resume state before forced fresh reconnects, and fail closed when old sockets refuse to die so Discord recovery stops looping on poisoned resume state. (#54697) Thanks @ngutman.
- BlueBubbles/groups: optionally enrich unnamed participant lists with local macOS Contacts names after group gating passes, so group member context can show names instead of only raw phone numbers.
- Agents/errors: surface provider quota/reset details when available, but keep HTML/Cloudflare rate-limit pages on the generic fallback so raw error pages are not shown to users. (#54512) Thanks @bugkill3r.
## 2026.3.24

View File

@ -155,6 +155,24 @@ describe("formatAssistantErrorText", () => {
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

@ -66,19 +66,30 @@ 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);
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(raw.trim())?.rest || raw);
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("{")) {
if (
trimmed.length > 300 ||
trimmed.startsWith("{") ||
/^(?:<!doctype\s+html\b|<html\b)/i.test(trimmed)
) {
return undefined;
}