From 0b16fb83157217c171e986613e1170c9dfe48903 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:33:33 +0100 Subject: [PATCH 01/10] feat(sources): add a reusable per-source circuit breaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (#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. --- src/services/sources/circuitBreaker.js | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/services/sources/circuitBreaker.js diff --git a/src/services/sources/circuitBreaker.js b/src/services/sources/circuitBreaker.js new file mode 100644 index 0000000..8d60d21 --- /dev/null +++ b/src/services/sources/circuitBreaker.js @@ -0,0 +1,70 @@ +'use strict'; + +const logger = require('../../logger'); + +/** + * A per-source circuit breaker for permanent (nonRetryable) failures like an + * invalid/revoked API key. Distinct from ordinary transient failures (network + * blips, rate limits): those already self-heal on the next fetch cycle and + * are intentionally left untouched by this module. + * + * State is process-local (module-level, one instance per source per + * process) — acceptable because it only affects retry cadence, not + * correctness; each horizontally-scaled replica independently rate-limits + * its own calls to a known-broken source rather than sharing a single + * circuit (see #98 for the analogous cross-replica coordination gap in + * scheduled jobs). + */ +function createCircuitBreaker({ sourceName, cooldownMs, reminderIntervalMs }) { + let openUntil = 0; + let lastReminderLoggedAt = 0; + + function isOpen() { + return Date.now() < openUntil; + } + + /** Call when a fetch is skipped because the circuit is open. Logs at most once per reminderIntervalMs, not once per skipped attempt. */ + function noteSkipped(context = {}) { + const now = Date.now(); + if (now - lastReminderLoggedAt >= reminderIntervalMs) { + logger.warn('Price source circuit open, skipping fetch', { + source: sourceName, + openUntil: new Date(openUntil).toISOString(), + ...context, + }); + lastReminderLoggedAt = now; + } + } + + /** Call on a nonRetryable failure. Logs distinctly (error level) only the first time the circuit transitions from closed to open. */ + function open(context = {}) { + const wasOpen = isOpen(); + openUntil = Date.now() + cooldownMs; + if (!wasOpen) { + logger.error('Price source permanently misconfigured', { + source: sourceName, + cooldownMs, + ...context, + }); + lastReminderLoggedAt = Date.now(); + } + } + + /** Call on a successful fetch. No-op if the circuit was already closed. */ + function close() { + openUntil = 0; + lastReminderLoggedAt = 0; + } + + function getState() { + return { + source: sourceName, + open: isOpen(), + openUntil: openUntil ? new Date(openUntil).toISOString() : null, + }; + } + + return { isOpen, noteSkipped, open, close, getState }; +} + +module.exports = { createCircuitBreaker }; From 68fb868f687b9264b2f44fa81c4afb1b5550cd8e Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:33:49 +0100 Subject: [PATCH 02/10] feat(config): add PRICE_SOURCE_CIRCUIT_COOLDOWN_MS / _REMINDER_MS 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. --- src/config.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/config.js b/src/config.js index 80f1547..6f83c67 100644 --- a/src/config.js +++ b/src/config.js @@ -38,6 +38,8 @@ const env = cleanEnv(rawEnv, { PRICE_REFRESH_INTERVAL_SECONDS: num({ default: 30 }), PRICE_STALE_THRESHOLD_MINUTES: num({ default: 5 }), PRICE_ANOMALY_THRESHOLD_PCT: num({ default: 20 }), + PRICE_SOURCE_CIRCUIT_COOLDOWN_MS: num({ default: 15 * 60 * 1000 }), + PRICE_SOURCE_CIRCUIT_REMINDER_MS: num({ default: 5 * 60 * 1000 }), LOG_LEVEL: str({ default: 'info', choices: ['debug', 'info', 'warn', 'error'], @@ -75,6 +77,15 @@ module.exports = { staleThresholdMinutes: env.PRICE_STALE_THRESHOLD_MINUTES, anomalyThresholdPercent: env.PRICE_ANOMALY_THRESHOLD_PCT, }, + priceSources: { + // How long a source's circuit stays open after a nonRetryable (e.g. 401) + // failure before it's attempted again. + circuitCooldownMs: env.PRICE_SOURCE_CIRCUIT_COOLDOWN_MS, + // Minimum gap between repeated "circuit open, skipping" log lines while + // the circuit stays open, so a misconfigured key doesn't spam one log + // line per fetch cycle for the entire cooldown window. + circuitReminderIntervalMs: env.PRICE_SOURCE_CIRCUIT_REMINDER_MS, + }, auth: { adminApiKey: env.ADMIN_API_KEY, }, From a24a005a86d53a367024fac217490889387c9423 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:34:12 +0100 Subject: [PATCH 03/10] fix(coinmarketcap): actually consult nonRetryable via a circuit breaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/services/sources/coinmarketcap.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/services/sources/coinmarketcap.js b/src/services/sources/coinmarketcap.js index f34e881..0ce3f73 100644 --- a/src/services/sources/coinmarketcap.js +++ b/src/services/sources/coinmarketcap.js @@ -1,6 +1,13 @@ const axios = require('axios'); const config = require('../../config'); const logger = require('../../logger'); +const { createCircuitBreaker } = require('./circuitBreaker'); + +const circuit = createCircuitBreaker({ + sourceName: 'coinmarketcap', + cooldownMs: config.priceSources.circuitCooldownMs, + reminderIntervalMs: config.priceSources.circuitReminderIntervalMs, +}); let apiClient = null; @@ -55,6 +62,11 @@ async function fetchPrice(assetCode, issuer = null) { return null; } + if (circuit.isOpen()) { + circuit.noteSkipped({ assetCode }); + return null; + } + try { const client = getClient(); const lookupKey = market.id ? String(market.id) : market.symbol; @@ -65,6 +77,11 @@ async function fetchPrice(assetCode, issuer = null) { }, }); + // A successful HTTP round-trip means the API key is valid, regardless + // of whether this particular asset had usable quote data — close the + // circuit before evaluating the response shape. + circuit.close(); + const data = response.data?.data?.[lookupKey]; if (!data || !data.quote?.USD?.price) { return null; @@ -74,6 +91,7 @@ async function fetchPrice(assetCode, issuer = null) { } catch (err) { if (err.response?.status === 401) { err.nonRetryable = true; + circuit.open({ assetCode }); logger.warn('CoinMarketCap authentication failed', { assetCode }); throw err; } @@ -89,4 +107,4 @@ async function fetchPrice(assetCode, issuer = null) { } } -module.exports = { fetchPrice }; +module.exports = { fetchPrice, getCircuitState: circuit.getState }; From 8940a736e30836525b8b59fd12561fe5c8455ce5 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:34:36 +0100 Subject: [PATCH 04/10] feat(coingecko): extend nonRetryable/circuit-breaker treatment to 401s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per #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. --- src/services/sources/coingecko.js | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/services/sources/coingecko.js b/src/services/sources/coingecko.js index 7e5ee93..d8c93c3 100644 --- a/src/services/sources/coingecko.js +++ b/src/services/sources/coingecko.js @@ -1,11 +1,18 @@ const axios = require('axios'); const config = require('../../config'); const logger = require('../../logger'); +const { createCircuitBreaker } = require('./circuitBreaker'); const STELLAR_COINGECKO_MAP = { XLM: 'stellar', }; +const circuit = createCircuitBreaker({ + sourceName: 'coingecko', + cooldownMs: config.priceSources.circuitCooldownMs, + reminderIntervalMs: config.priceSources.circuitReminderIntervalMs, +}); + let apiClient = null; function getClient() { @@ -30,6 +37,11 @@ async function fetchPrice(assetCode) { return null; } + if (circuit.isOpen()) { + circuit.noteSkipped({ assetCode }); + return null; + } + try { const client = getClient(); const response = await client.get('/simple/price', { @@ -39,6 +51,10 @@ async function fetchPrice(assetCode) { }, }); + // A successful HTTP round-trip means any configured API key is valid, + // regardless of whether this particular coin had usable price data. + circuit.close(); + const price = response.data[coinId]?.usd; if (price === undefined || price === null) { return null; @@ -46,6 +62,16 @@ async function fetchPrice(assetCode) { return price; } catch (err) { + if (err.response?.status === 401) { + // Per CoinGecko's docs, 401 means a missing/invalid API key — a + // permanent misconfiguration, not something that self-heals on + // retry. Distinct from 403 (CDN/firewall block) and 429 (rate + // limit), neither of which indicate a bad key. + err.nonRetryable = true; + circuit.open({ assetCode }); + logger.warn('CoinGecko authentication failed', { assetCode }); + throw err; + } if (err.response?.status === 429) { logger.warn('CoinGecko rate limit hit', { assetCode }); } else { @@ -55,4 +81,4 @@ async function fetchPrice(assetCode) { } } -module.exports = { fetchPrice }; +module.exports = { fetchPrice, getCircuitState: circuit.getState }; From fb375e5e2bd4f04101d580bd675b7abbb43aa863 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:35:07 +0100 Subject: [PATCH 05/10] feat(priceOracle): expose getSourceCircuitStates() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #95's observability requirement. --- src/services/priceOracle.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/services/priceOracle.js b/src/services/priceOracle.js index 7d03f27..b041079 100644 --- a/src/services/priceOracle.js +++ b/src/services/priceOracle.js @@ -9,10 +9,22 @@ const CACHE_PREFIX = 'price:'; const HISTORY_PREFIX = 'price:history:'; const SOURCES = [ { name: 'stellar_dex', fetch: stellarDex.fetchPrice }, - { name: 'coingecko', fetch: coingecko.fetchPrice }, - { name: 'coinmarketcap', fetch: coinmarketcap.fetchPrice }, + { name: 'coingecko', fetch: coingecko.fetchPrice, getCircuitState: coingecko.getCircuitState }, + { name: 'coinmarketcap', fetch: coinmarketcap.fetchPrice, getCircuitState: coinmarketcap.getCircuitState }, ]; +/** + * Circuit-breaker state for every source that has one (currently coingecko + * and coinmarketcap — stellar_dex has no API-key/auth failure mode). Lets + * callers (e.g. /health) see at a glance which price sources are currently + * skipped due to a nonRetryable failure. See #95. + */ +function getSourceCircuitStates() { + return SOURCES.filter((source) => typeof source.getCircuitState === 'function').map((source) => + source.getCircuitState() + ); +} + function median(values) { if (values.length === 0) return null; const sorted = [...values].sort((a, b) => a - b); @@ -236,4 +248,5 @@ module.exports = { getPrice, fetchFreshPrice, refreshAllCachedPrices, + getSourceCircuitStates, }; From 00f9d5e673116e9a0f014b6df3bbbc292072660b Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:35:07 +0100 Subject: [PATCH 06/10] feat(health): surface price source circuit states in GET /health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds price_source_circuits to the /health response body (and its OpenAPI schema) using priceOracle.getSourceCircuitStates() — the lowest-effort of the observability options #95 names (health response / metrics / logs) given #92 (health depth) and #93 (metrics) haven't landed yet. --- openapi.yaml | 17 +++++++++++++++++ src/index.js | 2 ++ 2 files changed, 19 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index dbadba4..1231371 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -460,6 +460,23 @@ components: redis_unavailable: type: boolean description: Inverse of redis_connected (for legacy monitoring) + price_source_circuits: + type: array + description: Circuit-breaker state for each price source with a nonRetryable failure mode (e.g. an invalid API key) + items: + type: object + properties: + source: + type: string + description: Price source name (e.g. coingecko, coinmarketcap) + open: + type: boolean + description: Whether this source is currently circuit-broken and being skipped + openUntil: + type: string + format: date-time + nullable: true + description: When the circuit will next allow a retry, or null if closed required: - status - timestamp diff --git a/src/index.js b/src/index.js index f7a5edf..56f0c18 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ const helmet = require('helmet'); const config = require('./config'); const logger = require('./logger'); const cache = require('./services/cache'); +const priceOracle = require('./services/priceOracle'); const priceRefreshJob = require('./jobs/priceRefresh'); const webhookRetryWorker = require('./jobs/webhookRetryWorker'); const buildCorsMiddleware = require('./middleware/cors'); @@ -35,6 +36,7 @@ app.get('/health', (req, res) => { timestamp: new Date().toISOString(), redis_connected: redisConnected, redis_unavailable: !redisConnected, + price_source_circuits: priceOracle.getSourceCircuitStates(), }); }); From f9a92943d1045405b02f094500a138f6b5327632 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:35:45 +0100 Subject: [PATCH 07/10] test: cover circuitBreaker.js in isolation 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. --- test/circuitBreaker.test.js | 145 ++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 test/circuitBreaker.test.js diff --git a/test/circuitBreaker.test.js b/test/circuitBreaker.test.js new file mode 100644 index 0000000..d90e709 --- /dev/null +++ b/test/circuitBreaker.test.js @@ -0,0 +1,145 @@ +'use strict'; + +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; + +jest.mock('../src/logger', () => mockLogger); + +function loadCircuitBreaker() { + jest.resetModules(); + mockLogger.error.mockClear(); + mockLogger.warn.mockClear(); + return require('../src/services/sources/circuitBreaker'); +} + +describe('circuit breaker', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('starts closed', () => { + const { createCircuitBreaker } = loadCircuitBreaker(); + const circuit = createCircuitBreaker({ + sourceName: 'test-source', + cooldownMs: 60000, + reminderIntervalMs: 30000, + }); + + expect(circuit.isOpen()).toBe(false); + expect(circuit.getState()).toEqual({ source: 'test-source', open: false, openUntil: null }); + }); + + test('open() trips the circuit and logs distinctly at error level the first time', () => { + const { createCircuitBreaker } = loadCircuitBreaker(); + const circuit = createCircuitBreaker({ + sourceName: 'test-source', + cooldownMs: 60000, + reminderIntervalMs: 30000, + }); + + circuit.open({ assetCode: 'XLM' }); + + expect(circuit.isOpen()).toBe(true); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Price source permanently misconfigured', + expect.objectContaining({ source: 'test-source', assetCode: 'XLM', cooldownMs: 60000 }) + ); + }); + + test('open() called again while already open does not repeat the error log', () => { + const { createCircuitBreaker } = loadCircuitBreaker(); + const circuit = createCircuitBreaker({ + sourceName: 'test-source', + cooldownMs: 60000, + reminderIntervalMs: 30000, + }); + + circuit.open(); + circuit.open(); + circuit.open(); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + test('remains open until cooldownMs elapses', () => { + const { createCircuitBreaker } = loadCircuitBreaker(); + const circuit = createCircuitBreaker({ + sourceName: 'test-source', + cooldownMs: 60000, + reminderIntervalMs: 30000, + }); + + circuit.open(); + jest.advanceTimersByTime(59999); + expect(circuit.isOpen()).toBe(true); + + jest.advanceTimersByTime(2); + expect(circuit.isOpen()).toBe(false); + }); + + test('close() resets the circuit immediately', () => { + const { createCircuitBreaker } = loadCircuitBreaker(); + const circuit = createCircuitBreaker({ + sourceName: 'test-source', + cooldownMs: 60000, + reminderIntervalMs: 30000, + }); + + circuit.open(); + expect(circuit.isOpen()).toBe(true); + + circuit.close(); + expect(circuit.isOpen()).toBe(false); + expect(circuit.getState()).toEqual({ source: 'test-source', open: false, openUntil: null }); + }); + + test('noteSkipped logs at most once per reminderIntervalMs while open', () => { + const { createCircuitBreaker } = loadCircuitBreaker(); + const circuit = createCircuitBreaker({ + sourceName: 'test-source', + cooldownMs: 60000, + reminderIntervalMs: 30000, + }); + + circuit.open(); + mockLogger.warn.mockClear(); + + // open() already logged the initial failure at error level and stamped + // the reminder clock, so immediate skips shouldn't double-log a warn. + circuit.noteSkipped(); + circuit.noteSkipped(); + circuit.noteSkipped(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(30000); + circuit.noteSkipped(); + circuit.noteSkipped(); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + }); + + test('re-opening after a fresh failure logs the error again', () => { + const { createCircuitBreaker } = loadCircuitBreaker(); + const circuit = createCircuitBreaker({ + sourceName: 'test-source', + cooldownMs: 60000, + reminderIntervalMs: 30000, + }); + + circuit.open(); + jest.advanceTimersByTime(60001); + expect(circuit.isOpen()).toBe(false); + + circuit.open(); + expect(mockLogger.error).toHaveBeenCalledTimes(2); + }); +}); From 29c84f64c6ef17badcecabfa9ca47d610d871cd8 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:35:45 +0100 Subject: [PATCH 08/10] test(coinmarketcap): cover the circuit breaker (#95 acceptance criteria) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers every acceptance criterion from #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 #95 asks for — 429s are completely unaffected by the circuit breaker. --- test/coinmarketcap.test.js | 119 +++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/test/coinmarketcap.test.js b/test/coinmarketcap.test.js index fe06716..b97dc85 100644 --- a/test/coinmarketcap.test.js +++ b/test/coinmarketcap.test.js @@ -28,6 +28,10 @@ jest.mock('../src/config', () => ({ [`USDC:${mockUsdcIssuer}`]: { id: 3408 }, }, }, + priceSources: { + circuitCooldownMs: 900000, + circuitReminderIntervalMs: 300000, + }, })); jest.mock('../src/logger', () => mockLogger); @@ -53,6 +57,7 @@ function loadSource() { mockAxiosCreate.mockReturnValue({ get: mockGet }); mockLogger.warn.mockClear(); mockLogger.debug.mockClear(); + mockLogger.error.mockClear(); return require('../src/services/sources/coinmarketcap'); } @@ -160,4 +165,118 @@ describe('CoinMarketCap source', () => { await expect(coinmarketcap.fetchPrice('XLM')).resolves.toBeNull(); }); + + describe('circuit breaker (#95)', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('opens the circuit on a 401 and skips the HTTP request on the next fetch', async () => { + const coinmarketcap = loadSource(); + const authError = new Error('unauthorized'); + authError.response = { status: 401 }; + mockGet.mockRejectedValueOnce(authError); + + await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + expect(coinmarketcap.getCircuitState()).toEqual({ + source: 'coinmarketcap', + open: true, + openUntil: new Date('2026-01-01T00:15:00.000Z').toISOString(), + }); + + mockGet.mockClear(); + const price = await coinmarketcap.fetchPrice('XLM'); + + expect(price).toBeNull(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + test('retries the source after the cooldown window elapses', async () => { + const coinmarketcap = loadSource(); + const authError = new Error('unauthorized'); + authError.response = { status: 401 }; + mockGet.mockRejectedValueOnce(authError); + await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + + jest.advanceTimersByTime(900001); + mockGet.mockClear(); + mockGet.mockResolvedValueOnce(quoteResponse('XLM', 0.15)); + + const price = await coinmarketcap.fetchPrice('XLM'); + + expect(mockGet).toHaveBeenCalledTimes(1); + expect(price).toBe(0.15); + }); + + test('a successful retry closes the circuit', async () => { + const coinmarketcap = loadSource(); + const authError = new Error('unauthorized'); + authError.response = { status: 401 }; + mockGet.mockRejectedValueOnce(authError); + await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + + jest.advanceTimersByTime(900001); + mockGet.mockResolvedValueOnce(quoteResponse('XLM', 0.15)); + await coinmarketcap.fetchPrice('XLM'); + + expect(coinmarketcap.getCircuitState()).toEqual({ + source: 'coinmarketcap', + open: false, + openUntil: null, + }); + }); + + test('a fresh 401 after cooldown re-opens the circuit with a new window', async () => { + const coinmarketcap = loadSource(); + const authError = new Error('unauthorized'); + authError.response = { status: 401 }; + mockGet.mockRejectedValueOnce(authError); + await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + + jest.advanceTimersByTime(900001); + mockGet.mockRejectedValueOnce(authError); + await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + + expect(coinmarketcap.getCircuitState().open).toBe(true); + expect(mockLogger.error).toHaveBeenCalledTimes(2); + }); + + test('the first 401 logs distinctly at error level; repeated skips while open do not', async () => { + const coinmarketcap = loadSource(); + const authError = new Error('unauthorized'); + authError.response = { status: 401 }; + mockGet.mockRejectedValueOnce(authError); + await expect(coinmarketcap.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Price source permanently misconfigured', + expect.objectContaining({ source: 'coinmarketcap' }) + ); + + await coinmarketcap.fetchPrice('XLM'); + await coinmarketcap.fetchPrice('XLM'); + + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + test('429s are unaffected by the circuit breaker', async () => { + const coinmarketcap = loadSource(); + const rateLimitError = new Error('too many requests'); + rateLimitError.response = { status: 429 }; + mockGet.mockRejectedValue(rateLimitError); + + await coinmarketcap.fetchPrice('XLM'); + const price = await coinmarketcap.fetchPrice('XLM'); + + expect(price).toBeNull(); + expect(mockGet).toHaveBeenCalledTimes(2); + expect(coinmarketcap.getCircuitState().open).toBe(false); + }); + }); }); From 1fd06dd8c12f03baf279fbca925bca01ec18d6c4 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:36:03 +0100 Subject: [PATCH 09/10] test(coingecko): add source coverage (previously untested) including 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). --- test/coingecko.test.js | 190 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 test/coingecko.test.js diff --git a/test/coingecko.test.js b/test/coingecko.test.js new file mode 100644 index 0000000..8fa5219 --- /dev/null +++ b/test/coingecko.test.js @@ -0,0 +1,190 @@ +'use strict'; + +const mockGet = jest.fn(); +const mockAxiosCreate = jest.fn(() => ({ get: mockGet })); + +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; + +jest.mock('axios', () => ({ + create: mockAxiosCreate, +})); + +jest.mock('../src/config', () => ({ + coingecko: { + apiKey: 'cg-test-key', + baseUrl: 'https://api.coingecko.test/api/v3', + }, + priceSources: { + circuitCooldownMs: 900000, + circuitReminderIntervalMs: 300000, + }, +})); + +jest.mock('../src/logger', () => mockLogger); + +function priceResponse(coinId, price) { + return { data: { [coinId]: { usd: price } } }; +} + +function loadSource() { + jest.resetModules(); + mockGet.mockReset(); + mockAxiosCreate.mockClear(); + mockAxiosCreate.mockReturnValue({ get: mockGet }); + mockLogger.warn.mockClear(); + mockLogger.debug.mockClear(); + mockLogger.error.mockClear(); + return require('../src/services/sources/coingecko'); +} + +describe('CoinGecko source', () => { + test('returns USD price for a supported asset (XLM)', async () => { + const coingecko = loadSource(); + mockGet.mockResolvedValueOnce(priceResponse('stellar', 0.11)); + + const price = await coingecko.fetchPrice('XLM'); + + expect(price).toBe(0.11); + expect(mockAxiosCreate).toHaveBeenCalledWith({ + baseURL: 'https://api.coingecko.test/api/v3', + headers: { Accept: 'application/json', 'x-cg-demo-api-key': 'cg-test-key' }, + timeout: 10000, + }); + expect(mockGet).toHaveBeenCalledWith('/simple/price', { + params: { ids: 'stellar', vs_currencies: 'usd' }, + }); + }); + + test('returns null for an unsupported asset without calling CoinGecko', async () => { + const coingecko = loadSource(); + + const price = await coingecko.fetchPrice('DOGE'); + + expect(price).toBeNull(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + test('returns null when the response omits usd price', async () => { + const coingecko = loadSource(); + mockGet.mockResolvedValueOnce({ data: { stellar: {} } }); + + await expect(coingecko.fetchPrice('XLM')).resolves.toBeNull(); + }); + + test('throws non-retryable HTTP 401 errors for an invalid API key', async () => { + const coingecko = loadSource(); + const authError = new Error('unauthorized'); + authError.response = { status: 401 }; + mockGet.mockRejectedValueOnce(authError); + + await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + expect(authError.nonRetryable).toBe(true); + expect(mockLogger.warn).toHaveBeenCalledWith('CoinGecko authentication failed', { assetCode: 'XLM' }); + }); + + test('returns null and logs on HTTP 429 rate limits, without throwing', async () => { + const coingecko = loadSource(); + const rateLimitError = new Error('too many requests'); + rateLimitError.response = { status: 429 }; + mockGet.mockRejectedValueOnce(rateLimitError); + + const price = await coingecko.fetchPrice('XLM'); + + expect(price).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith('CoinGecko rate limit hit', { assetCode: 'XLM' }); + }); + + test('returns null and logs a generic failure for other errors, without throwing', async () => { + const coingecko = loadSource(); + const networkError = new Error('ECONNRESET'); + mockGet.mockRejectedValueOnce(networkError); + + const price = await coingecko.fetchPrice('XLM'); + + expect(price).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'CoinGecko price fetch failed', + { assetCode: 'XLM', error: 'ECONNRESET' } + ); + }); + + describe('circuit breaker (#95)', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('opens the circuit on a 401 and skips the HTTP request on the next fetch', async () => { + const coingecko = loadSource(); + const authError = new Error('unauthorized'); + authError.response = { status: 401 }; + mockGet.mockRejectedValueOnce(authError); + + await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + expect(coingecko.getCircuitState().open).toBe(true); + + mockGet.mockClear(); + const price = await coingecko.fetchPrice('XLM'); + + expect(price).toBeNull(); + expect(mockGet).not.toHaveBeenCalled(); + }); + + test('retries after cooldown and closes the circuit on success', async () => { + const coingecko = loadSource(); + const authError = new Error('unauthorized'); + authError.response = { status: 401 }; + mockGet.mockRejectedValueOnce(authError); + await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized'); + + jest.advanceTimersByTime(900001); + mockGet.mockClear(); + mockGet.mockResolvedValueOnce(priceResponse('stellar', 0.12)); + + const price = await coingecko.fetchPrice('XLM'); + + expect(mockGet).toHaveBeenCalledTimes(1); + expect(price).toBe(0.12); + expect(coingecko.getCircuitState()).toEqual({ + source: 'coingecko', + open: false, + openUntil: null, + }); + }); + + test('403s (CDN/firewall block) are unaffected by the circuit breaker', async () => { + const coingecko = loadSource(); + const forbiddenError = new Error('forbidden'); + forbiddenError.response = { status: 403 }; + mockGet.mockRejectedValue(forbiddenError); + + const price = await coingecko.fetchPrice('XLM'); + + expect(price).toBeNull(); + expect(coingecko.getCircuitState().open).toBe(false); + }); + + test('429s are unaffected by the circuit breaker', async () => { + const coingecko = loadSource(); + const rateLimitError = new Error('too many requests'); + rateLimitError.response = { status: 429 }; + mockGet.mockRejectedValue(rateLimitError); + + await coingecko.fetchPrice('XLM'); + const price = await coingecko.fetchPrice('XLM'); + + expect(price).toBeNull(); + expect(mockGet).toHaveBeenCalledTimes(2); + expect(coingecko.getCircuitState().open).toBe(false); + }); + }); +}); From 09590658a45e36b5e3d7157bf2a8239c27f3f64a Mon Sep 17 00:00:00 2001 From: Temi-suwa18 <271503102+Temi-suwa18@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:36:03 +0100 Subject: [PATCH 10/10] test: cover getSourceCircuitStates() and its GET /health exposure 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. --- test/health.test.js | 33 +++++++++++++++++++++++ test/priceOracleCircuit.test.js | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 test/health.test.js create mode 100644 test/priceOracleCircuit.test.js diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..3cc935f --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,33 @@ +'use strict'; + +const request = require('supertest'); + +describe('GET /health', () => { + test('includes price_source_circuits with an entry per source that has a circuit breaker', async () => { + jest.resetModules(); + const { app } = require('../src/index'); + + const res = await request(app).get('/health'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.price_source_circuits)).toBe(true); + + const sourceNames = res.body.price_source_circuits.map((c) => c.source); + expect(sourceNames).toEqual(expect.arrayContaining(['coingecko', 'coinmarketcap'])); + + // stellar_dex has no API-key/auth failure mode, so it has no circuit entry. + expect(sourceNames).not.toContain('stellar_dex'); + }); + + test('every circuit starts closed with a null openUntil', async () => { + jest.resetModules(); + const { app } = require('../src/index'); + + const res = await request(app).get('/health'); + + for (const circuit of res.body.price_source_circuits) { + expect(circuit.open).toBe(false); + expect(circuit.openUntil).toBeNull(); + } + }); +}); diff --git a/test/priceOracleCircuit.test.js b/test/priceOracleCircuit.test.js new file mode 100644 index 0000000..221e7ec --- /dev/null +++ b/test/priceOracleCircuit.test.js @@ -0,0 +1,48 @@ +'use strict'; + +jest.mock('../src/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +jest.mock('../src/services/cache', () => ({ + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + getClient: jest.fn(), + isConnected: jest.fn(), +})); + +const mockCoingeckoCircuitState = { source: 'coingecko', open: true, openUntil: '2026-01-01T00:15:00.000Z' }; +const mockCmcCircuitState = { source: 'coinmarketcap', open: false, openUntil: null }; + +jest.mock('../src/services/sources/stellarDex', () => ({ + fetchPrice: jest.fn(), + // Deliberately no getCircuitState — stellar_dex has no auth-failure mode. +})); +jest.mock('../src/services/sources/coingecko', () => ({ + fetchPrice: jest.fn(), + getCircuitState: jest.fn(() => mockCoingeckoCircuitState), +})); +jest.mock('../src/services/sources/coinmarketcap', () => ({ + fetchPrice: jest.fn(), + getCircuitState: jest.fn(() => mockCmcCircuitState), +})); + +const priceOracle = require('../src/services/priceOracle'); + +describe('priceOracle.getSourceCircuitStates', () => { + test('returns the circuit state for every source that has one', () => { + const states = priceOracle.getSourceCircuitStates(); + + expect(states).toEqual([mockCoingeckoCircuitState, mockCmcCircuitState]); + }); + + test('omits sources with no getCircuitState (e.g. stellar_dex)', () => { + const states = priceOracle.getSourceCircuitStates(); + + expect(states.find((s) => s.source === 'stellar_dex')).toBeUndefined(); + }); +});