diff --git a/src/errors.ts b/src/errors.ts index 11c9c65..268c169 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -18,9 +18,15 @@ export class AuthenticationError extends DecodoError { } export class RateLimitError extends DecodoError { - constructor(message = 'Rate limit exceeded. Slow down your request rate.') { + public readonly retryAfterMs: number | undefined; + + constructor( + message = 'Rate limit exceeded. Slow down your request rate.', + retryAfterMs?: number, + ) { super(message, 429, 'failed'); this.name = 'RateLimitError'; + this.retryAfterMs = retryAfterMs; } } diff --git a/src/http.test.ts b/src/http.test.ts index 871eaa9..076089d 100644 --- a/src/http.test.ts +++ b/src/http.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { HttpClient } from './http.js'; +import { RateLimitError } from './errors.js'; import { jsonResponse, mockFetch } from './schema/test-helpers.js'; const baseConfig = { @@ -83,3 +84,60 @@ describe('HttpClient auth header', () => { expect(capturedHeaders?.Authorization).toBe('Bearer test-key'); }); }); + +describe('HttpClient rate limit Retry-After', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const rateLimited = (headers: Record = {}) => + new Response( + JSON.stringify({ status: 'failed', message: 'Too many requests' }), + { + status: 429, + headers: { 'Content-Type': 'application/json', ...headers }, + }, + ); + + const rateLimitError = async (headers?: Record) => { + mockFetch(() => rateLimited(headers)); + + const client = new HttpClient(baseConfig); + + return client.get('/v3/task/1').then( + () => { + throw new Error('expected a RateLimitError'); + }, + (error: unknown) => error as RateLimitError, + ); + }; + + it('parses delta-seconds', async () => { + const error = await rateLimitError({ 'Retry-After': '30' }); + + expect(error).toBeInstanceOf(RateLimitError); + expect(error.retryAfterMs).toBe(30_000); + }); + + it('parses an HTTP date', async () => { + const inTenSeconds = new Date(Date.now() + 10_000).toUTCString(); + + const error = await rateLimitError({ 'Retry-After': inTenSeconds }); + + expect(error.retryAfterMs).toBeGreaterThan(8_000); + expect(error.retryAfterMs).toBeLessThanOrEqual(10_000); + }); + + it('is undefined when the header is absent', async () => { + const error = await rateLimitError(); + + expect(error.retryAfterMs).toBeUndefined(); + }); + + it('is undefined when the header is unparseable', async () => { + const error = await rateLimitError({ 'Retry-After': 'soon' }); + + expect(error.retryAfterMs).toBeUndefined(); + }); +}); diff --git a/src/http.ts b/src/http.ts index f9416e4..e21ef2f 100644 --- a/src/http.ts +++ b/src/http.ts @@ -24,6 +24,24 @@ export type HttpClientConfig = { integrationHeader?: string; }; +const parseRetryAfter = (value: string | null): number | undefined => { + if (!value) { + return undefined; + } + + const seconds = Number(value); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const date = Date.parse(value); + if (!Number.isNaN(date)) { + return Math.max(0, date - Date.now()); + } + + return undefined; +}; + export class HttpClient { private readonly baseUrl: string; private readonly authHeader: string; @@ -85,7 +103,10 @@ export class HttpClient { throw new AuthenticationError(message); } if (res.status === 429) { - throw new RateLimitError(message); + throw new RateLimitError( + message, + parseRetryAfter(res.headers.get('retry-after')), + ); } if (res.status === 422 || (res.status === 400 && errorBody?.errors)) { throw new ValidationError(message, errorBody?.errors);