fix(gemini-web): surface missing Playwright browser as actionable 503 + cooldown hint, not a retryable 500 loop (#3516) (#3570)

Integrated into release/v3.8.20
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-10 10:26:14 -03:00
committed by GitHub
parent 297d4c078e
commit 6bfcf54120
4 changed files with 90 additions and 10 deletions

View File

@@ -22,6 +22,7 @@
- **fix(routing):** the `auto` model keyword now works on the Codex `/v1/responses` path — `resolveResponsesApiModel` rewrote the bare `auto` keyword to `codex/auto`, which ChatGPT rejects (`The 'auto' model is not supported when using Codex with a ChatGPT account`). `auto` (OmniRoute's zero-config auto-routing keyword) now passes through untouched so combo routing handles it. ([#3509](https://github.com/diegosouzapw/OmniRoute/issues/3509))
- **fix(cli-tools):** saving the OpenCode/CLI tool config no longer 400s in cloud mode — every CLI tool card posts `apiKey: null` (the real key is resolved server-side from `keyId`), but `guideSettingsSaveSchema` used `z.string().optional()`, which rejects `null`. The schema now normalizes `null``undefined`, so the save succeeds and the `keyId`/default path is used. ([#3552](https://github.com/diegosouzapw/OmniRoute/issues/3552))
- **fix(catalog):** PublicAI is no longer miscatalogued as keyless/free — it requires an API key (registry `authType:"apikey"`; signup grants a one-time credit, then it bills). The three PublicAI models moved from `freeType:"keyless"` (which could pick them into the no-auth pool and dispatch with no `Authorization` header) to `"one-time-initial"`, and the provider's `hasFree` flag is now `false` — matching `freeTierCatalog.ts`, which already excluded publicai. ([#3558](https://github.com/diegosouzapw/OmniRoute/issues/3558))
- **fix(gemini-web):** a missing Playwright Chromium browser no longer loops and trips the provider breaker — when the browser binary is not installed, `chromium.launch()` threw an error surfaced as a retryable **500**, so accountFallback marked the account unavailable and retry-looped. It is now classified as a host/config problem and returns **503** with an actionable message (`npx playwright install chromium`) and the `X-Omni-Fallback-Hint: connection_cooldown` header, which skips the provider circuit breaker and applies a short non-exponential cooldown. ([#3516](https://github.com/diegosouzapw/OmniRoute/issues/3516))
---

View File

@@ -19,6 +19,19 @@ import { sanitizeErrorMessage } from "../utils/error.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
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;
return /executable doesn't exist|executablenotfound|playwright install|chromium.*download/i.test(
message
);
}
const GEMINI_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36";
@@ -292,10 +305,36 @@ export class GeminiWebExecutor extends BaseExecutor {
transformedBody: body,
};
} catch (error) {
const rawMessage = error instanceof Error ? error.message : "Unknown error";
// #3516: a missing Playwright browser is a host/config problem, not a transient upstream
// fault. Surface an actionable error and tag it with the connection-cooldown hint so
// accountFallback skips the provider circuit breaker and applies a short, non-exponential
// cooldown instead of looping on a retryable 500.
if (isMissingBrowserExecutable(rawMessage)) {
return {
response: new Response(
JSON.stringify({
error:
"Gemini Web requires the Playwright Chromium browser, which is not installed. " +
"Run `npx playwright install chromium` on the host (or rebuild the Docker image with browsers).",
}),
{
status: 503,
headers: {
"Content-Type": "application/json",
"X-Omni-Fallback-Hint": "connection_cooldown",
},
}
),
url: GEMINI_URL,
headers: {},
transformedBody: body,
};
}
return {
response: new Response(
JSON.stringify({
error: sanitizeErrorMessage(error instanceof Error ? error.message : "Unknown error"),
error: sanitizeErrorMessage(rawMessage),
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
),

View File

@@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isMissingBrowserExecutable } from "../../open-sse/executors/gemini-web.ts";
// Regression for #3516: when the Playwright Chromium binary isn't installed, chromium.launch()
// throws "browserType.launch: Executable doesn't exist at ...". gemini-web returned that as a
// generic 500, which accountFallback treats as a retryable upstream error → the account is
// marked unavailable and the request loops/trips the provider breaker. A missing browser is a
// host/config problem, not a transient upstream fault — it must be classified so the executor
// can surface an actionable error and use the connection-cooldown hint instead of the 500 loop.
test("#3516 detects the Playwright missing-executable launch error", () => {
const msg =
"browserType.launch: Executable doesn't exist at /home/node/.cache/ms-playwright/" +
"chromium_headless_shell-1223/chrome-linux/headless_shell\n" +
"╔════════════════════════════════════════════════════════════╗\n" +
"║ Looks like Playwright Test or Playwright was just installed ║";
assert.equal(isMissingBrowserExecutable(msg), true);
});
test("#3516 detects the 'npx playwright install' guidance variant", () => {
assert.equal(isMissingBrowserExecutable("Please run the following command: npx playwright install"), true);
});
test("#3516 does NOT classify a normal upstream/network error as missing-executable", () => {
assert.equal(isMissingBrowserExecutable("No response from Gemini"), false);
assert.equal(isMissingBrowserExecutable("fetch failed: ECONNRESET"), false);
assert.equal(isMissingBrowserExecutable(""), false);
});

View File

@@ -75,18 +75,22 @@ test("Provider: gemini-web has correct models", async () => {
assert.ok(modelIds.includes("gemini-2.0-flash"));
});
// ─── Regression: #2832 — Playwright missing in Docker (runner-base) ──────────
// ─── Regression: #2832 / #3516 — Playwright missing in Docker (runner-base) ──
//
// When the `runner-base` Docker image is used (no Playwright browsers installed),
// `import("playwright")` succeeds but `chromium.launch()` throws the well-known
// "Executable doesn't exist" error. The executor MUST surface this as a sanitized
// 500 — never an unhandled rejection so users get a clear error message rather
// than a silent stream abort.
// "Executable doesn't exist" error. The executor MUST surface this as a structured,
// sanitized response — never an unhandled rejection / silent stream abort.
//
// Hard rule #12: error must go through sanitizeErrorMessage (no raw err.message
// or stack trace in the response body).
// #3516 superseded the original 500: a missing browser is a host/config problem,
// not a transient upstream fault, so it now returns 503 with the
// `X-Omni-Fallback-Hint: connection_cooldown` header (skips the provider circuit
// breaker, short non-exponential cooldown) and an actionable message — instead of a
// retryable 500 that marked the account unavailable and looped.
//
// Hard rule #12: the body carries no raw err.message stack trace.
test("#2832: Playwright launch failure returns sanitized 500, not unhandled rejection", async () => {
test("#2832/#3516: missing Playwright browser returns an actionable 503 with cooldown hint, not a retryable 500", async () => {
const playwrightError = new Error(
"browserType.launch: Executable doesn't exist at /home/node/.cache/ms-playwright/chromium_headless_shell-1161/chrome-linux/headless_shell\n" +
" at /app/node_modules/playwright-core/lib/server/browserType.js:123:19"
@@ -110,10 +114,17 @@ test("#2832: Playwright launch failure returns sanitized 500, not unhandled reje
log: null,
});
assert.equal(result.response.status, 500, "should return HTTP 500");
// #3516: missing browser → 503 + connection-cooldown hint (not a retryable 500 loop).
assert.equal(result.response.status, 503, "missing browser should return HTTP 503");
assert.equal(
result.response.headers.get("X-Omni-Fallback-Hint"),
"connection_cooldown",
"must signal connection cooldown so the provider breaker is skipped"
);
const json = (await result.response.json()) as any;
assert.ok(typeof json.error === "string", "error field must be a string");
// Hard rule #12: sanitizeErrorMessage must strip the stack trace tail.
assert.match(json.error, /playwright install|not installed/i, "message must be actionable");
// No raw stack trace / source path leaks into the body.
assert.ok(!json.error.includes("\n at "), "must not contain multi-line stack trace");
assert.ok(!json.error.includes("node_modules/playwright-core"), "must not contain node_modules source path");
} finally {