Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
58 changes: 58 additions & 0 deletions src/http.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<string, string> = {}) =>
new Response(
JSON.stringify({ status: 'failed', message: 'Too many requests' }),
{
status: 429,
headers: { 'Content-Type': 'application/json', ...headers },
},
);

const rateLimitError = async (headers?: Record<string, string>) => {
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();
});
});
23 changes: 22 additions & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading