diff --git a/CHANGELOG.md b/CHANGELOG.md index 850918c0056..3cfc97e018e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Agents/tools: resolve non-workspace host tilde paths against the OS home directory and keep edit recovery aligned with that same path target, so `~/...` host edit/write operations stop failing or reading back the wrong file when `OPENCLAW_HOME` differs. (#62804) Thanks @stainlu. - Speech/TTS: auto-enable the bundled Microsoft and ElevenLabs speech providers, and route generic TTS directive tokens through the explicit or active provider first so overrides like `[[tts:speed=1.2]]` stop silently landing on the wrong provider. (#62846) Thanks @stainlu. - OpenAI Codex/models: normalize stale native transport metadata in both runtime resolution and discovery/listing so legacy `openai-codex` rows with missing `api` or `https://chatgpt.com/backend-api/v1` self-heal to the canonical Codex transport instead of routing requests through broken HTML/Cloudflare paths, combining the original fixes proposed in #66969 (saamuelng601-pixel) and #67159 (hclsys). (#67635) +- Agents/failover: treat HTML provider error pages as upstream transport failures for CDN-style 5xx responses without misclassifying embedded body text as API rate limits, while still preserving auth remediation for HTML 401/403 pages and proxy remediation for HTML 407 pages. (#67642) Thanks @stainlu. ## 2026.4.15-beta.1 diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 56063b82c81..070cae1ce45 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -167,21 +167,21 @@ 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", () => { + it("returns upstream HTML 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.", + "The provider returned an HTML error page instead of an API response. This usually means a CDN or gateway (e.g. Cloudflare) blocked the request. Retry in a moment or check provider status.", ); }); - it("falls back to generic copy for prefixed HTML rate-limit pages", () => { + it("returns upstream HTML 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.", + "The provider returned an HTML error page instead of an API response. This usually means a CDN or gateway (e.g. Cloudflare) blocked the request. Retry in a moment or check provider status.", ); }); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index ce674353e60..d97f3af6908 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -257,6 +257,7 @@ export type ProviderRuntimeFailureKind = | "auth_scope" | "auth_refresh" | "auth_html_403" + | "upstream_html" | "proxy" | "rate_limit" | "dns" @@ -324,11 +325,15 @@ const REPLAY_INVALID_RE = const SANDBOX_BLOCKED_RE = /\bapproval is required\b|\bapproval timed out\b|\bapproval was denied\b|\bblocked by sandbox\b|\bsandbox\b.*\b(?:blocked|denied|forbidden|disabled|not allowed)\b/i; +function stripErrorPrefix(raw: string): string { + return raw.replace(/^error:\s*/i, "").trim(); +} + function inferSignalStatus(signal: FailoverSignal): number | undefined { if (typeof signal.status === "number" && Number.isFinite(signal.status)) { return signal.status; } - return extractLeadingHttpStatus(signal.message?.trim() ?? "")?.code; + return extractLeadingHttpStatus(stripErrorPrefix(signal.message?.trim() ?? ""))?.code; } function isHtmlErrorResponse(raw: string, status?: number): boolean { @@ -336,17 +341,22 @@ function isHtmlErrorResponse(raw: string, status?: number): boolean { if (!trimmed) { return false; } + const candidate = extractLeadingHttpStatus(trimmed) ? trimmed : stripErrorPrefix(trimmed); const inferred = typeof status === "number" && Number.isFinite(status) ? status - : extractLeadingHttpStatus(trimmed)?.code; + : extractLeadingHttpStatus(candidate)?.code; if (typeof inferred !== "number" || inferred < 400) { return false; } - const rest = extractLeadingHttpStatus(trimmed)?.rest ?? trimmed; + const rest = extractLeadingHttpStatus(candidate)?.rest ?? candidate; return HTML_BODY_RE.test(rest) && HTML_CLOSE_RE.test(rest); } +function isTransportHtmlErrorStatus(status: number | undefined): boolean { + return status !== 401 && status !== 403 && status !== 407; +} + function isOpenAICodexScopeContext(raw: string, provider?: string): boolean { const normalizedProvider = normalizeLowercaseStringOrEmpty(provider); return ( @@ -669,7 +679,9 @@ function isOpenRouterKeyLimitExceededError(raw: string, provider?: string): bool } function isExactUnknownNoDetailsError(raw: string): boolean { - return normalizeOptionalLowercaseString(raw)?.trim() === "unknown error (no error details in response)"; + return ( + normalizeOptionalLowercaseString(raw)?.trim() === "unknown error (no error details in response)" + ); } function classifyFailoverClassificationFromMessage( @@ -757,6 +769,13 @@ function classifyFailoverClassificationFromMessage( export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassification | null { const inferredStatus = inferSignalStatus(signal); + if ( + signal.message && + isTransportHtmlErrorStatus(inferredStatus) && + isHtmlErrorResponse(signal.message, inferredStatus) + ) { + return toReasonClassification("timeout"); + } const messageClassification = signal.message ? classifyFailoverClassificationFromMessage(signal.message, signal.provider) : null; @@ -791,12 +810,12 @@ export function classifyProviderRuntimeFailureKind( if (message && isAuthScopeErrorMessage(message, status, normalizedSignal.provider)) { return "auth_scope"; } - if (message && status === 403 && isHtmlErrorResponse(message, status)) { - return "auth_html_403"; - } if (message && isProxyErrorMessage(message, status)) { return "proxy"; } + if (message && isHtmlErrorResponse(message, status)) { + return status === 403 ? "auth_html_403" : "upstream_html"; + } const failoverClassification = classifyFailoverSignal({ ...normalizedSignal, status, @@ -885,6 +904,14 @@ export function formatAssistantErrorText( ); } + if (providerRuntimeFailureKind === "upstream_html") { + return ( + "The provider returned an HTML error page instead of an API response. " + + "This usually means a CDN or gateway (e.g. Cloudflare) blocked the request. " + + "Retry in a moment or check provider status." + ); + } + if (providerRuntimeFailureKind === "proxy") { return "LLM request failed: proxy or tunnel configuration blocked the provider request."; } diff --git a/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts b/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts index 0e7bfbed0e4..0545470f6c7 100644 --- a/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts +++ b/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts @@ -16,7 +16,11 @@ vi.mock("../../plugins/provider-runtime.js", async () => { }; }); -import { classifyFailoverReason, isContextOverflowError } from "./errors.js"; +import { + classifyFailoverReason, + classifyProviderRuntimeFailureKind, + isContextOverflowError, +} from "./errors.js"; import { classifyProviderSpecificError, matchesProviderContextOverflow, @@ -146,3 +150,69 @@ describe("classifyFailoverReason with provider patterns", () => { ); }); }); + +describe("Cloudflare / CDN HTML error page classification (#67517)", () => { + const cloudflareHtml502 = + "502 Bad Gateway" + + "

502 Bad Gateway

cloudflare-nginx

"; + const cloudflareHtml503 = + "503" + + "

Service Unavailable

Please try again. Rate limit exceeded.

"; + const html401 = + "401 Unauthorized" + + "

Unauthorized

"; + const html403 = + "403 Forbidden" + + "

Forbidden

"; + const html407 = + "407 Proxy Authentication Required" + + "

Proxy Authentication Required

"; + const prefixedHtml401 = `Error: 401 ${html401}`; + const prefixedHtml407 = `Error: 407 ${html407}`; + + it("classifies Cloudflare HTML 502 as timeout", () => { + expect(classifyFailoverReason(`502 ${cloudflareHtml502}`)).toBe("timeout"); + }); + + it("classifies Cloudflare HTML 503 with rate-limit text as timeout", () => { + expect(classifyFailoverReason(`503 ${cloudflareHtml503}`)).toBe("timeout"); + }); + + it("preserves auth classification for 401 HTML", () => { + expect(classifyFailoverReason(`401 ${html401}`)).toBe("auth"); + }); + + it("preserves auth classification for 403 HTML", () => { + expect(classifyFailoverReason(`403 ${html403}`)).toBe("auth"); + }); + + it("preserves auth classification for Error-prefixed 401 HTML", () => { + expect(classifyFailoverReason(prefixedHtml401)).toBe("auth"); + }); + + it("classifies runtime failure kind as upstream_html for non-auth HTML", () => { + expect(classifyProviderRuntimeFailureKind({ status: 502, message: cloudflareHtml502 })).toBe( + "upstream_html", + ); + }); + + it("classifies 403 HTML runtime failures as auth_html_403", () => { + expect(classifyProviderRuntimeFailureKind({ status: 403, message: html403 })).toBe( + "auth_html_403", + ); + }); + + it("classifies 407 HTML runtime failures as proxy", () => { + expect(classifyProviderRuntimeFailureKind({ status: 407, message: html407 })).toBe("proxy"); + }); + + it("classifies Error-prefixed 407 HTML runtime failures as proxy", () => { + expect(classifyProviderRuntimeFailureKind(prefixedHtml407)).toBe("proxy"); + }); + + it("does not misclassify JSON API rate-limit responses as HTML", () => { + const jsonRateLimit = + '429 {"error":{"type":"rate_limit_error","message":"Rate limit exceeded"}}'; + expect(classifyFailoverReason(jsonRateLimit)).toBe("rate_limit"); + }); +});