From c69133a77d8574ad6474795c5c1066fc2d339d84 Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Mon, 29 Jun 2026 11:02:35 +0100 Subject: [PATCH] fix: deduplicate concurrent price requests with in-flight promise gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getFreshPrices() performs a read-check-write on lastRequestTime without any synchronisation. Under concurrent price requests (e.g. multiple BullMQ workers triggering rebalance checks simultaneously), all callers read a stale lastRequestTime, pass the rate-limit guard, and fire simultaneous HTTP requests to CoinGecko — exhausting the free-tier quota in seconds. Add an inflightPriceRequest singleton so that all concurrent callers share one in-flight HTTP request. The promise is cleared in .finally() so subsequent requests after completion proceed normally. Fixes #28 --- backend/src/services/reflector.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/src/services/reflector.ts b/backend/src/services/reflector.ts index fad54b2..8c76a9a 100644 --- a/backend/src/services/reflector.ts +++ b/backend/src/services/reflector.ts @@ -24,6 +24,7 @@ export class ReflectorService { private readonly CACHE_DURATION = process.env.NODE_ENV === 'production' ? 600000 : 300000 // 10 min vs 5 min private lastRequestTime = 0 private readonly MIN_REQUEST_INTERVAL = 90000 // Increased to 1.5 minutes for Pro API + private inflightPriceRequest: Promise | null = null private reflectorContractId: string | null private sorobanRpcUrl: string // Maps asset codes to the symbols the Reflector contract recognises @@ -173,8 +174,24 @@ export class ReflectorService { return {} } + // Deduplicate concurrent requests: collapse all callers onto one in-flight fetch. + // Without this, N concurrent callers (e.g. BullMQ workers) each read a stale + // lastRequestTime, pass the guard, and fire N simultaneous HTTP requests. + if (this.inflightPriceRequest) { + logger.info('[DEBUG] Reusing in-flight price request') + return this.inflightPriceRequest + } + this.lastRequestTime = now + this.inflightPriceRequest = this._doFetchPrices(assets).finally(() => { + this.inflightPriceRequest = null + }) + + return this.inflightPriceRequest + } + + private async _doFetchPrices(assets: string[]): Promise { // Try Reflector oracle first; fall back to CoinGecko for any missing assets const reflectorPrices = await this.fetchPricesFromReflector(assets).catch(err => { logger.warn('[Reflector] Batch fetch failed, falling back to CoinGecko:', err)