diff --git a/clicker/clicker.py b/clicker/clicker.py index f5f38bb..1d0fe60 100644 --- a/clicker/clicker.py +++ b/clicker/clicker.py @@ -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 " 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?" diff --git a/clicker/runner.py b/clicker/runner.py index ed37801..b2e35c8 100644 --- a/clicker/runner.py +++ b/clicker/runner.py @@ -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() diff --git a/lib/db.ts b/lib/db.ts index 297041f..2065fca 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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;