diff --git a/open-sse/executors/chatgpt-session/errors.ts b/open-sse/executors/chatgpt-session/errors.ts new file mode 100644 index 0000000000..20d874f40a --- /dev/null +++ b/open-sse/executors/chatgpt-session/errors.ts @@ -0,0 +1,85 @@ +/** + * Maps every failure this provider can produce onto the HTTP contract the router expects. + * + * The distinction that matters: a 503 with `connection_cooldown` lets combo routing skip this + * connection without opening the provider circuit breaker, while a 400 is terminal and must not + * be retried (a changed ChatGPT DOM will not fix itself on a retry). + */ + +import { ChatGptSessionInputError } from "./messages.ts"; + +export interface ChatGptSessionErrorClass { + status: number; + code: string; + fallbackHint?: "connection_cooldown"; +} + +interface ErrorLike { + message: string; + name?: string; + status?: number; + code?: string; +} + +function asErrorLike(error: unknown): ErrorLike { + if (error instanceof Error) { + const typed = error as Error & { status?: unknown; code?: unknown }; + return { + message: error.message, + name: error.name, + ...(typeof typed.status === "number" ? { status: typed.status } : {}), + ...(typeof typed.code === "string" ? { code: typed.code } : {}), + }; + } + if (error && typeof error === "object") { + const typed = error as Record; + return { + message: typeof typed.message === "string" ? typed.message : String(error), + ...(typeof typed.name === "string" ? { name: typed.name } : {}), + ...(typeof typed.status === "number" ? { status: typed.status } : {}), + ...(typeof typed.code === "string" ? { code: typed.code } : {}), + }; + } + return { message: String(error ?? "") }; +} + +const BROWSER_UNAVAILABLE = + /No supported Chrome|browserType\.launch|Executable doesn't exist|chromium.*not installed/i; +const MISSING_CREDENTIALS = + /credentials are missing|Cookie or verified browser storage state is required|Cookie header is missing/i; +const SESSION_EXPIRED = /not authenticated|storage state is invalid|sign ?in|log ?in|logged out/i; +const RATE_LIMITED = /rate limit|usage limit|too many requests|message limit/i; +const ROUTE_UNAVAILABLE = + /not available for this|not available while the account|is not supported/i; +const UI_TIMEOUT = /waitForSelector|Timeout \d+ms exceeded|actionability|interception/i; + +export function classifyChatGptSessionError(error: unknown): ChatGptSessionErrorClass { + if (error instanceof ChatGptSessionInputError) { + return { status: 400, code: error.code }; + } + + const like = asErrorLike(error); + + if (BROWSER_UNAVAILABLE.test(like.message)) { + return { status: 503, code: "browser_unavailable", fallbackHint: "connection_cooldown" }; + } + if (MISSING_CREDENTIALS.test(like.message)) { + return { status: 401, code: "missing_credentials" }; + } + if (SESSION_EXPIRED.test(like.message)) { + return { status: 401, code: "session_expired" }; + } + if (RATE_LIMITED.test(like.message)) { + return { status: 429, code: "rate_limited" }; + } + if (ROUTE_UNAVAILABLE.test(like.message)) { + return { status: 400, code: "route_unavailable" }; + } + if (like.name === "TimeoutError" || UI_TIMEOUT.test(like.message)) { + return { status: 400, code: "browser_ui_timeout" }; + } + if (typeof like.status === "number" && like.status >= 400) { + return { status: like.status, code: like.code ?? "turn_failed" }; + } + return { status: 502, code: "turn_failed" }; +} diff --git a/tests/unit/chatgpt-session-errors.test.ts b/tests/unit/chatgpt-session-errors.test.ts new file mode 100644 index 0000000000..cb83c747f6 --- /dev/null +++ b/tests/unit/chatgpt-session-errors.test.ts @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { classifyChatGptSessionError } from "../../open-sse/executors/chatgpt-session/errors.ts"; +import { ChatGptSessionInputError } from "../../open-sse/executors/chatgpt-session/messages.ts"; + +test("a missing browser is a cooldown-hinted 503", () => { + const result = classifyChatGptSessionError( + new Error("No supported Chrome or Chromium executable was found") + ); + assert.equal(result.status, 503); + assert.equal(result.code, "browser_unavailable"); + assert.equal(result.fallbackHint, "connection_cooldown"); +}); + +test("a playwright launch failure is also a cooldown-hinted 503", () => { + const result = classifyChatGptSessionError( + new Error("browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/x") + ); + assert.equal(result.status, 503); + assert.equal(result.fallbackHint, "connection_cooldown"); +}); + +test("missing credentials are a 401", () => { + assert.equal( + classifyChatGptSessionError(new Error("ChatGPT browser credentials are missing")).status, + 401 + ); +}); + +test("an expired session is a 401 session_expired", () => { + const result = classifyChatGptSessionError(new Error("ChatGPT page is not authenticated")); + assert.equal(result.status, 401); + assert.equal(result.code, "session_expired"); +}); + +test("a rate-limit dialog is a 429", () => { + const result = classifyChatGptSessionError(new Error("ChatGPT reported a usage limit")); + assert.equal(result.status, 429); + assert.equal(result.code, "rate_limited"); +}); + +test("an account-capability mismatch is a terminal 400", () => { + const result = classifyChatGptSessionError( + new Error("pro is not available for this non-Pro connection") + ); + assert.equal(result.status, 400); + assert.equal(result.code, "route_unavailable"); + assert.equal(result.fallbackHint, undefined); +}); + +test("a DOM timeout is a terminal 400, not a retryable 5xx", () => { + const timeout = new Error("locator.waitForSelector: Timeout 30000ms exceeded"); + timeout.name = "TimeoutError"; + const result = classifyChatGptSessionError(timeout); + assert.equal(result.status, 400); + assert.equal(result.code, "browser_ui_timeout"); +}); + +test("input errors map to their own 400 codes", () => { + const result = classifyChatGptSessionError( + new ChatGptSessionInputError("vision_unsupported", "no images") + ); + assert.equal(result.status, 400); + assert.equal(result.code, "vision_unsupported"); +}); + +test("an explicit upstream status wins over message matching", () => { + const result = classifyChatGptSessionError({ message: "anything", status: 502, code: "x" }); + assert.equal(result.status, 502); +}); + +test("an unrecognised failure is a retryable 502", () => { + const result = classifyChatGptSessionError(new Error("something odd happened")); + assert.equal(result.status, 502); + assert.equal(result.code, "turn_failed"); +});