fix(errors): detect HTML after HTTP reason phrases

Share one internal HTTP response-body parser across assistant error formatting and provider runtime failure classification so reason phrases do not hide complete HTML error pages.

Fixes #122244.
This commit is contained in:
Pengyi Peng
2026-08-11 21:40:58 +00:00
committed by Shakker
parent f73b436f05
commit a816c4a3b4
7 changed files with 64 additions and 3 deletions
@@ -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<!doctype html><html><body>down</body></html>";
expect(classifyProviderRuntimeFailureKind(raw)).toBe("upstream_html");
});
it("classifies generic resource-exhausted codes as rate_limit", () => {
expect(
classifyProviderRuntimeFailureKind({
@@ -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 {
@@ -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\n<!doctype html><html><body>down</body></html>";
expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true);
});
it("does not flag partial HTML after an HTTP reason phrase", () => {
const partialHtml = "HTTP 502 Bad Gateway\n\n<!doctype html><html><body>down";
expect(isCloudflareOrHtmlErrorPage(partialHtml)).toBe(false);
});
it("detects standalone Cloudflare challenge HTML pages", () => {
// HTML challenge pages are provider transport failures, not model text.
const htmlError = `<!DOCTYPE html>
+12
View File
@@ -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",
"",
"<!doctype html><html><body><h1>502</h1></body></html>",
].join("\n");
expect(formatRawAssistantErrorForUi(raw)).toBe(
"The AI service is temporarily unavailable (HTTP 502). Please try again in a moment.",
);
});
it("formats only status lines inside the HTTP range", () => {
expect(formatRawAssistantErrorForUi("100 Continue")).toBe("HTTP 100: Continue");
expect(formatRawAssistantErrorForUi("599 Provider Error")).toBe("HTTP 599: Provider Error");
+3 -2
View File
@@ -1,5 +1,6 @@
// Assistant error formatting helpers normalize assistant-visible error payloads.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { extractHttpResponseBody } from "./http-error-response.js";
const ERROR_PAYLOAD_PREFIX_RE =
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error)(?:\s+\d{3})?[:\s-]+/i;
const HTTP_STATUS_DELIMITER_RE = /(?:\s*:\s*|\s+)/;
@@ -160,7 +161,7 @@ export function isCloudflareOrHtmlErrorPage(raw: string): boolean {
return true;
}
const status = extractLeadingHttpStatus(trimmed);
const status = extractHttpResponseBody(extractLeadingHttpStatus(trimmed));
if (!status || status.code < 500) {
return false;
}
@@ -170,7 +171,7 @@ export function isCloudflareOrHtmlErrorPage(raw: string): boolean {
}
return (
status.code < 600 && HTML_ERROR_PREFIX_RE.test(status.rest) && HTML_CLOSE_RE.test(status.rest)
status.code < 600 && HTML_ERROR_PREFIX_RE.test(status.body) && HTML_CLOSE_RE.test(status.body)
);
}
+17
View File
@@ -0,0 +1,17 @@
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
export function extractHttpResponseBody(
status: { code: number; rest: string } | null,
): { code: number; body: string } | null {
if (!status) {
return null;
}
if (HTML_ERROR_PREFIX_RE.test(status.rest)) {
return { code: status.code, body: status.rest };
}
const lineBreak = status.rest.indexOf("\n");
return {
code: status.code,
body: lineBreak === -1 ? status.rest : status.rest.slice(lineBreak + 1).trimStart(),
};
}
+14
View File
@@ -12,11 +12,25 @@ import {
isolateRtlRenderedLine,
isTerminalSafeAutocompleteValue,
isCommandMarkedMessage,
resolveFinalAssistantText,
sanitizeMarkdownSource,
sanitizeRenderableLine,
sanitizeRenderableText,
} from "./tui-formatters.js";
describe("resolveFinalAssistantText", () => {
it("hides complete HTML error pages after an HTTP reason phrase", () => {
const raw = "HTTP 502 Bad Gateway\n\n<!doctype html><html><body>down</body></html>";
const rendered = resolveFinalAssistantText({ errorMessage: raw });
expect(rendered).toBe(
"The AI service is temporarily unavailable (HTTP 502). Please try again in a moment.",
);
expect(rendered).not.toContain("<html>");
});
});
describe("formatTuiFooter", () => {
it("shows session modes and the process delivery mode in one compact summary", () => {
expect(