Survive transient dashboard errors instead of failing the run

Next's dev server intermittently answers a 500 while recompiling a route: it
reads a build manifest mid-write and cannot parse it. A single one of those
during the locate poll was fatal, so a blip in the pipeline killed a run partway
through an auth flow on Windows.

5xx responses and dropped connections are now a distinct TransientError, retried
until the step's own timeout. A 4xx still fails immediately — those are verdicts
about the request, not blips. If the errors persist all the way to the timeout,
the message says so rather than blaming a missing extension.

Error bodies are also summarised. A dev-server 500 replies with a full HTML page,
and printing it raw buried the one line that said what went wrong under kilobytes
of script tags.

This makes the client tolerant of the fault, which is not the same as fixing it:
the real answer on an automation host is to run a production build rather than
`next dev`, so those manifests are written once instead of continuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-30 14:00:23 -05:00
co-authored by Claude Opus 5
parent a8853c56d1
commit 3ff5728af9
3 changed files with 52 additions and 10 deletions
+50 -8
View File
@@ -21,6 +21,7 @@ Requires the AutoBuyer page switch to be ON — that's the master arming switch.
import argparse
import json
import re
import random
import sys
import time
@@ -38,6 +39,16 @@ class DashboardError(RuntimeError):
pass
class TransientError(DashboardError):
"""A failure that is worth retrying: a 5xx, or the server briefly unreachable.
Next's dev server intermittently serves a 500 while it recompiles a route —
it reads a build manifest mid-write and fails to parse it. That is a blip in
the pipeline, not a verdict about the page, and it should not kill a run that
is halfway through spending money.
"""
class NotFoundError(DashboardError):
"""The extension reached the page and the element simply isn't there.
@@ -53,6 +64,26 @@ class NotFoundError(DashboardError):
_ABSENT_MARKERS = ("No element matches", "no index")
def _summarise_error_body(body: str, limit: int = 200) -> str:
"""Keep an error readable.
A dev-server 500 answers with a full HTML page — several kilobytes of script
tags and a stack trace — and printing it raw buries the one line that says
what went wrong.
"""
try:
return str(json.loads(body).get("error", body))[:limit]
except json.JSONDecodeError:
pass
if "<!DOCTYPE" in body or "<html" in body:
match = re.search(r'"message":"(.*?)"', body)
detail = match.group(1) if match else "no detail in the page"
return f"server returned an HTML error page ({detail[:limit]})"
return body[:limit]
def _is_absent(message: str) -> bool:
return any(marker in message for marker in _ABSENT_MARKERS)
@@ -72,14 +103,11 @@ class Dashboard:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")
try:
message = json.loads(body).get("error", body)
except json.JSONDecodeError:
message = body
raise DashboardError(f"{method} {path} -> HTTP {exc.code}: {message}") from None
message = _summarise_error_body(exc.read().decode(errors="replace"))
cls = TransientError if exc.code >= 500 else DashboardError
raise cls(f"{method} {path} -> HTTP {exc.code}: {message}") from None
except urllib.error.URLError as exc:
raise DashboardError(f"Cannot reach {self.base}{exc.reason}") from None
raise TransientError(f"Cannot reach {self.base}{exc.reason}") from None
def status(self) -> dict:
return self._request("/api/autobuyer/status")
@@ -100,8 +128,17 @@ class Dashboard:
request_id = queued["id"]
deadline = time.monotonic() + timeout
last_transient = None
while time.monotonic() < deadline:
row = self._request(f"/api/autobuyer/locate?id={request_id}")
try:
row = self._request(f"/api/autobuyer/locate?id={request_id}")
except TransientError as exc:
# The extension may well answer while the server is having a
# moment; keep polling rather than failing the run over a blip.
last_transient = exc
time.sleep(POLL_INTERVAL)
continue
if row["status"] == "done":
return row["result"]
if row["status"] == "error":
@@ -110,6 +147,11 @@ class Dashboard:
raise cls(f"Extension could not locate it: {detail}")
time.sleep(POLL_INTERVAL)
if last_transient is not None:
raise DashboardError(
f"No answer within {timeout:g}s, and the dashboard kept erroring "
f"({last_transient})"
)
raise DashboardError(
f"No answer within {timeout:g}s. Is the extension installed, is Chrome "
f"running, and is the AutoBuyer switch ON?"
+1 -1
View File
@@ -33,7 +33,7 @@ HEARTBEAT_SECONDS = 2.0
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
# the heartbeat so the dashboard can say "restart your runner" instead of letting
# a stale process fail on a step type it has never heard of.
VERSION = "0.12.0"
VERSION = "0.13.0"
# Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event()
+1 -1
View File
@@ -604,7 +604,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
/** The runner version this server's step vocabulary requires. A running process
* doesn't reload when the source changes, so an older one silently fails on
* steps it predates — the dashboard warns instead. */
export const RUNNER_EXPECTED_VERSION = '0.12.0';
export const RUNNER_EXPECTED_VERSION = '0.13.0';
export interface RunnerHeartbeat {
at: number;