From 755cff833c1f6aca06ab2c3e1f802f35e7c4d553 Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 26 Mar 2026 13:25:18 +0300 Subject: [PATCH] fix(errors): guard HTML rate limit copy --- CHANGELOG.md | 1 + ...ed-helpers.formatassistanterrortext.test.ts | 18 ++++++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 17 ++++++++++++++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b5f09cd14..e1c08287fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 0b4a1c14641..cef0c6a4d35 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -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 Your quota is exhausted", + ); + 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 rate limit", + ); + 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."); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 0132fa33998..ff94ea58831 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -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("{") || + /^(?: