-
Notifications
You must be signed in to change notification settings - Fork 0
Surface engine cooldown and classify network-level blocks on /monitor #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """Shared block-signal detection used by both the scraper and the API. | ||
|
|
||
| Some engines block at the network layer rather than with an HTTP status: the | ||
| server (or an upstream proxy) tears down the TCP connection, so the browser | ||
| nav fails with a ``net::ERR_CONNECTION_*`` error and there is *no* HTTP | ||
| response to read a 429/403/503 from. Yahoo does exactly this — after a burst it | ||
| serves a persistent empty HTTP 500 (Server: ATS, Connection: close) and a real | ||
| browser nav then fails ``net::ERR_CONNECTION_CLOSED`` (see | ||
| docs/BLOCK_SIGNAL_FINDINGS.md). Treating these as a block (not a transient | ||
| glitch) lets the scraper bench the engine and the monitor label it correctly. | ||
|
|
||
| Deliberately narrow: only connection-level *refusals/teardowns* count. A plain | ||
| navigation timeout (``TimeoutError``) is a transient failure we neither punish | ||
| (cooldown) nor flag (health) — it stays "other"/non-block, matching the | ||
| existing contract in scraper/cooldown.py. | ||
| """ | ||
|
|
||
| # Substrings matched case-insensitively against a scrape's error message | ||
| # (``"{ExceptionType}: {message}"``). These are Chromium/Playwright net errors | ||
| # that mean the connection was refused or torn down before a usable response — | ||
| # a server actively closing the door, i.e. a block. | ||
| NETWORK_BLOCK_PATTERNS = ( | ||
| "err_connection_closed", | ||
| "err_connection_reset", | ||
| "err_connection_refused", | ||
| "err_connection_aborted", | ||
| "err_connection_failed", | ||
| "err_socket_not_connected", | ||
| "err_empty_response", | ||
| ) | ||
|
|
||
|
|
||
| def is_network_block(error_message: str | None) -> bool: | ||
| """True if ``error_message`` looks like a connection-level block/teardown. | ||
|
|
||
| Returns False for ``None``/empty and for transient errors such as | ||
| navigation timeouts. | ||
| """ | ||
| if not error_message: | ||
| return False | ||
| haystack = error_message.lower() | ||
| return any(pattern in haystack for pattern in NETWORK_BLOCK_PATTERNS) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -176,6 +176,16 @@ def ensure_schema() -> None: | |
| "CREATE INDEX IF NOT EXISTS idx_scraper_cycles_started_at " | ||
| "ON scraper_cycles(started_at DESC)" | ||
| ) | ||
| cursor.execute( | ||
| """ | ||
| CREATE TABLE IF NOT EXISTS engine_cooldowns ( | ||
| engine VARCHAR(32) PRIMARY KEY, | ||
| failures INTEGER NOT NULL DEFAULT 0, | ||
| next_probe_at TIMESTAMP, | ||
| updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| ) | ||
| """ | ||
| ) | ||
| _schema_ready = True | ||
|
|
||
|
|
||
|
|
@@ -665,6 +675,61 @@ def get_recent_cycles(limit: int = 24) -> list[dict]: | |
| return [dict(row) for row in cursor.fetchall()] | ||
|
|
||
|
|
||
| # ── Engine cooldown snapshot (engine_cooldowns) ────────────────────────────── | ||
|
|
||
|
|
||
| # Idempotent: each row is a full overwrite keyed by engine, so a retry after a | ||
| # committed write just reapplies the same absolute state — safe. | ||
| @retry_on_transient_error() | ||
| def upsert_engine_cooldowns( | ||
| states: "list[tuple[str, int, float]]", | ||
| ) -> None: | ||
| """Persist the scraper's per-engine cooldown snapshot for the monitor. | ||
|
|
||
| ``states`` is ``(engine, failures, remaining_seconds)`` per tracked engine. | ||
| ``next_probe_at`` is stored as absolute UTC (``NOW() + remaining``) using the | ||
| *DB* clock so the API can derive a fresh countdown without trusting the | ||
| scraper's wall-clock; a non-cooling engine (failures == 0) stores NULL. | ||
| """ | ||
|
Comment on lines
+684
to
+693
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): Cooldown persistence uses The If the goal is for a = [(e, f, max(0.0, r), max(0.0, r)) for (e, f, r) in states]
cursor.executemany(
"INSERT INTO engine_cooldowns (engine, failures, next_probe_at, updated_at) "
"VALUES (%s, %s, "
" CASE WHEN %s > 0 THEN NOW() + make_interval(secs => %s) END, "
" NOW()) "
"ON CONFLICT (engine) DO UPDATE SET "
" failures = EXCLUDED.failures, "
" next_probe_at = EXCLUDED.next_probe_at, "
" updated_at = EXCLUDED.updated_at",
a,
)This ensures already-elapsed cooldowns are stored with |
||
| if not states: | ||
| return | ||
| with _Connection() as conn: | ||
| cursor = conn.cursor() | ||
| cursor.executemany( | ||
| "INSERT INTO engine_cooldowns (engine, failures, next_probe_at, updated_at) " | ||
| "VALUES (%s, %s, " | ||
| " CASE WHEN %s > 0 THEN NOW() + make_interval(secs => %s) END, " | ||
| " NOW()) " | ||
| "ON CONFLICT (engine) DO UPDATE SET " | ||
| " failures = EXCLUDED.failures, " | ||
| " next_probe_at = EXCLUDED.next_probe_at, " | ||
| " updated_at = EXCLUDED.updated_at", | ||
| [(e, f, f, max(0.0, r)) for (e, f, r) in states], | ||
| ) | ||
|
|
||
|
|
||
| @retry_on_transient_error() | ||
| def get_engine_cooldowns() -> dict[str, dict]: | ||
| """Per-engine cooldown state keyed by engine name (for the monitor). | ||
|
|
||
| Returns ``{engine: {failures, next_probe_at, updated_at}}``. The endpoint | ||
| decides what is still "live" (freshness + remaining); this is a plain read. | ||
| """ | ||
| with _Connection() as conn: | ||
| cursor = conn.cursor() | ||
| cursor.execute( | ||
| "SELECT engine, failures, next_probe_at, updated_at FROM engine_cooldowns" | ||
| ) | ||
| return { | ||
| row["engine"]: { | ||
| "failures": row["failures"], | ||
| "next_probe_at": row["next_probe_at"], | ||
| "updated_at": row["updated_at"], | ||
| } | ||
| for row in cursor.fetchall() | ||
| } | ||
|
|
||
|
|
||
| # ── Scrape metrics aggregation (scraper_logs) ──────────────────────────────── | ||
|
|
||
| # Shared aggregate columns for the overall + per-engine rollups. duration_ms is | ||
|
|
@@ -687,7 +752,8 @@ def get_recent_cycles(limit: int = 24) -> list[dict]: | |
| percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_latency_ms, | ||
| MAX(scraped_at) AS last_scrape_at, | ||
| (ARRAY_AGG(success ORDER BY scraped_at DESC))[1] AS last_success, | ||
| (ARRAY_AGG(http_status_code ORDER BY scraped_at DESC))[1] AS last_http_status | ||
| (ARRAY_AGG(http_status_code ORDER BY scraped_at DESC))[1] AS last_http_status, | ||
| (ARRAY_AGG(error_message ORDER BY scraped_at DESC))[1] AS last_error_message | ||
| """ | ||
|
|
||
| _METRICS_WHERE = "scraped_at >= NOW() - make_interval(secs => %s)" | ||
|
|
@@ -717,6 +783,7 @@ def _ms(value) -> int | None: | |
| "last_scrape_at": row["last_scrape_at"], | ||
| "last_success": row["last_success"], | ||
| "last_http_status": row["last_http_status"], | ||
| "last_error_message": row["last_error_message"], | ||
| "http_status_breakdown": breakdown, | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (bug_risk): Cooldown tooltip (
title) is never cleared when an engine stops cooling, leaving stale hover text.In
updateEngineRow, theelsebranch only clearscd.textContent, so when an engine stops cooling the previouscd.titleremains and shows stale tooltip text. Clear the title as well: