Skip to content

fix(analytics): keep scrapeServerIfStale to its "never throws" contract - #598

Merged
Hydralerne merged 1 commit into
oblien:mainfrom
AbdullahM07:fix/scraper-never-throws
Aug 17, 2026
Merged

fix(analytics): keep scrapeServerIfStale to its "never throws" contract#598
Hydralerne merged 1 commit into
oblien:mainfrom
AbdullahM07:fix/scraper-never-throws

Conversation

@AbdullahM07

Copy link
Copy Markdown
Member

Fixes #597.

scrapeServerIfStale's outer try had a finally but no catch, and the two awaits that talk to the cache — cacheStore() and store.get(key) — sit above the inner try that guards scrapeServer. A cache backend error therefore rejected the returned promise, which two docstrings promise cannot happen (this function's own, and runAnalyticsScrapeSweep's).

Consequences, both in #597 in detail:

  • The three void scrapeServerIfStale(...) read handlers turn that rejection into an unhandled rejection — fatal under Node's default --unhandled-rejections=throw. A Redis blip while the analytics tab is open takes the API process down.
  • The sweep's un-guarded await in a for loop abandons every server after the failing one, and skips the collection-switch re-assertion that follows the loop.

The inner catch stays exactly as it was — it is the per-server scrape failure the contract means by "swallows per-server failures". This adds the missing outer catch for the cache layer, logging through the same debug channel.

} catch (err) {
  debug(`scrape-on-demand:cache-error server=${serverId} ${safeErrorMessage(err)}`);
} finally {
  inflightScrape.delete(serverId);
}

No behaviour change on the success or throttled paths; inflightScrape cleanup was already in the finally and is untouched.

Verified: npx tsc --noEmit in apps/api exits 0; npx vitest run src/modules/system/ test/modules/system/ → 13 files / 137 tests pass. Full apps/api suite is green apart from four pre-existing load-related 20s timeouts in unrelated files that also fail on unpatched main.

No test here — the trigger is a cache-backend rejection rather than anything reachable through the public surface, and stubbing cacheStore to reject felt like more scaffolding than the nine-line fix warrants. Say the word and I'll add it.

The outer `try` in `scrapeServerIfStale` had a `finally` but no `catch`, and the
two awaits that talk to the cache — `cacheStore()` and `store.get(key)` — sit
ABOVE the inner try that guards `scrapeServer`. A cache backend error (Redis
down, connection reset) therefore rejected the returned promise, which two
docstrings promise cannot happen: this function's own ("self-throttles and never
throws") and `runAnalyticsScrapeSweep`'s ("Never throws — scrapeServerIfStale
swallows per-server failures, so one unreachable box can't fail the job or stop
the remaining servers").

Two consequences followed from that:

  - The three fire-and-forget read handlers — geo.service.ts:221 and
    analytics.controller.ts:239/272 — call it as `void scrapeServerIfStale(id)`.
    A rejected voided promise is an unhandled rejection, which is fatal on Node's
    default `--unhandled-rejections=throw`: a Redis blip while someone opens the
    analytics tab takes the API process down.
  - The sweep awaits it in a loop with no try/catch, so the same error abandoned
    every server after the failing one — exactly the "one box can't stop the
    remaining servers" guarantee it documents.

Catches at the outer level and logs through the same `debug` channel the inner
handler uses. No behaviour change on the success or throttled paths.
@Hydralerne
Hydralerne merged commit a462766 into oblien:main Aug 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scrapeServerIfStale can reject despite its "never throws" contract — voided call sites make a cache error process-fatal

2 participants