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:
co-authored by
Claude Opus 5
parent
a8853c56d1
commit
3ff5728af9
+50
-8
@@ -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
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user