Skip to content

fix: circuit-break nonRetryable price source failures instead of retrying forever - #102

Merged
prodbycorne merged 10 commits into
SmartDropLabs:mainfrom
Temi-suwa18:fix/coinmarketcap-nonretryable-circuit-breaker
Jul 17, 2026
Merged

fix: circuit-break nonRetryable price source failures instead of retrying forever#102
prodbycorne merged 10 commits into
SmartDropLabs:mainfrom
Temi-suwa18:fix/coinmarketcap-nonretryable-circuit-breaker

Conversation

@Temi-suwa18

Copy link
Copy Markdown
Contributor

Summary

Fixes #95.

coinmarketcap.js already tagged 401 (auth) failures with err.nonRetryable = true, but nothing downstream ever read it — priceOracle.js's fetchFromAllSources catches every source error identically. A revoked/misconfigured COINMARKETCAP_API_KEY was retried on every price fetch and every scheduled refresh cycle (every 30s by default), forever, logged at the same warn level as an ordinary transient blip.

What changed

  • src/services/sources/circuitBreaker.js (new): a small reusable per-source circuit breaker. open() trips the circuit and logs once at error level (not on every subsequent skip); noteSkipped() logs at most one reminder per configurable interval while open; close() resets on a successful fetch; getState() exposes { source, open, openUntil }.
  • coinmarketcap.js: consults the circuit before attempting a fetch (skips the HTTP call entirely while open), opens it on 401, closes it on any successful round-trip. priceOracle.js needed no changes — the circuit check is entirely internal to the source module, so the generic orchestrator doesn't need to know per-source retry policy.
  • coingecko.js: extended the same treatment to its 401 case. I checked CoinGecko's actual API docs first (they weren't previously handling any auth-failure status) — confirmed 401 = missing/invalid key, 403 = a separate CDN/firewall-block category, 429 = rate limit. Only 401 opens the circuit; 403/429 are explicitly untouched.
  • config.js: new PRICE_SOURCE_CIRCUIT_COOLDOWN_MS (default 15 min) and PRICE_SOURCE_CIRCUIT_REMINDER_MS (default 5 min).
  • priceOracle.js: new getSourceCircuitStates(), aggregating circuit state from every source that has one (stellar_dex doesn't — no auth-failure mode).
  • GET /health (+ openapi.yaml): now includes price_source_circuits, the lowest-effort of the observability options the issue names, since the companion health-depth (Health check endpoint reports only Redis status — misses price-source, webhook-worker, and DB connectivity #92) and metrics (Missing production metrics for webhook delivery outcomes, retry-queue depth, and price-source failure rates #93) issues haven't landed yet.

Explicitly out of scope (per the issue's own edge-case notes)

Test plan

  • npm test — 210/210 passing across 26 suites
  • New: test/circuitBreaker.test.js (circuit lifecycle in isolation)
  • Updated: test/coinmarketcap.test.js — covers every acceptance criterion: 401 opens the circuit and the next fetch skips the HTTP call; retried after cooldown; successful retry closes the circuit; fresh 401 after cooldown re-opens with a new window; first failure logs at error, repeated skips don't re-log; 429s are unaffected (explicit regression check)
  • New: test/coingecko.test.js — this source had zero prior test coverage; covers existing behavior plus the same circuit-breaker cases, plus an explicit 403-is-unaffected test
  • New: test/health.test.js, test/priceOracleCircuit.test.jsprice_source_circuits shape via the real Express app and via getSourceCircuitStates() directly

err.nonRetryable was set on CoinMarketCap 401s but never consulted
anywhere — a revoked/misconfigured API key was retried on every price
fetch and every scheduled refresh cycle forever, logged identically
to a one-off transient blip (SmartDropLabs#95).

Adds a small circuit-breaker factory shared by any price source with
a permanent-failure mode: open() trips the circuit and logs once at
error level (not on every subsequent skip), noteSkipped() logs at
most one reminder per configurable interval while open, close()
resets on a successful fetch, and getState() exposes open/openUntil
for observability. State is intentionally process-local — see the
module's doc comment for why that's an acceptable tradeoff here.
Config for the circuit breaker's cooldown window (default 15 minutes)
and its "still open" reminder-log cadence (default 5 minutes), under
a new priceSources namespace.
fetchPrice already set err.nonRetryable = true on a 401 and threw,
but nothing downstream ever read it (priceOracle.js's
fetchFromAllSources catches every error identically). Rather than
pushing nonRetryable-awareness up into the generic orchestrator
(which shouldn't need to know per-source retry policy), the circuit
check is entirely internal to this module:

  - Before attempting a fetch, skip it if the circuit is open
    (returns null immediately, no HTTP call, no throw).
  - On a 401, open the circuit for circuitCooldownMs and still throw
    (unchanged behavior for any caller relying on the existing throw).
  - On any successful HTTP round-trip, close the circuit — a 401
    means the key was bad *at that moment*, and an operator fixing it
    should not require a process restart to clear the circuit.
  - 429s and other transient errors are untouched — they already
    self-heal on the next cycle and this issue explicitly scopes the
    circuit breaker to the nonRetryable (401) case only.

priceOracle.js needs no changes at all under this design.
Per SmartDropLabs#95's own suggestion, checked CoinGecko's actual API behavior:
their docs confirm 401 means a missing/invalid API key (403 is a
separate CDN/firewall-block category, 429 is rate-limiting — neither
indicates a bad key). coingecko.js previously had no 401 handling at
all; it now mirrors coinmarketcap.js's treatment exactly — same
circuit breaker pattern, same throw-with-nonRetryable-flag on 401,
429/other errors unaffected.
Aggregates circuit-breaker state from every source that has one
(currently coingecko and coinmarketcap — stellar_dex has no API-key
auth-failure mode) so operators can see at a glance which price
sources are currently circuit-broken, per SmartDropLabs#95's observability
requirement.
Adds price_source_circuits to the /health response body (and its
OpenAPI schema) using priceOracle.getSourceCircuitStates() — the
lowest-effort of the observability options SmartDropLabs#95 names (health
response / metrics / logs) given SmartDropLabs#92 (health depth) and SmartDropLabs#93 (metrics)
haven't landed yet.
Circuit lifecycle: starts closed, open() trips and logs once at
error level, repeated open() calls while open don't re-log, stays
open until cooldownMs elapses, close() resets immediately,
noteSkipped() logs at most once per reminderIntervalMs, and a fresh
failure after cooldown re-opens with a new window and a new log line.
…ptance criteria)

Covers every acceptance criterion from SmartDropLabs#95: a 401 opens the circuit
and the very next fetch skips the HTTP call entirely; the source is
retried after the cooldown window; a successful retry closes the
circuit; a fresh 401 after cooldown re-opens it with a new window;
the first 401 logs distinctly at error level and repeated skips while
open don't re-log; and — the explicit regression check SmartDropLabs#95 asks for —
429s are completely unaffected by the circuit breaker.
…circuit breaker

coingecko.js had zero test coverage before this change. Covers the
existing fetch/asset-mapping/429 behavior plus the new 401 handling:
throws nonRetryable on 401, circuit opens and skips the next fetch,
retries after cooldown and closes on success, and 403/429 are both
explicitly unaffected by the circuit (403 is CoinGecko's separate
CDN/firewall-block category, not an auth failure).
priceOracleCircuit.test.js unit-tests getSourceCircuitStates() in
isolation (returns a state per source that has one, omits stellar_dex
which doesn't). health.test.js exercises the same thing end-to-end
through the real Express app, asserting the new price_source_circuits
field shape on GET /health.
@prodbycorne
prodbycorne merged commit ee602f3 into SmartDropLabs:main Jul 17, 2026
2 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.

CoinMarketCap's nonRetryable auth-failure flag is set but never consulted — invalid keys are retried every 30s forever

2 participants