|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect, vi } from 'vitest'; |
| 4 | +import { resilientFetch } from './resilient-fetch'; |
| 5 | + |
| 6 | +/** Minimal Response stand-in — resilientFetch only reads `.status` + `.headers.get`. */ |
| 7 | +function resp(status: number, headers: Record<string, string> = {}): Response { |
| 8 | + return { |
| 9 | + status, |
| 10 | + ok: status < 400, |
| 11 | + headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, |
| 12 | + } as unknown as Response; |
| 13 | +} |
| 14 | + |
| 15 | +/** A fetch that returns the scripted statuses in order (last one repeats). */ |
| 16 | +function scripted(statuses: Array<number | Record<string, string> | [number, Record<string, string>]>) { |
| 17 | + let i = 0; |
| 18 | + return vi.fn(async () => { |
| 19 | + const s = statuses[Math.min(i++, statuses.length - 1)]; |
| 20 | + if (Array.isArray(s)) return resp(s[0], s[1]); |
| 21 | + return resp(s as number); |
| 22 | + }); |
| 23 | +} |
| 24 | + |
| 25 | +const noSleep = async () => {}; |
| 26 | + |
| 27 | +describe('resilientFetch', () => { |
| 28 | + it('returns a successful response without retrying', async () => { |
| 29 | + const fetchImpl = scripted([200]); |
| 30 | + const res = await resilientFetch('http://x', {}, { fetchImpl, sleep: noSleep }); |
| 31 | + expect(res.status).toBe(200); |
| 32 | + expect(fetchImpl).toHaveBeenCalledTimes(1); |
| 33 | + }); |
| 34 | + |
| 35 | + it('retries a 429 then returns the success', async () => { |
| 36 | + const fetchImpl = scripted([429, 200]); |
| 37 | + const res = await resilientFetch('http://x', {}, { fetchImpl, sleep: noSleep, retries: 3 }); |
| 38 | + expect(res.status).toBe(200); |
| 39 | + expect(fetchImpl).toHaveBeenCalledTimes(2); |
| 40 | + }); |
| 41 | + |
| 42 | + it('retries a 5xx then returns the success', async () => { |
| 43 | + const fetchImpl = scripted([503, 500, 200]); |
| 44 | + const res = await resilientFetch('http://x', {}, { fetchImpl, sleep: noSleep, retries: 3 }); |
| 45 | + expect(res.status).toBe(200); |
| 46 | + expect(fetchImpl).toHaveBeenCalledTimes(3); |
| 47 | + }); |
| 48 | + |
| 49 | + it('gives up after `retries` attempts and returns the last response', async () => { |
| 50 | + const fetchImpl = scripted([500, 500, 500]); |
| 51 | + const res = await resilientFetch('http://x', {}, { fetchImpl, sleep: noSleep, retries: 3 }); |
| 52 | + expect(res.status).toBe(500); |
| 53 | + expect(fetchImpl).toHaveBeenCalledTimes(3); |
| 54 | + }); |
| 55 | + |
| 56 | + it('does NOT retry a non-retryable status (4xx other than 429)', async () => { |
| 57 | + const fetchImpl = scripted([404, 200]); |
| 58 | + const res = await resilientFetch('http://x', {}, { fetchImpl, sleep: noSleep, retries: 3 }); |
| 59 | + expect(res.status).toBe(404); |
| 60 | + expect(fetchImpl).toHaveBeenCalledTimes(1); |
| 61 | + }); |
| 62 | + |
| 63 | + it('honours a numeric Retry-After header on a 429', async () => { |
| 64 | + const fetchImpl = scripted([[429, { 'retry-after': '2' }], 200]); |
| 65 | + const sleep = vi.fn(noSleep); |
| 66 | + await resilientFetch('http://x', {}, { fetchImpl, sleep, retries: 3 }); |
| 67 | + expect(sleep).toHaveBeenCalledWith(2000); |
| 68 | + }); |
| 69 | + |
| 70 | + it('times out a hung request and surfaces the error', async () => { |
| 71 | + const fetchImpl = vi.fn( |
| 72 | + (_url: any, init: any) => |
| 73 | + new Promise<Response>((_, reject) => { |
| 74 | + init.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); |
| 75 | + }), |
| 76 | + ); |
| 77 | + await expect( |
| 78 | + resilientFetch('http://x', {}, { fetchImpl, sleep: noSleep, retries: 1, timeoutMs: 10 }), |
| 79 | + ).rejects.toThrow(); |
| 80 | + expect(fetchImpl).toHaveBeenCalledTimes(1); |
| 81 | + }); |
| 82 | + |
| 83 | + it('retries a network error before succeeding', async () => { |
| 84 | + let n = 0; |
| 85 | + const fetchImpl = vi.fn(async () => { |
| 86 | + if (n++ === 0) throw new Error('ECONNRESET'); |
| 87 | + return resp(200); |
| 88 | + }); |
| 89 | + const res = await resilientFetch('http://x', {}, { fetchImpl, sleep: noSleep, retries: 3 }); |
| 90 | + expect(res.status).toBe(200); |
| 91 | + expect(fetchImpl).toHaveBeenCalledTimes(2); |
| 92 | + }); |
| 93 | + |
| 94 | + it('does not retry when the caller aborts', async () => { |
| 95 | + const ac = new AbortController(); |
| 96 | + ac.abort(); |
| 97 | + const fetchImpl = vi.fn(async (_u: any, init: any) => { |
| 98 | + if (init.signal.aborted) throw new Error('aborted by caller'); |
| 99 | + return resp(200); |
| 100 | + }); |
| 101 | + await expect( |
| 102 | + resilientFetch('http://x', { signal: ac.signal }, { fetchImpl, sleep: noSleep, retries: 3 }), |
| 103 | + ).rejects.toThrow(/aborted/); |
| 104 | + expect(fetchImpl).toHaveBeenCalledTimes(1); |
| 105 | + }); |
| 106 | +}); |
0 commit comments