Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions open-sse/executors/browserExecutableCheck.ts
Original file line number Diff line number Diff line change
@@ -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"))
);
}
23 changes: 7 additions & 16 deletions open-sse/executors/gemini-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down
24 changes: 21 additions & 3 deletions open-sse/executors/zai-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions open-sse/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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>,
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/zai-web-missing-browser-executable-13232.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
);
});
Loading