fix(sse): classify missing Chromium as a Z.ai host/config cooldown (#13232) (#13777)

Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-16 06:15:47 -03:00
committed by GitHub
parent 74e44d7630
commit 0dbd7f47d2
6 changed files with 136 additions and 21 deletions

View File

@@ -0,0 +1 @@
- **fix(sse):** classify a missing Playwright Chromium install on the Z.ai web transport as an actionable 503 host/config cooldown instead of a generic 502 that trips the provider circuit breaker (#13232) — thanks @oleksandr1811

View File

@@ -0,0 +1,18 @@
/**
* Shared classification for browser-backed executors: distinguishes a missing Playwright
* Chromium binary (`chromium.launch: Executable doesn't exist at ...`) from a transient upstream
* fault. This is a host/config problem, not something a retry loop can fix, so executors must
* NOT surface it as a plain retryable 5xx (which marks the account unavailable / trips the
* provider circuit breaker). Originally added for `gemini-web.ts` (#3516); extracted here so
* every browser-backed executor (Gemini Web, Z.ai Web, ...) can share the same detection.
*/
export function isMissingBrowserExecutable(message: string): boolean {
if (!message) return false;
const lower = message.toLowerCase();
return (
lower.includes("executable doesn't exist") ||
lower.includes("executablenotfound") ||
lower.includes("playwright install") ||
(lower.includes("chromium") && lower.includes("download"))
);
}

View File

@@ -15,6 +15,7 @@
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
import { normalizeGeminiCookieInput } from "../utils/geminiCookies.ts";
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
@@ -27,22 +28,12 @@ import {
const GEMINI_URL = "https://gemini.google.com/app";
/**
* Whether an error came from Playwright failing to launch because the browser binary is not
* installed (`chromium.launch: Executable doesn't exist at ...`). This is a host/config
* problem, not a transient upstream fault, so the executor must NOT surface it as a retryable
* 500 (which marks the account unavailable and loops / trips the provider breaker). See #3516.
*/
export function isMissingBrowserExecutable(message: string): boolean {
if (!message) return false;
const lower = message.toLowerCase();
return (
lower.includes("executable doesn't exist") ||
lower.includes("executablenotfound") ||
lower.includes("playwright install") ||
(lower.includes("chromium") && lower.includes("download"))
);
}
// Re-exported for backward compatibility: some tests/callers import this classification helper
// from gemini-web.ts, its original home (#3516). The implementation now lives in
// browserExecutableCheck.ts so other browser-backed executors (e.g. zai-web.ts, #13232) can
// share it without importing this whole executor module.
export { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
const GEMINI_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";

View File

@@ -51,6 +51,7 @@ import {
makeZaiChunkEmitter,
} from "./zai-web/stream.ts";
import { browserBackedChat } from "../services/browserBackedChat.ts";
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
import { CursorImageError, resolveCursorImages } from "../utils/cursorImages.ts";
import {
makeExecutorErrorResult as makeErrorResult,
@@ -424,9 +425,26 @@ export class ZaiWebExecutor extends BaseExecutor {
try {
result = await browserBackedChat(buildZaiBrowserChatOptions({ ...input, attachments }));
} catch (error) {
const message = sanitizeErrorMessage(
error instanceof Error ? error.message : "browser transport unavailable"
);
const rawMessage = error instanceof Error ? error.message : "browser transport unavailable";
// #13232: a missing Playwright browser binary is a host/config problem, not a transient
// upstream fault (same class as #3516 in gemini-web.ts). Surface an actionable message and
// tag it with the connection-cooldown hint so accountFallback skips the whole-provider
// circuit breaker (502/500 would trip it) and applies a short, non-exponential cooldown
// instead.
if (isMissingBrowserExecutable(rawMessage)) {
return {
errorResult: makeErrorResult(
503,
"Z.ai requires the Playwright Chromium browser, which is not installed. " +
"Run `npx playwright install chromium` on the host (or rebuild the Docker image " +
"with browsers).",
input.body,
ZAI_CHAT_URL,
{ "X-Omni-Fallback-Hint": "connection_cooldown" }
),
};
}
const message = sanitizeErrorMessage(rawMessage);
return {
errorResult: makeErrorResult(
502,

View File

@@ -1134,7 +1134,8 @@ export function makeExecutorErrorResult(
status: number,
message: string,
body: unknown,
url: string
url: string,
extraResponseHeaders?: Record<string, string>
) {
return {
response: new Response(
@@ -1145,7 +1146,10 @@ export function makeExecutorErrorResult(
code: `HTTP_${status}`,
},
}),
{ status, headers: { "Content-Type": "application/json" } }
{
status,
headers: { "Content-Type": "application/json", ...extraResponseHeaders },
}
),
url,
headers: {} as Record<string, string>,

View File

@@ -0,0 +1,83 @@
/**
* Regression for GitHub issue #13232 — "[BUG] Z.ai web error".
*
* The Z.ai web transport drives a real headed Chromium browser (via Playwright) to get past
* Z.ai's CAPTCHA. When the local Playwright Chromium binary is missing,
* `browserType.launch()` throws "Executable doesn't exist at ...". Before this fix, zai-web.ts
* had no classification for that failure and surfaced it as a plain 502 with no fallback hint —
* a status that trips the whole-provider circuit breaker (`AGENTS.md` → "Provider Circuit
* Breaker") as if the upstream itself were failing, instead of applying the intended
* host/config connection cooldown. This mirrors the exact failure class already handled for
* Gemini Web in #3516 (`isMissingBrowserExecutable`, now shared via
* `open-sse/executors/browserExecutableCheck.ts`).
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Buffer } from "node:buffer";
const mod = await import("../../open-sse/executors/zai-web.ts");
const TEST_TOKEN = `e30.${Buffer.from(JSON.stringify({ id: "user-123" })).toString("base64url")}.sig`;
describe("issue #13232 — Z.ai browser transport classifies a missing Chromium install", () => {
let emptyBrowsersDir: string;
let originalBrowsersPath: string | undefined;
before(() => {
emptyBrowsersDir = fs.mkdtempSync(path.join(os.tmpdir(), "playwright-empty-"));
originalBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
// Force chromium.launch() to genuinely fail with the exact class of error the reporter hit
// ("Executable doesn't exist at ..."), without touching any real ~/.cache/ms-playwright
// install.
process.env.PLAYWRIGHT_BROWSERS_PATH = emptyBrowsersDir;
});
after(() => {
if (originalBrowsersPath === undefined) {
delete process.env.PLAYWRIGHT_BROWSERS_PATH;
} else {
process.env.PLAYWRIGHT_BROWSERS_PATH = originalBrowsersPath;
}
fs.rmSync(emptyBrowsersDir, { recursive: true, force: true });
});
it(
"returns a classified 503 + X-Omni-Fallback-Hint: connection_cooldown instead of a bare " +
"502 (contrast: gemini-web.ts isMissingBrowserExecutable, #3516)",
async () => {
const executor = new mod.ZaiWebExecutor();
const body = { model: "glm-5.3-flash", messages: [{ role: "user", content: "hi" }] };
const result = await executor.execute({
model: "glm-5.3-flash",
body,
stream: false,
credentials: { apiKey: TEST_TOKEN },
signal: null,
});
assert.ok("response" in result, "expected an error Response, not a stream result");
const response = (result as { response: Response }).response;
const payload = (await response.json()) as { error?: { message?: string } };
assert.equal(
response.status,
503,
"zai-web must classify a missing local Chromium install as a host/config error (503), " +
"not a generic retryable 502 that trips the whole-provider circuit breaker."
);
assert.equal(
response.headers.get("X-Omni-Fallback-Hint"),
"connection_cooldown",
"the connection-cooldown hint must be set so accountFallback applies a short cooldown " +
"instead of tripping the provider circuit breaker."
);
assert.match(
payload.error?.message ?? "",
/Playwright Chromium browser.*not installed.*npx playwright install chromium/s
);
}
);
});