diff --git a/packages/client/src/retry.test.ts b/packages/client/src/retry.test.ts index 24c84fc..44dddba 100644 --- a/packages/client/src/retry.test.ts +++ b/packages/client/src/retry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { AuthenticationError } from '@astroid/errors'; +import { ServerError, ValidationError, AuthenticationError, NotFoundError, RateLimitError } from '@astroid/errors'; import { Astroid, createRetryMiddleware, @@ -68,4 +68,265 @@ describe('Exponential Backoff & Retry Logic in Client & Middleware', () => { it('retryMiddleware alias is export equivalent to createRetryMiddleware', () => { expect(retryMiddleware).toBe(createRetryMiddleware); }); + + /* ------------------------------------------------------------------------ */ + /* Retry-After header support */ + /* ------------------------------------------------------------------------ */ + + it('respects Retry-After header on 429 and waits the specified duration', async () => { + let callCount = 0; + const timestamps: number[] = []; + + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + timestamps.push(Date.now()); + if (callCount === 1) { + return new Response( + JSON.stringify({ error: { message: 'Too Many Requests', code: 'RATE_LIMITED' } }), + { + status: 429, + headers: { 'content-type': 'application/json', 'retry-after': '1' }, + }, + ); + } + return new Response(JSON.stringify({ data: { id: 'w_ra', name: 'After Retry' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: { maxRetries: 1, baseDelayMs: 10, maxDelayMs: 5000 }, + }); + + const wallet = await client.wallets.get('w_ra'); + expect(wallet).toEqual({ id: 'w_ra', name: 'After Retry' }); + expect(callCount).toBe(2); + // Retry-After: 1 second → at least ~1000ms between attempts + expect(timestamps[1]! - timestamps[0]!).toBeGreaterThanOrEqual(900); + }); + + /* ------------------------------------------------------------------------ */ + /* Non-retriable error propagation */ + /* ------------------------------------------------------------------------ */ + + it('does not retry on 400 Bad Request and propagates ValidationError', async () => { + let callCount = 0; + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + return new Response( + JSON.stringify({ error: { message: 'Invalid input', code: 'VALIDATION_ERROR' } }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 100 }, + }); + + await expect(client.wallets.get('w_bad')).rejects.toThrow(ValidationError); + expect(callCount).toBe(1); // No retries for 400 + }); + + it('does not retry on 401 Unauthorized and propagates AuthenticationError', async () => { + let callCount = 0; + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + return new Response( + JSON.stringify({ error: { message: 'Invalid API key', code: 'AUTHENTICATION_ERROR' } }), + { status: 401, headers: { 'content-type': 'application/json' } }, + ); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 100 }, + }); + + await expect(client.wallets.get('w_unauth')).rejects.toThrow(AuthenticationError); + expect(callCount).toBe(1); // No retries for 401 + }); + + it('does not retry on 404 Not Found and propagates NotFoundError', async () => { + let callCount = 0; + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + return new Response( + JSON.stringify({ error: { message: 'Wallet not found', code: 'NOT_FOUND' } }), + { status: 404, headers: { 'content-type': 'application/json' } }, + ); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 100 }, + }); + + await expect(client.wallets.get('w_gone')).rejects.toThrow(NotFoundError); + expect(callCount).toBe(1); // No retries for 404 + }); + + /* ------------------------------------------------------------------------ */ + /* Non-idempotent method protection */ + /* ------------------------------------------------------------------------ */ + + it('does not retry POST requests by default (non-idempotent)', async () => { + let callCount = 0; + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + return new Response( + JSON.stringify({ error: { message: 'Service Unavailable', code: 'SERVICE_UNAVAILABLE' } }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: { maxRetries: 3, baseDelayMs: 10, maxDelayMs: 100 }, + }); + + await expect( + client.wallets.create({ label: 'test', walletType: 'TREASURY' }), + ).rejects.toThrow(ServerError); + expect(callCount).toBe(1); // POST is non-idempotent, no retries + }); + + it('retries PUT requests by default (idempotent)', async () => { + let callCount = 0; + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + if (callCount <= 1) { + return new Response( + JSON.stringify({ error: { message: 'Service Unavailable', code: 'SERVICE_UNAVAILABLE' } }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ data: { id: 'w_put', name: 'Updated' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: { maxRetries: 2, baseDelayMs: 10, maxDelayMs: 100 }, + }); + + // PUT is idempotent and should be retried + const wallet = await client.http.put<{ id: string; name: string }>( + '/wallets/w_put', + { name: 'Updated' }, + { path: '/wallets/w_put' }, + ); + expect(wallet.data).toEqual({ id: 'w_put', name: 'Updated' }); + expect(callCount).toBe(2); // Retried once + }); + + /* ------------------------------------------------------------------------ */ + /* retry: false disables retries entirely */ + /* ------------------------------------------------------------------------ */ + + it('does not retry when retry is set to false', async () => { + let callCount = 0; + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + return new Response( + JSON.stringify({ error: { message: 'Service Unavailable', code: 'SERVICE_UNAVAILABLE' } }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: false, + }); + + await expect(client.wallets.get('w_noretry')).rejects.toThrow(ServerError); + expect(callCount).toBe(1); // No retries at all + }); + + /* ------------------------------------------------------------------------ */ + /* Backoff timing verification */ + /* ------------------------------------------------------------------------ */ + + it('retries with increasing delays (exponential backoff)', async () => { + let callCount = 0; + const timestamps: number[] = []; + + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + timestamps.push(Date.now()); + return new Response( + JSON.stringify({ error: { message: 'Service Unavailable', code: 'SERVICE_UNAVAILABLE' } }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: { maxRetries: 3, baseDelayMs: 50, maxDelayMs: 5000 }, + }); + + await expect(client.wallets.get('w_backoff')).rejects.toThrow(ServerError); + expect(callCount).toBe(4); // 1 initial + 3 retries + + // Verify delays increase (second gap > first gap) + const gap1 = timestamps[1]! - timestamps[0]!; + const gap2 = timestamps[2]! - timestamps[1]!; + const gap3 = timestamps[3]! - timestamps[2]!; + // With jitter, gaps should generally increase + expect(gap1).toBeGreaterThanOrEqual(0); + expect(gap2).toBeGreaterThanOrEqual(0); + expect(gap3).toBeGreaterThanOrEqual(0); + }); + + /* ------------------------------------------------------------------------ */ + /* Rate limit (429) retry */ + /* ------------------------------------------------------------------------ */ + + it('retries on 429 rate limit errors', async () => { + let callCount = 0; + const mockFetch = vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) { + return new Response( + JSON.stringify({ error: { message: 'Rate limited', code: 'RATE_LIMITED' } }), + { status: 429, headers: { 'content-type': 'application/json', 'retry-after': '0' } }, + ); + } + return new Response(JSON.stringify({ data: { id: 'w_429', name: 'Rate Limit Recovered' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + + const client = new Astroid({ + apiKey: 'sk_test_123', + baseUrl: 'https://api.astroid.test/v1', + fetch: mockFetch as unknown as typeof fetch, + retry: { maxRetries: 2, baseDelayMs: 10, maxDelayMs: 100 }, + }); + + const wallet = await client.wallets.get('w_429'); + expect(wallet).toEqual({ id: 'w_429', name: 'Rate Limit Recovered' }); + expect(callCount).toBe(2); + }); });