diff --git a/src/agents/embedded-agent-helpers/provider-runtime-failure.test.ts b/src/agents/embedded-agent-helpers/provider-runtime-failure.test.ts index fbc519819d3a..ee29fc7b14d1 100644 --- a/src/agents/embedded-agent-helpers/provider-runtime-failure.test.ts +++ b/src/agents/embedded-agent-helpers/provider-runtime-failure.test.ts @@ -17,6 +17,12 @@ function expectNotFailoverSample(sample: string) { } describe("classifyProviderRuntimeFailureKind", () => { + it("classifies complete HTML after an HTTP reason phrase as upstream_html", () => { + const raw = "HTTP 502 Bad Gateway\n\n
down"; + + expect(classifyProviderRuntimeFailureKind(raw)).toBe("upstream_html"); + }); + it("classifies generic resource-exhausted codes as rate_limit", () => { expect( classifyProviderRuntimeFailureKind({ diff --git a/src/agents/embedded-agent-helpers/provider-runtime-failure.ts b/src/agents/embedded-agent-helpers/provider-runtime-failure.ts index 94327e996277..56b71d254299 100644 --- a/src/agents/embedded-agent-helpers/provider-runtime-failure.ts +++ b/src/agents/embedded-agent-helpers/provider-runtime-failure.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { extractLeadingHttpStatus } from "../../shared/assistant-error-format.js"; +import { extractHttpResponseBody } from "../../shared/http-error-response.js"; import { classifyOAuthRefreshFailure } from "../auth-profiles/oauth-refresh-failure.js"; import { formatExecDeniedUserMessage } from "../exec-approval-result.js"; import { @@ -70,7 +71,7 @@ function isHtmlErrorResponse(raw: string, status?: number): boolean { if (typeof inferred !== "number" || inferred < 400) { return false; } - const rest = extractLeadingHttpStatus(candidate)?.rest ?? candidate; + const rest = extractHttpResponseBody(extractLeadingHttpStatus(candidate))?.body ?? candidate; return HTML_BODY_RE.test(rest) && HTML_CLOSE_RE.test(rest); } function isCloudflareChallengeResponse(message: string): boolean { diff --git a/src/shared/assistant-error-format.html.test.ts b/src/shared/assistant-error-format.html.test.ts index 55dd903abf01..729e0fa895ba 100644 --- a/src/shared/assistant-error-format.html.test.ts +++ b/src/shared/assistant-error-format.html.test.ts @@ -17,6 +17,16 @@ describe("isCloudflareOrHtmlErrorPage", () => { expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); }); + it("detects complete 5xx HTML pages after an HTTP reason phrase", () => { + const htmlError = "HTTP 502 Bad Gateway\n\ndown"; + expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true); + }); + + it("does not flag partial HTML after an HTTP reason phrase", () => { + const partialHtml = "HTTP 502 Bad Gateway\n\ndown"; + expect(isCloudflareOrHtmlErrorPage(partialHtml)).toBe(false); + }); + it("detects standalone Cloudflare challenge HTML pages", () => { // HTML challenge pages are provider transport failures, not model text. const htmlError = ` diff --git a/src/shared/assistant-error-format.test.ts b/src/shared/assistant-error-format.test.ts index 6093a3664cad..c3ca52044e2b 100644 --- a/src/shared/assistant-error-format.test.ts +++ b/src/shared/assistant-error-format.test.ts @@ -81,6 +81,18 @@ describe("extractErrorHttpStatus", () => { }); describe("HTTP status consumers", () => { + it("does not return raw HTML after an HTTP reason phrase", () => { + const raw = [ + "HTTP 502 Bad Gateway", + "", + "