Skip to content

fix: deduplicate concurrent price requests to prevent CoinGecko quota exhaustion#33

Open
luciachizaram wants to merge 1 commit into
grantFoxin:mainfrom
luciachizaram:fix/coingecko-thundering-herd
Open

fix: deduplicate concurrent price requests to prevent CoinGecko quota exhaustion#33
luciachizaram wants to merge 1 commit into
grantFoxin:mainfrom
luciachizaram:fix/coingecko-thundering-herd

Conversation

@luciachizaram

Copy link
Copy Markdown

Problem

ReflectorService.getFreshPrices() performs a read-check-write on this.lastRequestTime without any synchronisation:

const now = Date.now()
if (now - this.lastRequestTime < this.MIN_REQUEST_INTERVAL) {
    return {}
}
this.lastRequestTime = now

JavaScript's single-threaded event loop does not protect against this: await fetch() yields execution, allowing multiple coroutines to pass the guard before any of them updates lastRequestTime. With ≥4 concurrent BullMQ workers, all callers fire simultaneous HTTP requests to CoinGecko, exhausting the free-tier quota (10–30 req/min) in seconds.

After 429s, the service falls back to getFallbackPrices() which adds ±1% random noise to hardcoded base prices — rebalance thresholds may trigger or suppress incorrectly.

Fix

Add an inflightPriceRequest singleton promise gate. When a fetch is already in flight, subsequent callers reuse the same promise instead of firing a new request:

if (this.inflightPriceRequest) {
    return this.inflightPriceRequest
}

this.inflightPriceRequest = this._doFetchPrices(assets).finally(() => {
    this.inflightPriceRequest = null
})
return this.inflightPriceRequest

This collapses all concurrent callers onto one in-flight HTTP request, honouring the rate limit regardless of concurrency level. The .finally() ensures the gate is cleared whether the fetch succeeds or fails, so subsequent requests after completion proceed normally.

Changes

  • backend/src/services/reflector.ts:
    • Added inflightPriceRequest property
    • getFreshPrices() now checks for and reuses in-flight requests
    • Extracted fetch logic to _doFetchPrices() for clarity

Fixes #28

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 grantFoxin#28
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.

Non-atomic rate-limit guard in ReflectorService causes thundering-herd: concurrent requests bypass MIN_REQUEST_INTERVAL and exhaust CoinGecko quota

1 participant