You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
src/services/sources/coinmarketcap.js deliberately tags authentication failures with a custom flag, clearly intending some caller upstream to treat them differently from ordinary transient errors:
asyncfunctionfetchPrice(assetCode,issuer=null){
...
try{
...
}catch(err){if(err.response?.status===401){err.nonRetryable=true;// <- set, but never read anywhere in the codebaselogger.warn('CoinMarketCap authentication failed',{ assetCode });throwerr;}if(err.response?.status===429){ ... }else{ ... }returnnull;}}
But src/services/priceOracle.js's fetchFromAllSources() — the only caller of source.fetch — catches every error identically, with no special-casing for err.nonRetryable:
for(constsourceofSOURCES){try{constprice=awaitsource.fetch(assetCode,issuer);if(price!==null&&price>0){results.push({source: source.name, price });}}catch(err){logger.warn('Source fetch failed',{source: source.name, assetCode,error: err.message});// treats 401 identically to a timeout}}
err.nonRetryable is dead — it is set and thrown, but nothing downstream ever reads it. Practically, this means a misconfigured or revoked COINMARKETCAP_API_KEY (a persistent, non-transient misconfiguration that will never resolve itself without operator intervention) is retried every single price fetch, and, separately, every 30-second scheduled refresh cycle (PRICE_REFRESH_INTERVAL_SECONDS default), forever, indistinguishable in the logs from a one-off transient network blip — both log identically at warn level via the generic 'Source fetch failed' message. An operator has no way to distinguish "CoinMarketCap had a blip a minute ago" from "our CoinMarketCap API key has been dead for three weeks" without manually correlating hundreds of near-identical log lines, and the system keeps wastefully calling out to an endpoint that is certain to fail, on every single cycle, with no backoff specific to this class of permanent failure.
Requirements
In priceOracle.js's fetchFromAllSources() (or a wrapper introduced for this purpose), check err.nonRetryable and, when true, stop attempting that source for a cooldown window (a "circuit breaker" — open the circuit on a non-retryable failure, and only attempt the source again after a configurable cooldown, e.g. PRICE_SOURCE_CIRCUIT_COOLDOWN_MS) rather than retrying it unconditionally on the very next cycle.
Log non-retryable failures distinctly (e.g. at error level with a distinguishing message like 'Price source permanently misconfigured') the first time the circuit opens, rather than repeating the same warn every cycle — subsequent skips while the circuit is open should log at most a periodic reminder, not one line per skipped attempt.
Extend the same nonRetryable/circuit-breaker treatment to coingecko.js if an equivalent permanent-failure class exists for it (e.g. an invalid COINGECKO_API_KEY producing a 401/403 — check CoinGecko's actual API behavior and add the same flag if applicable, for consistency across sources).
Expose the circuit state (open/closed, and since-when) somewhere observable — either in logs, in the /health response, or via the metrics work from the companion observability issue — so operators can see at a glance which price sources are currently circuit-broken.
Acceptance Criteria
A simulated CoinMarketCap 401 response causes that source to be skipped on subsequent fetch cycles for the configured cooldown window, rather than being retried every cycle.
The first occurrence of a non-retryable failure logs distinctly from ordinary transient failures.
After the cooldown window elapses, the source is attempted again (and the circuit re-opens if it fails again, or closes/resets if it succeeds).
fetchFromAllSources's behavior for ordinary (retryable) failures is unchanged — this only affects sources flagged nonRetryable.
New unit tests cover: circuit opens on 401, source is skipped while open, source is retried after cooldown, and successful retry closes the circuit.
Additional Notes
More precise references
src/services/sources/coinmarketcap.js:74-89 (fetchPrice's catch block): confirmed exact structure — 401 sets err.nonRetryable = true, logs logger.warn('CoinMarketCap authentication failed', { assetCode }), and re-throws (throw err); 429 and all other errors are logged and the function returns null (does not throw).
src/services/priceOracle.js:77-92 (fetchFromAllSources): confirmed the for...of loop over SOURCES wraps each source.fetch(...) call in a generic try/catch that only reads err.message for the log line (logger.warn('Source fetch failed', { source: source.name, assetCode, error: err.message })) — err.nonRetryable is never referenced anywhere in this file, confirmed via full read.
src/jobs/priceRefresh.js:11-12: confirmed PRICE_REFRESH_INTERVAL_SECONDS drives cronExpression = \*/${intervalSeconds} * * * * *`— a **seconds-level** cron expression (six-field, not the usual five-field minute-level cron), meaning the "every 30 seconds" framing in the issue is precise if the configured default is 30, not an approximation — worth double-checkingconfig.price.refreshInterval's actual default value during implementation (not independently re-verified this pass since config.js` wasn't re-read), but the mechanism itself (seconds-granularity cron) is confirmed.
Note the asymmetry between the two failure branches in coinmarketcap.js: the 429 branch does NOT throw (falls through to return null at the end of the function after the if/else if/else chain), while the 401 branch DOES throw — meaning fetchFromAllSources's try/catch only ever actually catches the 401 case from this source (since a 429/other error already returns null without throwation, handled by the if (price !== null && price > 0) check simply not adding it to results). This is an important nuance: the dead err.nonRetryable flag is only ever attached to the one error path that actually propagates as a thrown exception to fetchFromAllSources's catch block — the fix's if (err.nonRetryable) check in that catch block is exactly the right place, but it's worth being explicit in the PR that this is the only path where err.nonRetryable could ever be true today, and that a future contributor extending nonRetryable to coingecko.js (per the issue's own suggestion) must also choose to throw (not silently return null) for it to ever reach this same catch block.
The circuit should probably distinguish "known-bad config, don't retry until an operator changes something" (a 401, which won't self-heal by waiting) from "rate-limited, will self-heal shortly" (a 429, which already has an implicit cooldown via next-cycle retry) — the issue's own requirements focus on the 401/nonRetryable case specifically and treat 429 as already reasonably handled by the existing per-cycle retry cadence; worth confirming in the PR that circuit-breaker logic is NOT accidentally applied to 429s too (which would change existing, working behavior unnecessarily and isn't asked for).
If COINMARKETCAP_API_KEY is rotated/fixed by an operator while the circuit is open, the fix needs the cooldown to actually expire and re-attempt on schedule (not require a process restart to clear the circuit) — this is already implied by the "cooldown window" requirement, but worth being explicit that manual circuit-reset (e.g. via an admin endpoint or just waiting out the cooldown) should be tested, not just circuit-opening.
resolveMarket() (lines 21-45) can return null for reasons unrelated to authentication (unsupported asset/issuer) — confirmed this path returns early (line 54-56) before ever reaching the try/catch, so it's unaffected by and doesn't interact with the circuit-breaker logic; worth noting explicitly so a reviewer doesn't conflate "source returned null because asset unsupported" with "source is circuit-broken."
Implementation sketch
// coinmarketcap.js — new module-level stateletcircuitOpenUntil=0;letcircuitOpenLoggedAt=0;asyncfunctionfetchPrice(assetCode,issuer=null){if(Date.now()<circuitOpenUntil){// periodic reminder log, not one line per skipped attemptif(Date.now()-circuitOpenLoggedAt>config.priceSources.circuitReminderIntervalMs){logger.warn('CoinMarketCap circuit open, skipping fetch',{until: newDate(circuitOpenUntil).toISOString()});circuitOpenLoggedAt=Date.now();}returnnull;}
...
}catch(err){if(err.response?.status===401){constwasOpen=Date.now()<circuitOpenUntil;circuitOpenUntil=Date.now()+config.priceSources.circuitCooldownMs;if(!wasOpen){logger.error('Price source permanently misconfigured',{source: 'coinmarketcap', assetCode,cooldownMs: config.priceSources.circuitCooldownMs});}err.nonRetryable=true;throwerr;}
...
}}functiongetCircuitState(){return{open: Date.now()<circuitOpenUntil,until: circuitOpenUntil||null};}module.exports={ fetchPrice, getCircuitState };
Add PRICE_SOURCE_CIRCUIT_COOLDOWN_MS to config.js (e.g. default 15 minutes). On a successful fetch after the circuit was previously open, reset circuitOpenUntil = 0 (closes/resets the circuit, per acceptance criteria). Expose getCircuitState() for /health (#92) or /metrics (#93) to surface. priceOracle.js's fetchFromAllSources needs no changes at all under this design — the circuit check is entirely internal to the source module, which keeps the fix localized and avoids the generic catch block in priceOracle.js needing to know about per-source circuit semantics (a simpler design than pushing nonRetryable-awareness up into the orchestrator, and arguably preferable since the orchestrator's job is just to try each source and aggregate, not to manage retry policy per source).
Test/reproduction plan
Mock axios to return a 401 on getClient().get(...); call fetchPrice twice in a row; assert the second call does NOT make an HTTP request at all (circuit open) and returns null immediately.
Advance a mocked clock past circuitCooldownMs; call fetchPrice again; assert an HTTP request IS made this time (circuit re-attempted).
Mock the retried request to succeed; assert getCircuitState().open is now false (circuit closed/reset).
Mock the retried request to fail with another 401; assert the circuit re-opens with a fresh cooldown window.
Assert the first 401 logs at error level with the distinguishing message, and a second consecutive skipped attempt within the same open window does NOT produce a duplicate identical warn/error log line (only the periodic-reminder cadence, tested by asserting log call count).
Regression: a 429 or generic network error still behaves exactly as before (returns null, no circuit interaction) — explicit test asserting circuit state is untouched by these error types.
Overview
src/services/sources/coinmarketcap.jsdeliberately tags authentication failures with a custom flag, clearly intending some caller upstream to treat them differently from ordinary transient errors:But
src/services/priceOracle.js'sfetchFromAllSources()— the only caller ofsource.fetch— catches every error identically, with no special-casing forerr.nonRetryable:err.nonRetryableis dead — it is set and thrown, but nothing downstream ever reads it. Practically, this means a misconfigured or revokedCOINMARKETCAP_API_KEY(a persistent, non-transient misconfiguration that will never resolve itself without operator intervention) is retried every single price fetch, and, separately, every 30-second scheduled refresh cycle (PRICE_REFRESH_INTERVAL_SECONDSdefault), forever, indistinguishable in the logs from a one-off transient network blip — both log identically atwarnlevel via the generic'Source fetch failed'message. An operator has no way to distinguish "CoinMarketCap had a blip a minute ago" from "our CoinMarketCap API key has been dead for three weeks" without manually correlating hundreds of near-identical log lines, and the system keeps wastefully calling out to an endpoint that is certain to fail, on every single cycle, with no backoff specific to this class of permanent failure.Requirements
priceOracle.js'sfetchFromAllSources()(or a wrapper introduced for this purpose), checkerr.nonRetryableand, when true, stop attempting that source for a cooldown window (a "circuit breaker" — open the circuit on a non-retryable failure, and only attempt the source again after a configurable cooldown, e.g.PRICE_SOURCE_CIRCUIT_COOLDOWN_MS) rather than retrying it unconditionally on the very next cycle.errorlevel with a distinguishing message like'Price source permanently misconfigured') the first time the circuit opens, rather than repeating the samewarnevery cycle — subsequent skips while the circuit is open should log at most a periodic reminder, not one line per skipped attempt.nonRetryable/circuit-breaker treatment tocoingecko.jsif an equivalent permanent-failure class exists for it (e.g. an invalidCOINGECKO_API_KEYproducing a401/403— check CoinGecko's actual API behavior and add the same flag if applicable, for consistency across sources)./healthresponse, or via the metrics work from the companion observability issue — so operators can see at a glance which price sources are currently circuit-broken.Acceptance Criteria
401response causes that source to be skipped on subsequent fetch cycles for the configured cooldown window, rather than being retried every cycle.fetchFromAllSources's behavior for ordinary (retryable) failures is unchanged — this only affects sources flaggednonRetryable.Additional Notes
More precise references
src/services/sources/coinmarketcap.js:74-89(fetchPrice's catch block): confirmed exact structure —401setserr.nonRetryable = true, logslogger.warn('CoinMarketCap authentication failed', { assetCode }), and re-throws (throw err);429and all other errors are logged and the function returnsnull(does not throw).src/services/priceOracle.js:77-92(fetchFromAllSources): confirmed thefor...ofloop overSOURCESwraps eachsource.fetch(...)call in a generictry/catchthat only readserr.messagefor the log line (logger.warn('Source fetch failed', { source: source.name, assetCode, error: err.message })) —err.nonRetryableis never referenced anywhere in this file, confirmed via full read.src/jobs/priceRefresh.js:11-12: confirmedPRICE_REFRESH_INTERVAL_SECONDSdrivescronExpression = \*/${intervalSeconds} * * * * *`— a **seconds-level** cron expression (six-field, not the usual five-field minute-level cron), meaning the "every 30 seconds" framing in the issue is precise if the configured default is 30, not an approximation — worth double-checkingconfig.price.refreshInterval's actual default value during implementation (not independently re-verified this pass sinceconfig.js` wasn't re-read), but the mechanism itself (seconds-granularity cron) is confirmed.coinmarketcap.js: the429branch does NOT throw (falls through toreturn nullat the end of the function after theif/else if/elsechain), while the401branch DOES throw — meaningfetchFromAllSources'stry/catchonly ever actually catches the401case from this source (since a429/other error already returnsnullwithout throwation, handled by theif (price !== null && price > 0)check simply not adding it toresults). This is an important nuance: the deaderr.nonRetryableflag is only ever attached to the one error path that actually propagates as a thrown exception tofetchFromAllSources's catch block — the fix'sif (err.nonRetryable)check in that catch block is exactly the right place, but it's worth being explicit in the PR that this is the only path whereerr.nonRetryablecould ever be true today, and that a future contributor extendingnonRetryabletocoingecko.js(per the issue's own suggestion) must also choose to throw (not silently return null) for it to ever reach this same catch block.Additional edge cases
coinmarketcap.jscurrently has no module-level mutable state at all beyond the lazily-constructedapiClient(line 5,let apiClient = null), so introducing circuit state (open/closed, opened-at timestamp) as a new module-level variable is consistent with the existing pattern, but it means circuit state is per-process, not shared across horizontally-scaled replicas — directly interacting with Background jobs (price refresh, webhook retry worker) have no leader election — every horizontally-scaled replica runs them independently #98 (leader election): without Background jobs (price refresh, webhook retry worker) have no leader election — every horizontally-scaled replica runs them independently #98, each replica maintains its own independent circuit state and could each independently retry-then-fail on their own schedule, which is a smaller version of the same "no coordination across replicas" theme as Background jobs (price refresh, webhook retry worker) have no leader election — every horizontally-scaled replica runs them independently #98, though arguably lower-severity here since a per-replica circuit breaker still meaningfully reduces total call volume even if not perfectly synchronized.401, which won't self-heal by waiting) from "rate-limited, will self-heal shortly" (a429, which already has an implicit cooldown via next-cycle retry) — the issue's own requirements focus on the401/nonRetryablecase specifically and treat429as already reasonably handled by the existing per-cycle retry cadence; worth confirming in the PR that circuit-breaker logic is NOT accidentally applied to429s too (which would change existing, working behavior unnecessarily and isn't asked for).COINMARKETCAP_API_KEYis rotated/fixed by an operator while the circuit is open, the fix needs the cooldown to actually expire and re-attempt on schedule (not require a process restart to clear the circuit) — this is already implied by the "cooldown window" requirement, but worth being explicit that manual circuit-reset (e.g. via an admin endpoint or just waiting out the cooldown) should be tested, not just circuit-opening.resolveMarket()(lines 21-45) can returnnullfor reasons unrelated to authentication (unsupported asset/issuer) — confirmed this path returns early (line 54-56) before ever reaching the try/catch, so it's unaffected by and doesn't interact with the circuit-breaker logic; worth noting explicitly so a reviewer doesn't conflate "source returned null because asset unsupported" with "source is circuit-broken."Implementation sketch
Add
PRICE_SOURCE_CIRCUIT_COOLDOWN_MStoconfig.js(e.g. default 15 minutes). On a successful fetch after the circuit was previously open, resetcircuitOpenUntil = 0(closes/resets the circuit, per acceptance criteria). ExposegetCircuitState()for/health(#92) or/metrics(#93) to surface.priceOracle.js'sfetchFromAllSourcesneeds no changes at all under this design — the circuit check is entirely internal to the source module, which keeps the fix localized and avoids the generic catch block inpriceOracle.jsneeding to know about per-source circuit semantics (a simpler design than pushingnonRetryable-awareness up into the orchestrator, and arguably preferable since the orchestrator's job is just to try each source and aggregate, not to manage retry policy per source).Test/reproduction plan
axiosto return a401ongetClient().get(...); callfetchPricetwice in a row; assert the second call does NOT make an HTTP request at all (circuit open) and returnsnullimmediately.circuitCooldownMs; callfetchPriceagain; assert an HTTP request IS made this time (circuit re-attempted).getCircuitState().openis nowfalse(circuit closed/reset).401; assert the circuit re-opens with a fresh cooldown window.401logs aterrorlevel with the distinguishing message, and a second consecutive skipped attempt within the same open window does NOT produce a duplicate identical warn/error log line (only the periodic-reminder cadence, tested by asserting log call count).429or generic network error still behaves exactly as before (returnsnull, no circuit interaction) — explicit test asserting circuit state is untouched by these error types.Cross-references
getCircuitState()above is designed to feed either or both) — coordinate the exact shape of the exposed data (open/since-when) so both consumers can use the same function without duplicating logic.