Scope: operating a running
chatgpt-web2apideployment — interpreting health, diagnosing failure modes, understanding breaker states, recovering auth, collecting logs, and restarting safely. For installation see deployment.md; for OS-supervisor wiring see os-supervision.md.
All facts below are verified against the source (cited as file:line in the
appendix). Thresholds and field names are exact, not paraphrased — an incident
is the worst time to discover a guessed state name.
Before declaring a deployment live, confirm each row:
| # | Check | Command / where | Expected |
|---|---|---|---|
| 1 | Chrome is reachable on the CDP port | curl -sf http://127.0.0.1:9222/json/version |
200 + JSON (browser version) |
| 2 | REST /health returns healthy or starting |
curl -s http://127.0.0.1:8080/health |
status ∈ {healthy,starting}, chrome_running=true, driver_connected=true |
| 3 | No breakers open | /health → open_breakers |
[] |
| 4 | last_error is null or explained |
/health → last_error |
null in steady state |
| 5 | MCP/SSE handshake succeeds (if MCP used) | ensure exits 0; or an MCP client initialize + list_tools |
tools returned, no error |
| 6 | One real chat send succeeds | POST /v1/chat/completions non-stream "Reply with exactly: ok" |
HTTP 200, content ok, finish_reason=stop |
Row 6 is the only end-to-end signal — the rest are necessary preconditions. A deployment that passes 1–5 but fails 6 is "up but not serving" and must be treated as down. See §3 failure modes.
chatgpt-web2api ensure --rest-port 8080 --mcp-sse-port 8090 --cdp-port 9222ensure is point-in-time: it reconciles REST + MCP/SSE to a healthy state
and exits. It is not a daemon. Exit codes:
| Code | Meaning | Action |
|---|---|---|
0 |
REST + SSE ready | nothing |
1 |
reconcile failure (REST or SSE could not be made ready) | inspect logs, re-run after fix |
2 |
auth/login needed (auth_required breaker open) |
human login required (see §6) |
⚠️ python -m chatgpt_web2api.ensuredoes not work — theensuresubmodule has no__main__block. Use the console-script subcommand only.
ensure launches REST and MCP/SSE as detached child processes (POSIX
start_new_session=True; Windows DETACHED_PROCESS) so they outlive the
ensure invocation. There is no continuous in-process supervisor — later
failures require re-running ensure or an OS supervisor (see
os-supervision.md).
- REST owns Chrome. REST is launched first; it manages the Chrome process.
- MCP/SSE attaches. MCP/SSE is a separate process that connects to the already-running Chrome over the shared CDP port — it never launches Chrome.
ensurereconciles REST first; SSE is never reconciled until REST is ready, and if REST needs auth (exit 2) SSE is skipped entirely.
GET http://localhost:8080/health (also served at /). No auth required.
| Field | Type | Meaning |
|---|---|---|
status |
string | Derived state — see states table |
chrome_running |
bool | Live probe to http://127.0.0.1:<cdp_port>/json/version returned 200 |
cdp_connected |
bool | Alias of driver_connected |
driver_connected |
bool | CDP driver is connected |
requests_served |
int | Accepted chat requests since REST start. Incremented at the start of request handling (after auth, before JSON parsing) — a malformed/failed request still counts. This is not a count of successful sends; for that see last_successful_send_at. |
started_at |
float | REST process start time (epoch seconds; compute uptime as now - started_at) |
last_successful_send_at |
float | null | Epoch of last successful chat send; null until first success |
last_error |
string | null | Latching last error "<ExcType>: <msg>" |
open_breakers |
list[string] | .value strings of currently-open breakers; [] = none open |
breakers |
object | Full per-kind snapshot — see §5 |
There is no
uptimefield. Compute it fromstarted_at.
Status is computed sequentially; earlier branches win. An open breaker can
only downgrade starting/healthy → degraded — it can never upgrade to
broken and never overrides an existing broken (Chrome-down is the harder
failure, and broken invites a destructive supervisor restart).
status |
Exact condition | Meaning |
|---|---|---|
broken |
chrome_running == false |
Chrome is down (probe to /json/version failed). REST will be restarted by ensure. |
degraded |
Chrome up but driver_connected == false, OR a breaker is open overlaying starting/healthy |
Up but refusing some/all traffic. Do not restart-loop — see §7. |
starting |
Chrome up, driver connected, but zero chat requests accepted (request_count == 0) and no send recorded (last_successful_send_at is null) |
Cold bootstrap. Give it room; do not restart. |
healthy |
Chrome up, driver connected, and not (last_successful_send_at is null AND request_count == 0) |
REST/Chrome/driver are up and the server has seen traffic or a send has succeeded. Caveat: does not guarantee a send succeeded — request_count increments before validation, so one malformed request flips starting→healthy. True read-readiness is last_successful_send_at != null or a passing post-deploy send (§8). |
status |
chrome_running |
driver_connected |
breaker open? | ensure action |
|---|---|---|---|---|
broken |
false | any | n/a | immediate REST restart |
degraded (driver dead) |
true | false | — | poll up to budget, then restart (unless auth) |
degraded (breaker trip) |
true | true | yes | wait cooldown+grace (timed) / exit 2 (auth) |
starting |
true | true | no | wait up to 30s for connectivity |
healthy |
true | true | no | none |
Ordered roughly by frequency in practice.
- Symptom:
/healthopen_breakerscontains"auth_required";ensureexits 2; chat requests return HTTP 503circuit_open. - Cause: ChatGPT session token expired or was invalidated. The REST
preflight attempts a token refresh first (
recover_auth); only if that fails does the breaker stay open. - Fix: human login — see §6. This breaker is
indefinite (never auto-recovers); only a successful token refresh closes
it.
ensurewill not restart REST for this — a restart can't fix a login problem.
- Symptom:
open_breakerscontains"chrome_crash_loop"; Chrome restarts repeatedly. - Cause: 3 Chrome restarts within a 300s window. Usually a resource issue (out of memory, display/Xvfb missing on a headless server), a Chrome profile lock, or an OS update breaking the Chrome binary.
- Fix: check Chrome can launch manually; check display/headless config;
clear the Chrome profile lock if stale. Cooldown is 300s — it half-opens
after that, and
ensurerestarts REST if still open pastcooldown + grace.
- Symptom:
open_breakerscontains"cdp_reconnect"; transient. - Cause: 5 reconnect failures in 120s. Often a transient CDP socket drop (Chrome update, tab crash, system suspend/resume). Usually self-heals.
- Fix: usually none — cooldown 120s, then a successful reconnect closes it.
If it persists, Chrome may be wedged; a
brokenstatus will follow andensurewill restart REST.
- Symptom:
open_breakerscontains"composer_send_readiness"; sends fail preflight. - Cause: 3 send-readiness failures in 120s. Typically a ChatGPT DOM change (selector drift) or a transient page state where the composer isn't ready.
- Fix: cooldown 300s; a confirmed successful send closes it. Persistent trips on a stable ChatGPT suggest selector drift — check release notes / DOM.
- Symptom: Chrome up, driver disconnected, no breaker tripped.
- Cause: CDP driver disconnected but not yet tripped (below threshold), or recovering.
- Fix: let
ensure's degraded policy run (poll up to ~20s, then restart). Do not race it with a faster supervisor restart — see §7.
- Symptom: Chrome not reachable on the CDP port.
- Cause: Chrome crashed, was killed, or the port probe is blocked.
- Fix:
ensurerestarts REST (which relaunches Chrome) immediately. If it loops, you'll land inchrome_crash_loop(3.2).
- Symptom: chat 503s with
code: circuit_open, but RESTstatus=healthy. - Two possible causes:
- REST breaker opened between reads — re-read
/health;open_breakerswill now be non-empty. Normal eventual consistency between the snapshot and a tripped REST breaker. - The request was MCP, and the breaker is in MCP's local registry —
REST
/healthstays healthy because MCP has a separate registry with no cross-process propagation (see §4). Inspect MCP logs forCircuit open for <kind>; do not rely on/health.
- REST breaker opened between reads — re-read
REST (HTTP):
HTTP 503
{
"error": {
"message": "Circuit open for <kind> — cooling down. Retry later.",
"type": "server_error",
"param": null,
"code": "circuit_open"
}
}<kind> is the breaker's .value string (e.g. auth_required,
composer_send_readiness).
MCP (tool result): isError: true, text
"Circuit open for <kind> — cooling down. Retry later. (circuit_open, kind=<kind>)".
⚠️ MCP has its own breaker registry. The MCP process creates a separate localBreakerRegistry(mcp_server.py:1891) with no cross-process propagation to/from REST (mcp_server.py:1890,664). REST's/healthserializes REST's registry only — it does not reflect MCP-local breaker state. An MCPcircuit_opencan occur while REST/healthstill showsopen_breakers: []andstatus: healthy.
REST circuit_open: check REST /health for the authoritative list:
GET http://localhost:8080/health → open_breakers, breakers{}
This is reliable for REST because the failing request and the health snapshot share the same registry.
MCP circuit_open: /health is not a reliable source — the open
breaker is in MCP's local registry, not REST's. Inspect the MCP process logs
(stderr via the supervisor) for the Circuit open for <kind> line, then act on
that <kind> per §3 failure modes and
§5 breakers. Restart or wait the MCP process
according to your supervisor policy (see os-supervision.md).
(Exception: the auth_required breaker is effectively shared in practice —
both processes trip it on the same expired session — but the registry state is
still per-process; confirm via MCP logs.)
first_open() returns breakers in enum order (auth_required,
composer_send_readiness, cdp_reconnect, chrome_crash_loop). An error
naming one breaker does not mean others aren't also open — within REST,
check /health open_breakers for the full list; within MCP, grep the logs for
all Circuit open lines.
The system tracks exactly four breakers. All thresholds/cooldowns are
hardcoded in breakers.py — there is no env override (do not look for
W2A_BREAKER_*; it does not exist). The only breaker-adjacent env tunable is
W2A_ENSURE_BREAKER_COOLDOWN_GRACE_S (default 5s), which is an ensure
supervisor grace budget, not a breaker cooldown.
Kind (.value) |
Trips on | Threshold / window | Cooldown | Resets on |
|---|---|---|---|---|
auth_required |
explicit trip() — login page instead of data, or HTTP 401 |
single-shot | indefinite (None) |
successful recover_auth() token refresh (explicit reset()) — never auto-recovers |
composer_send_readiness |
send-readiness / send failures | 3 / 120s | 300s | a confirmed successful send (record_success) |
cdp_reconnect |
failed CDP reconnects | 5 / 120s | 120s | successful reconnect + token refresh |
chrome_crash_loop |
Chrome restarts | 3 / 300s | 300s | cooldown half-open only (no record_success path) |
- tripped — the persistent latch flag is set.
- open — tripped AND still within cooldown (or
cooldown_until is Nonefor auth).is_open()returns true; requests fail-fast. - half-open — tripped but past cooldown.
is_open()returns false; a successful trial (record_success) closes it, a fresh failure burst re-trips. - closed — fresh state (after
reset()or successful recovery).
[closed] ──failures≥threshold──► [open] ──cooldown elapses──► [half-open]
│ │
│ success → [closed]
│ failure → [open]
│
(auth: cooldown_until=None, indefinite)
│
reset() via recover_auth ──► [closed]
open_breakers: [] = nothing currently blocking; non-empty = listed kinds
are open. The nested breakers object gives per-kind detail:
| Field | Type | Meaning |
|---|---|---|
open |
bool | is_open() result |
reason |
string | null | why tripped; auto-trips read "<threshold> failures in <window>s window" |
tripped_at |
float | null | monotonic timestamp (only comparable within the process) |
cooldown_until |
float | null | monotonic expiry; None for closed or sticky-auth |
cooldown_seconds_remaining |
float | null | seconds left; None is overloaded = closed OR sticky-auth; 0.0 = half-open |
failures_in_window |
int | failures in the current rolling window |
Gotcha:
cooldown_seconds_remaining == nulldoes not distinguish "closed" from "sticky auth". Distinguish auth bykind == "auth_required" && open == true.
auth_required is the one breaker that needs a human. It is indefinite and
does not self-heal — only a successful token refresh closes it.
ensureexits 2./healthopen_breakerscontains"auth_required".- Chat requests return 503
circuit_openwith kindauth_required. - The breaker
reasonis"login page returned instead of data"or"HTTP 401 from backend-api".
Every chat-request preflight, on REST and MCP, calls driver.recover_auth()
before raising CircuitOpenError. recover_auth() probes /api/auth/session
for a fresh token; if it succeeds, it resets the breaker and the request
proceeds. So a transient token lapse can self-clear on the next request. If the
session itself is dead, recovery fails and the breaker stays open.
- Log in to ChatGPT in the Chrome window REST owns. On a headed deployment, open the visible Chrome and complete login at chatgpt.com. On a headless/remote deployment, use cookie export (see deployment.md Option 2) or a headed session to re-establish the session.
- Trigger a token refresh. Any chat request will invoke
recover_auth()in preflight; a successful/api/auth/sessionrefresh resets the breaker. Or re-runensure— if the session is now valid, it exits 0. - Confirm
/healthshowsauth_requiredno longer inopen_breakers.
- Restarting REST.
ensuredeliberately does not restart REST onauth_required(a restart can't fix a login problem; it just relaunches Chrome into the same logged-out state). - Waiting. The cooldown is
None(indefinite). It will not time out.
Restarting REST relaunches Chrome, which is destructive: in-flight requests
are lost, the visible Chrome window flashes closed/reopened, and any unsaved
page state is gone. Restart only when necessary, and let ensure do it.
ensure already encodes the correct degraded policy:
broken→ restart immediately.degraded(driver dead, no breaker) → poll up to ~20s, then restart.degraded(timed breaker) → waitcooldown + grace, then restart.degraded(auth_required) → do not restart (exit 2).
A supervisor that restarts faster than ensure's degraded budget will race
it and cause destructive browser flapping. See os-supervision.md
"Restart policy".
status=brokenthat doesn't self-heal after oneensurecycle.- Chrome is wedged (CDP port probe fails,
chrome_crash_looptripping). - After a code/config change that requires a fresh process.
ensureis a readiness reconciler, not a hot-reload or deploy mechanism. It reconciles REST + MCP/SSE to a healthy state and will cold-start a missing process, but it will not restart an already-healthy process merely because the source code changed. A post-merge process running stale code is healthy byensure's definition, so it is left alone.
After pulling a code/package update (e.g. pip install -U, a merged fix), the
long-lived processes must be restarted explicitly to load the new code.
Do not restart REST and MCP back-to-back — MCP exits during startup if it
cannot reach Chrome/CDP (run_mcp() logs "Cannot connect to Chrome", cleans
up, and returns without starting the SSE listener), so a blind sequence leaves
no MCP listener. Restart REST first, confirm REST/Chrome/CDP is ready,
then restart MCP.
Split-service style (the recommended always-on layout from os-supervision.md) — ordered, not back-to-back:
# 1. Restart REST (it owns Chrome, so this relaunches Chrome too).
systemctl --user restart chatgpt-web2api.service
# 2. Wait until REST/Chrome/CDP is actually ready. `ensure` reconciles and
# exits 0 only when REST is ready — or poll /health until
# chrome_running=true and driver_connected=true.
chatgpt-web2api ensure --rest-port 8080 --mcp-sse-port 8090 --cdp-port 9222
# 3. NOW restart MCP/SSE. It connects to the already-ready Chrome over CDP.
systemctl --user restart chatgpt-web2api-mcp.service
# 4. Re-run ensure (or an MCP tool call) to confirm the SSE listener came up.
chatgpt-web2api ensure --rest-port 8080 --mcp-sse-port 8090 --cdp-port 9222Why the ordering matters: MCP/SSE attaches to REST-owned Chrome over CDP and
has no Chrome of its own. Restarting MCP while REST is mid-restart (Chrome
not yet listening on the CDP port) causes MCP to exit with "Cannot connect to
Chrome" and leaves no SSE listener — a silent failure under Type=simple.
ensure-timer style — there is no long-lived supervisor to restart; kill the existing REST and MCP processes, then re-runensureto cold-start them in the correct order (REST first;ensurereconciles SSE only after REST is ready, so the ordering is built in).- General rule: restart both REST and MCP/SSE after a code change, even
if you think only one surface was touched. The safer default avoids the
"REST looks healthy but MCP runs stale code" state (which silently broke
MCP-only fixes — see the #31
list_conversationsschema fix, where the merged code did not take effect until the MCP process was restarted).
After the restart, run ensure once more to confirm readiness, then validate
per §8.
# 1. Stop gracefully if you can (sends SIGTERM; REST drains).
# On systemd: systemctl --user stop chatgpt-web2api.service
# 2. Let ensure reconcile (it will cold-start REST + MCP if they're gone).
chatgpt-web2api ensure --rest-port 8080 --mcp-sse-port 8090 --cdp-port 9222
# 3. Verify — see §8.If ensure exits 2, stop and do auth recovery (§6) — do not loop ensure.
Applies only when
parallel_tabs=true. In the default (parallel_tabs=false) single-worker mode there is exactly one process and this section does not apply.
When parallel mode is enabled, only the owner process (the one that launched
Chrome) manages the Chrome lifecycle. Other processes are attachers. This
split is structural (_owns_chrome), not lock-based — see
deployment.md → Parallel mode.
Attacher process dies → Chrome should remain running. The owner's health
monitor is the only one that restarts; an attacher's monitor no-ops on restart
by design. No action required beyond restarting the attacher process itself
(its owned tab is reclaimable on restart only if it uses a stable
W2A_INSTANCE_ID; a stdio MCP process whose identity was PID-derived starts a
fresh tab).
Owner process dies → Chrome may be orphaned: it keeps running but no live
process owns its lifecycle, so no monitor restarts it and attachers will not
self-promote. This is intentional in the current design — runtime owner
failover / an ownership lease is deferred future work, not an incident. Recover
with the normal safe restart sequence (§7): restart
the REST/owner process first, wait for Chrome/CDP readiness, then restart
MCP/SSE or attachers. A freshly started process finds the CDP port and elects
owner (or attaches) per ChromeProcess.ensure_running.
If an operator depends on restart-on-crash surviving owner death, supervise the owner process with OS-level restart (see os-supervision.md) so a new owner process starts automatically.
After a code/package update, restart in dependency order.
ensuredoes not hot-reload code (see §7), and REST/MCP must not be restarted back-to-back (MCP exits if Chrome/CDP isn't ready when it starts). The correct code-deploy order is:
- restart REST (owns Chrome)
- reconcile/wait for REST readiness (
ensure, or poll/healthuntilchrome_running=trueanddriver_connected=true)- restart MCP/SSE (now that Chrome is listening)
- run
ensureto confirm both came up- validate below
The steps below assume the processes are running the code you intend to validate. For a non-code restart (e.g. recovering from a crash), skip to step 1 and let
ensurereconcile.
Run in order; stop at the first failure.
# 1. ensure exited 0 (REST + SSE ready) — or re-run it:
chatgpt-web2api ensure --rest-port 8080 --mcp-sse-port 8090 --cdp-port 9222
# expect: exit 0, "REST + SSE ready"
# 2. Health
curl -s http://localhost:8080/health | python -m json.tool
# expect: status healthy|starting, chrome_running true, driver_connected true,
# open_breakers [], last_error null
# 3. One real chat send (end-to-end; exercises the full send + completion path)
curl -s -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","stream":false,"messages":[{"role":"user","content":"Reply with exactly: ok"}]}'
# expect: HTTP 200, content "ok", finish_reason "stop"Use
Reply with exactly: ok, not bare"ok". Plain"ok"is often interpreted as an acknowledgement cue and returns"Acknowledged."— still a valid 200, but not an exact-output sanity check. For runtime validation, the hard pass is HTTP 200 + OpenAI-compatible shape +finish_reason: stop; the exact content is a best-effort cue unless you add a stronger system instruction.
Step 3 is the only signal that the composer DOM path, completion detection, and backend fetch all work end-to-end. Steps 1–2 are preconditions.
The REST send does not exercise MCP output-schema validation. After a
change touching MCP tool schemas or the MCP server, call an affected MCP tool
and confirm it returns isError: false. For example, list_conversations
(which returns update_time as an ISO string and gizmo_id as null — the
shape that broke structured-output validation in #31 before the fix):
# python -m pip install mcp (if not already installed)
import asyncio
from mcp import ClientSession
from mcp.client.sse import sse_client
async def main():
async with sse_client("http://localhost:8090/sse") as (r, w):
async with ClientSession(r, w) as s:
await s.initialize()
res = await s.call_tool("list_conversations", {"limit": 3})
print("isError:", res.isError) # expect: False
print("count:", len((res.structuredContent or {}).get("conversations", [])))
asyncio.run(main())isError: False with conversations returned = the MCP path is healthy. Omit
this step only if your deployment does not use MCP/SSE.
chatgpt-web2api logs to stderr at the configured level. There is no
built-in log file or rotation — capture via the supervisor's stdout/stderr
redirection (see os-supervision.md per-OS sections).
Control verbosity with W2A_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR;
default INFO):
W2A_LOG_LEVEL=DEBUG # most verbose; use for incident diagnosisCircuit open for <kind>— a breaker tripped; cross-reference/healthopen_breakers.auth_required/ "login page returned instead of data" / "HTTP 401" — auth expired; see §6.- repeated Chrome restart lines — heading toward
chrome_crash_loop. phase_1_appear/phase_2_streamGenerationStuckError — completion detection stalled; usually transient, persistent cases suggest DOM drift.end_turn fetch failed— transient backend fetch error (the DOM action-button fallback covers it; not fatal on its own).
All field names, state values, thresholds, and exit codes above are verified against:
/healthschema &statusconditions —src/chatgpt_web2api/api_server.py:108-186- Breaker kinds, policies, states —
src/chatgpt_web2api/breakers.py:46-109, 133-252 - Breaker trip/reset sites —
backend_client.py:187,210-211,344;chatgpt_dom.py:173,206,277,418,427;cdp_driver.py:592,599;chrome.py:40-68 - 503
circuit_openmapping —api_server.py:377-397,467-480; MCPmcp_server.py:1519-1576 ensureexit codes & flow —ensure.py:14-19,725-790- Degraded polling policy & tunables —
ensure.py:540-689;config.py:89-96,181-187,214-219 - Detached child launch —
ensure.py:305-316; REST cmd:260-280; SSE cmd:283-302 - Chrome ownership —
ensure.py:8-9("REST owns Chrome; SSE attaches and never launches Chrome")
Thresholds are hardcoded in breakers.py:104-109; there is no env override.