feat(providers): classify chatgpt-session failures onto the router contract

This commit is contained in:
diegosouzapw
2026-09-02 01:41:59 -03:00
parent 5860c0d16f
commit 16fe5d6426
2 changed files with 162 additions and 0 deletions

View File

@@ -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<string, unknown>;
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" };
}

View File

@@ -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");
});