Skip to content

fix(priceOracle): circuit breaker false-trips on unsupported-asset lookups - #141

Merged
prodbycorne merged 7 commits into
SmartDropLabs:mainfrom
davidishere1:fix/circuit-breaker-unsupported-asset-false-trip
Aug 21, 2026
Merged

fix(priceOracle): circuit breaker false-trips on unsupported-asset lookups#141
prodbycorne merged 7 commits into
SmartDropLabs:mainfrom
davidishere1:fix/circuit-breaker-unsupported-asset-false-trip

Conversation

@davidishere1

Copy link
Copy Markdown
Contributor

Summary

CircuitBreaker.call() treats any null/undefined return as a failure. Every price source's fetchPrice() legitimately returns null for the common, permanent, non-error case of "this source doesn't support this asset" — not just for actual failures. Since each source's CircuitBreaker is shared across every asset (one breaker per source, not per source-per-asset), a source being asked about even one unsupported asset (e.g. CoinGecko queried for USDC — STELLAR_COINGECKO_MAP only maps XLM, and USDC is a real, first-class configured asset per config.coinmarketcap.assetIssuerMap) will eventually trip that source's breaker OPEN, taking it offline for every other asset it does support.

Fixes it the way the issue calls "the cleanest fix": each source now exposes isSupported(assetCode, issuer), and priceOracle.js's fetchFromAllSources checks it before ever invoking the circuit-breaker-wrapped fetchPrice — an unsupported lookup never reaches the breaker at all. A genuine null for an asset a source is supposed to support still goes through breaker.call() and still counts toward the failure threshold, unchanged.

Changes (7 commits)

  1. feat(coingecko): add isSupported(assetCode), extracted from fetchPrice's existing unsupported-asset check
  2. feat(coinmarketcap): add isSupported(assetCode, issuer), reusing the existing resolveMarket helper so it can't drift from fetchPrice's own notion of "supported"
  3. feat(stellarDex): add isSupported(assetCode, issuer), matching fetchPrice's existing issuer-required check
  4. fix(priceOracle): the actual fix — fetchFromAllSources skips a source's breaker entirely when isSupported returns false
  5. test(priceOracle): regression tests for the false-trip (verified they fail against the pre-fix code) plus a test confirming genuine failures still trip the breaker
  6. docs: clarify in the README that unsupported-asset lookups don't count toward CIRCUIT_BREAKER_FAILURE_THRESHOLD
  7. docs(circuitBreaker): document the null-means-failure contract on call() so a future caller wrapping a new shared resource doesn't repeat this bug

Acceptance criteria

  • Repeatedly requesting a price for an asset a source doesn't support does not trip that source's breaker OPEN
  • A source's breaker still trips OPEN after failureThreshold genuine failures for an asset it supports
  • Test asserts fetching a CoinGecko-unsupported asset many times in a row doesn't affect CoinGecko's breaker state, and XLM (CoinGecko-supported) keeps succeeding via it throughout
  • The same fix is applied consistently across coingecko.js, coinmarketcap.js, and stellarDex.js

Test plan

  • npx jest — 382/382 passing across 39 suites, run against local Redis matching CI's service container
  • npx @redocly/cli lint openapi.yaml — passes (untouched by this change)
  • Manually verified the two core regression tests in priceOracle.test.js fail against the pre-fix fetchFromAllSources (CoinGecko's mocked fetch gets called 1 and 2 times respectively when it should never be called) and pass against the fix

Closes #130

…e's unsupported-asset check

Lets callers check whether CoinGecko can serve a given asset before ever
invoking the circuit-breaker-wrapped fetchPrice — see SmartDropLabs#130. STELLAR_COINGECKO_MAP
only maps XLM, so isSupported('USDC') is false while isSupported('XLM') is true,
matching fetchPrice's existing '!coinId' branch exactly.
…lveMarket

Lets callers check whether CoinMarketCap can serve a given asset/issuer
before ever invoking the circuit-breaker-wrapped fetchPrice — see SmartDropLabs#130.
Reuses the existing resolveMarket helper so isSupported can never drift
from fetchPrice's own notion of 'supported' (an XLM-style no-issuer asset,
a configured asset:issuer pair like USDC, or nothing in assetIssuerMap
at all).
Lets callers check whether the Stellar DEX can serve a given asset before
ever invoking the circuit-breaker-wrapped fetchPrice — see SmartDropLabs#130. Matches
fetchPrice's existing '!issuer && normalizedCode !== XLM' check exactly:
true for XLM regardless of issuer, true for any other asset with an
issuer, false for a non-XLM asset with none.
…esn't support the asset

fetchFromAllSources now checks source.isSupported(assetCode, issuer) before
ever calling source.breaker.call(...). This is the actual fix for SmartDropLabs#130:
previously every source's CircuitBreaker instance is shared across all
assets, so a source being asked about even one unsupported asset (e.g.
CoinGecko queried for USDC, which STELLAR_COINGECKO_MAP never mapped) would
eventually trip that source's breaker OPEN for every other asset it does
support, purely from a normal 'unsupported asset' null being misread as a
source failure. A genuine null for an asset a source is supposed to support
still goes through breaker.call() and still counts toward the failure
threshold, unchanged.
…ce's breaker

Adds isSupported mocks to the existing source-module mocks (defaulting to
true so prior tests keep exercising the breaker-wrapped fetch path
unchanged) and a resetCircuitBreakers() call to beforeEach so SmartDropLabs#130's
deliberately-tripped breaker tests never leak state into other tests.

Three new tests: a source is skipped entirely (fetch never called) for an
asset it doesn't support; 5 repeated lookups for a CoinGecko-unsupported
asset never move its breaker off 'closed', and XLM (CoinGecko-supported)
keeps succeeding via it throughout; and a source still trips 'open' after
failureThreshold genuine null returns for an asset it does support, with
fetch correctly skipped afterward. Verified the first two fail against the
pre-fix fetchFromAllSources (fetch called 1 and 2 times respectively) and
pass against the fix.
…rce's circuit breaker

The existing CIRCUIT_BREAKER_* env var docs didn't mention that these
breakers are shared per-source across every watched asset — worth spelling
out now that SmartDropLabs#130's fix makes 'unsupported asset' and 'source failure'
distinct, so operators tuning CIRCUIT_BREAKER_FAILURE_THRESHOLD understand
what actually counts toward it.
…ller's isSupported obligation

Adds a JSDoc comment on call() spelling out that null/undefined is treated
as a failure, and that callers must pre-filter permanently-unsupported
requests before ever calling call() rather than letting them surface as
null — that conflation is exactly what SmartDropLabs#130 was. Points at priceOracle.js's
isSupported gate as the reference implementation so a future caller wrapping
a new shared resource in this breaker doesn't repeat the bug.
@prodbycorne

Copy link
Copy Markdown
Contributor

clean code

@prodbycorne
prodbycorne merged commit 2ff5e20 into SmartDropLabs:main Aug 21, 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

2 participants