Skip to content

Commit e588e90

Browse files
stainluobviyus
andauthored
fix: classify HTML provider error pages correctly (#67642) (thanks @stainlu)
* fix(agents): classify Cloudflare/CDN HTML error pages as transport failures Fixes #67517 When a provider endpoint returns an HTML error page (e.g. Cloudflare 502/503/520-524), the pattern-based message classifiers would scan the HTML body and misinterpret embedded text like "Rate limit exceeded" as a structured rate_limit API error. This caused incorrect failover behavior (profile rotation instead of clean retry/fallback) and left the TUI stuck. Two fixes: 1. classifyFailoverSignal now short-circuits on HTML responses before running pattern matchers, returning "timeout" (transport failure) so retry/fallback handles them correctly. 2. classifyProviderRuntimeFailureKind now detects HTML errors at any status (not just 403), returning "upstream_html" for non-403 statuses with a clear user-facing message about CDN/gateway errors. Adds regression tests covering Cloudflare 502/503 HTML with embedded rate-limit text, 403 HTML (still classified as auth), and JSON rate-limit responses (still classified correctly). * fix: preserve auth and proxy HTML classification * fix: classify HTML provider error pages correctly (#67642) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent 55f05df commit e588e90

4 files changed

Lines changed: 110 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
3535
- 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.
3636
- 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.
3737
- 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)
38+
- 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.
3839

3940
## 2026.4.15-beta.1
4041

src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,21 +167,21 @@ describe("formatAssistantErrorText", () => {
167167
expect(result).toBe("⚠️ Your quota has been exhausted, try again in 24 hours");
168168
});
169169

170-
it("falls back to generic copy for HTML quota pages", () => {
170+
it("returns upstream HTML copy for HTML quota pages", () => {
171171
const msg = makeAssistantError(
172172
"429 <!DOCTYPE html><html><body>Your quota is exhausted</body></html>",
173173
);
174174
expect(formatAssistantErrorText(msg)).toBe(
175-
"⚠️ API rate limit reached. Please try again later.",
175+
"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.",
176176
);
177177
});
178178

179-
it("falls back to generic copy for prefixed HTML rate-limit pages", () => {
179+
it("returns upstream HTML copy for prefixed HTML rate-limit pages", () => {
180180
const msg = makeAssistantError(
181181
"Error: 521 <!DOCTYPE html><html><body>rate limit</body></html>",
182182
);
183183
expect(formatAssistantErrorText(msg)).toBe(
184-
"⚠️ API rate limit reached. Please try again later.",
184+
"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.",
185185
);
186186
});
187187

src/agents/pi-embedded-helpers/errors.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export type ProviderRuntimeFailureKind =
257257
| "auth_scope"
258258
| "auth_refresh"
259259
| "auth_html_403"
260+
| "upstream_html"
260261
| "proxy"
261262
| "rate_limit"
262263
| "dns"
@@ -324,29 +325,38 @@ const REPLAY_INVALID_RE =
324325
const SANDBOX_BLOCKED_RE =
325326
/\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;
326327

328+
function stripErrorPrefix(raw: string): string {
329+
return raw.replace(/^error:\s*/i, "").trim();
330+
}
331+
327332
function inferSignalStatus(signal: FailoverSignal): number | undefined {
328333
if (typeof signal.status === "number" && Number.isFinite(signal.status)) {
329334
return signal.status;
330335
}
331-
return extractLeadingHttpStatus(signal.message?.trim() ?? "")?.code;
336+
return extractLeadingHttpStatus(stripErrorPrefix(signal.message?.trim() ?? ""))?.code;
332337
}
333338

334339
function isHtmlErrorResponse(raw: string, status?: number): boolean {
335340
const trimmed = raw.trim();
336341
if (!trimmed) {
337342
return false;
338343
}
344+
const candidate = extractLeadingHttpStatus(trimmed) ? trimmed : stripErrorPrefix(trimmed);
339345
const inferred =
340346
typeof status === "number" && Number.isFinite(status)
341347
? status
342-
: extractLeadingHttpStatus(trimmed)?.code;
348+
: extractLeadingHttpStatus(candidate)?.code;
343349
if (typeof inferred !== "number" || inferred < 400) {
344350
return false;
345351
}
346-
const rest = extractLeadingHttpStatus(trimmed)?.rest ?? trimmed;
352+
const rest = extractLeadingHttpStatus(candidate)?.rest ?? candidate;
347353
return HTML_BODY_RE.test(rest) && HTML_CLOSE_RE.test(rest);
348354
}
349355

356+
function isTransportHtmlErrorStatus(status: number | undefined): boolean {
357+
return status !== 401 && status !== 403 && status !== 407;
358+
}
359+
350360
function isOpenAICodexScopeContext(raw: string, provider?: string): boolean {
351361
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
352362
return (
@@ -669,7 +679,9 @@ function isOpenRouterKeyLimitExceededError(raw: string, provider?: string): bool
669679
}
670680

671681
function isExactUnknownNoDetailsError(raw: string): boolean {
672-
return normalizeOptionalLowercaseString(raw)?.trim() === "unknown error (no error details in response)";
682+
return (
683+
normalizeOptionalLowercaseString(raw)?.trim() === "unknown error (no error details in response)"
684+
);
673685
}
674686

675687
function classifyFailoverClassificationFromMessage(
@@ -757,6 +769,13 @@ function classifyFailoverClassificationFromMessage(
757769

758770
export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassification | null {
759771
const inferredStatus = inferSignalStatus(signal);
772+
if (
773+
signal.message &&
774+
isTransportHtmlErrorStatus(inferredStatus) &&
775+
isHtmlErrorResponse(signal.message, inferredStatus)
776+
) {
777+
return toReasonClassification("timeout");
778+
}
760779
const messageClassification = signal.message
761780
? classifyFailoverClassificationFromMessage(signal.message, signal.provider)
762781
: null;
@@ -791,12 +810,12 @@ export function classifyProviderRuntimeFailureKind(
791810
if (message && isAuthScopeErrorMessage(message, status, normalizedSignal.provider)) {
792811
return "auth_scope";
793812
}
794-
if (message && status === 403 && isHtmlErrorResponse(message, status)) {
795-
return "auth_html_403";
796-
}
797813
if (message && isProxyErrorMessage(message, status)) {
798814
return "proxy";
799815
}
816+
if (message && isHtmlErrorResponse(message, status)) {
817+
return status === 403 ? "auth_html_403" : "upstream_html";
818+
}
800819
const failoverClassification = classifyFailoverSignal({
801820
...normalizedSignal,
802821
status,
@@ -885,6 +904,14 @@ export function formatAssistantErrorText(
885904
);
886905
}
887906

907+
if (providerRuntimeFailureKind === "upstream_html") {
908+
return (
909+
"The provider returned an HTML error page instead of an API response. " +
910+
"This usually means a CDN or gateway (e.g. Cloudflare) blocked the request. " +
911+
"Retry in a moment or check provider status."
912+
);
913+
}
914+
888915
if (providerRuntimeFailureKind === "proxy") {
889916
return "LLM request failed: proxy or tunnel configuration blocked the provider request.";
890917
}

src/agents/pi-embedded-helpers/provider-error-patterns.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ vi.mock("../../plugins/provider-runtime.js", async () => {
1616
};
1717
});
1818

19-
import { classifyFailoverReason, isContextOverflowError } from "./errors.js";
19+
import {
20+
classifyFailoverReason,
21+
classifyProviderRuntimeFailureKind,
22+
isContextOverflowError,
23+
} from "./errors.js";
2024
import {
2125
classifyProviderSpecificError,
2226
matchesProviderContextOverflow,
@@ -146,3 +150,69 @@ describe("classifyFailoverReason with provider patterns", () => {
146150
);
147151
});
148152
});
153+
154+
describe("Cloudflare / CDN HTML error page classification (#67517)", () => {
155+
const cloudflareHtml502 =
156+
"<!doctype html><html><head><title>502 Bad Gateway</title></head>" +
157+
"<body><h1>502 Bad Gateway</h1><p>cloudflare-nginx</p></body></html>";
158+
const cloudflareHtml503 =
159+
"<!doctype html><html><head><title>503</title></head>" +
160+
"<body><h1>Service Unavailable</h1><p>Please try again. Rate limit exceeded.</p></body></html>";
161+
const html401 =
162+
"<!doctype html><html><head><title>401 Unauthorized</title></head>" +
163+
"<body><h1>Unauthorized</h1></body></html>";
164+
const html403 =
165+
"<!doctype html><html><head><title>403 Forbidden</title></head>" +
166+
"<body><h1>Forbidden</h1></body></html>";
167+
const html407 =
168+
"<!doctype html><html><head><title>407 Proxy Authentication Required</title></head>" +
169+
"<body><h1>Proxy Authentication Required</h1></body></html>";
170+
const prefixedHtml401 = `Error: 401 ${html401}`;
171+
const prefixedHtml407 = `Error: 407 ${html407}`;
172+
173+
it("classifies Cloudflare HTML 502 as timeout", () => {
174+
expect(classifyFailoverReason(`502 ${cloudflareHtml502}`)).toBe("timeout");
175+
});
176+
177+
it("classifies Cloudflare HTML 503 with rate-limit text as timeout", () => {
178+
expect(classifyFailoverReason(`503 ${cloudflareHtml503}`)).toBe("timeout");
179+
});
180+
181+
it("preserves auth classification for 401 HTML", () => {
182+
expect(classifyFailoverReason(`401 ${html401}`)).toBe("auth");
183+
});
184+
185+
it("preserves auth classification for 403 HTML", () => {
186+
expect(classifyFailoverReason(`403 ${html403}`)).toBe("auth");
187+
});
188+
189+
it("preserves auth classification for Error-prefixed 401 HTML", () => {
190+
expect(classifyFailoverReason(prefixedHtml401)).toBe("auth");
191+
});
192+
193+
it("classifies runtime failure kind as upstream_html for non-auth HTML", () => {
194+
expect(classifyProviderRuntimeFailureKind({ status: 502, message: cloudflareHtml502 })).toBe(
195+
"upstream_html",
196+
);
197+
});
198+
199+
it("classifies 403 HTML runtime failures as auth_html_403", () => {
200+
expect(classifyProviderRuntimeFailureKind({ status: 403, message: html403 })).toBe(
201+
"auth_html_403",
202+
);
203+
});
204+
205+
it("classifies 407 HTML runtime failures as proxy", () => {
206+
expect(classifyProviderRuntimeFailureKind({ status: 407, message: html407 })).toBe("proxy");
207+
});
208+
209+
it("classifies Error-prefixed 407 HTML runtime failures as proxy", () => {
210+
expect(classifyProviderRuntimeFailureKind(prefixedHtml407)).toBe("proxy");
211+
});
212+
213+
it("does not misclassify JSON API rate-limit responses as HTML", () => {
214+
const jsonRateLimit =
215+
'429 {"error":{"type":"rate_limit_error","message":"Rate limit exceeded"}}';
216+
expect(classifyFailoverReason(jsonRateLimit)).toBe("rate_limit");
217+
});
218+
});

0 commit comments

Comments
 (0)