Skip to content

Commit 5846746

Browse files
feat: Handle too_many_requests errors / Retry-After header (#187)
1 parent 0733c22 commit 5846746

File tree

10 files changed

+359
-11
lines changed

10 files changed

+359
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ adding additional type guards.
2727
- Added support for new payment methods `blik`, `mb_way`, `pix` and `upi`. See [related changelog](https://developer.paddle.com/changelog/2025/blik-mbway-payment-methods?utm_source=dx&utm_medium=paddle-node-sdk).
2828
- Non-catalog discounts on Transactions, see [changelog](https://developer.paddle.com/changelog/2025/custom-discounts?utm_source=dx&utm_medium=paddle-node-sdk)
2929
- Support `retained_fee` field on totals objects to show the fees retained by Paddle for the adjustment.
30+
- `ApiError` will now have `retryAfter` property set for [too_many_requests](https://developer.paddle.com/errors/shared/too_many_requests) errors
3031

3132
### Changed
3233

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2091,7 +2091,7 @@ console.log('Archived Business:', archivedBusiness)
20912091

20922092
### Error Handling
20932093

2094-
If a request fails, Paddle returns an `error` object that contains the same information as [errors returned by the API](https://developer.paddle.com/api-reference/about/errors?utm_source=dx&utm_medium=paddle-node-sdk). You can use the `code` attribute to search an error in [the error reference](https://developer.paddle.com/errors/overview?utm_source=dx&utm_medium=paddle-node-sdk) and to handle the error in your app. Validation errors also return an array of `errors` that tell you which fields failed validation.
2094+
If a request fails, Paddle returns an `error` object that contains the same information as [errors returned by the API](https://developer.paddle.com/api-reference/about/errors?utm_source=dx&utm_medium=paddle-node-sdk). You can use the `code` attribute to search an error in [the error reference](https://developer.paddle.com/errors/overview?utm_source=dx&utm_medium=paddle-node-sdk) and to handle the error in your app. Validation errors also return an array of `errors` that tell you which fields failed validation. The `retryAfter` property will be set for `too_many_requests` errors.
20952095

20962096
This example shows how to handle an error with the code `conflict`:
20972097

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { NodeRuntime } from '../../index.esm.node.js';
2+
import { Client } from '../../internal/api/client.js';
3+
import { Environment, ErrorResponse, Response } from '../../internal/index.js';
4+
import { BaseResource } from '../../internal/base/base-resource.js';
5+
import { ApiError } from '../../internal/errors/generic.js';
6+
7+
class TestResource extends BaseResource {
8+
public async trigger(url: string) {
9+
const response = await this.client.get<undefined, Response<object> | ErrorResponse>(url);
10+
return this.handleResponse(response);
11+
}
12+
}
13+
14+
const mockFetch = jest.fn();
15+
16+
describe('BaseResource', () => {
17+
let client: Client;
18+
let resource: TestResource;
19+
20+
beforeAll(() => {
21+
// Mock fetch globally
22+
global.fetch = mockFetch;
23+
});
24+
25+
beforeEach(() => {
26+
NodeRuntime.initialize();
27+
client = new Client('TEST_API_KEY', { environment: Environment.sandbox });
28+
resource = new TestResource(client);
29+
mockFetch.mockClear();
30+
});
31+
32+
test('throws ApiError with retryAfter when Retry-After header present', async () => {
33+
const mockResponse = {
34+
status: 429,
35+
headers: new Map([['Retry-After', '42']]),
36+
json: jest.fn().mockResolvedValue({
37+
error: {
38+
type: 'request_error',
39+
code: 'too_many_requests',
40+
detail:
41+
'IP address exceeded the allowed rate limit. Retry after the number of seconds in the Retry-After header.',
42+
documentation_url: 'https://developer.paddle.com/errors/shared/too_many_requests',
43+
},
44+
meta: { request_id: 'req_1' },
45+
}),
46+
};
47+
48+
mockFetch.mockResolvedValue(mockResponse);
49+
50+
await expect(resource.trigger('/test')).rejects.toBeInstanceOf(ApiError);
51+
52+
try {
53+
await resource.trigger('/test');
54+
} catch (err) {
55+
const e = err as ApiError;
56+
expect(e.type).toBe('request_error');
57+
expect(e.code).toBe('too_many_requests');
58+
expect(e.detail).toBe(
59+
'IP address exceeded the allowed rate limit. Retry after the number of seconds in the Retry-After header.',
60+
);
61+
expect(e.retryAfter).toBe(42);
62+
}
63+
});
64+
65+
test('throws ApiError with undefined retryAfter when header absent', async () => {
66+
const mockResponse = {
67+
status: 409,
68+
headers: new Map(),
69+
json: jest.fn().mockResolvedValue({
70+
error: {
71+
type: 'request_error',
72+
code: 'conflict',
73+
detail: 'Request conflicts with another change.',
74+
documentation_url: 'https://developer.paddle.com/errors/shared/too_many_requests',
75+
},
76+
meta: { request_id: 'req_2' },
77+
}),
78+
};
79+
80+
mockFetch.mockResolvedValue(mockResponse);
81+
82+
await expect(resource.trigger('/test')).rejects.toBeInstanceOf(ApiError);
83+
84+
try {
85+
await resource.trigger('/test');
86+
} catch (err) {
87+
const e = err as ApiError;
88+
expect(e.type).toBe('request_error');
89+
expect(e.code).toBe('conflict');
90+
expect(e.detail).toBe('Request conflicts with another change.');
91+
expect(e.retryAfter).toBeNull();
92+
}
93+
});
94+
95+
test('successful responses pass through handleResponse', async () => {
96+
const mockResponse = {
97+
status: 200,
98+
headers: new Map([['Retry-After', '99']]),
99+
json: jest.fn().mockResolvedValue({
100+
data: { id: 'ok' },
101+
meta: { request_id: 'req_ok' },
102+
}),
103+
};
104+
105+
mockFetch.mockResolvedValue(mockResponse);
106+
107+
const result = await resource.trigger('/ok');
108+
expect(result).toEqual({ id: 'ok' });
109+
});
110+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { NodeRuntime } from '../../index.esm.node.js';
2+
import { Client } from '../../internal/api/client.js';
3+
import { Environment } from '../../internal/index.js';
4+
import { rawResponse } from '../../internal/types/response.js';
5+
6+
const mockFetch = jest.fn();
7+
8+
describe('Client', () => {
9+
let client: Client;
10+
11+
beforeAll(() => {
12+
// Mock fetch globally
13+
global.fetch = mockFetch;
14+
});
15+
16+
beforeEach(() => {
17+
NodeRuntime.initialize();
18+
19+
client = new Client('TEST_API_KEY', { environment: Environment.sandbox });
20+
mockFetch.mockClear();
21+
});
22+
23+
test('should include raw response in GET request', async () => {
24+
const mockResponse = {
25+
status: 200,
26+
headers: new Map([['Retry-After', '60']]),
27+
json: jest.fn().mockResolvedValue({
28+
data: { id: 'test_id' },
29+
meta: { request_id: 'req_123' },
30+
}),
31+
};
32+
33+
mockFetch.mockResolvedValue(mockResponse);
34+
35+
const result = await client.get('/test-endpoint');
36+
37+
expect(result).toEqual({
38+
data: { id: 'test_id' },
39+
meta: { request_id: 'req_123' },
40+
[rawResponse]: mockResponse,
41+
});
42+
});
43+
44+
test('should include raw response in POST request', async () => {
45+
const mockResponse = {
46+
status: 200,
47+
headers: new Map([['Retry-After', '60']]),
48+
json: jest.fn().mockResolvedValue({
49+
data: { id: 'test_id' },
50+
meta: { request_id: 'req_123' },
51+
}),
52+
};
53+
54+
mockFetch.mockResolvedValue(mockResponse);
55+
56+
const result = await client.post('/test-endpoint', { test: 'data' });
57+
58+
expect(result).toEqual({
59+
data: { id: 'test_id' },
60+
meta: { request_id: 'req_123' },
61+
[rawResponse]: mockResponse,
62+
});
63+
});
64+
65+
test('should include raw response in PATCH request', async () => {
66+
const mockResponse = {
67+
status: 200,
68+
headers: new Map([['Retry-After', '60']]),
69+
json: jest.fn().mockResolvedValue({
70+
data: { id: 'test_id' },
71+
meta: { request_id: 'req_123' },
72+
}),
73+
};
74+
75+
mockFetch.mockResolvedValue(mockResponse);
76+
77+
const result = await client.patch('/test-endpoint', { test: 'data' });
78+
79+
expect(result).toEqual({
80+
data: { id: 'test_id' },
81+
meta: { request_id: 'req_123' },
82+
[rawResponse]: mockResponse,
83+
});
84+
});
85+
86+
test('should include raw response in DELETE request', async () => {
87+
const mockResponse = {
88+
status: 204,
89+
headers: new Map([['Retry-After', '60']]),
90+
json: jest.fn().mockResolvedValue({
91+
data: { id: 'test_id' },
92+
meta: { request_id: 'req_123' },
93+
}),
94+
};
95+
96+
mockFetch.mockResolvedValue(mockResponse);
97+
98+
const result = await client.delete('/test-endpoint');
99+
100+
expect(result).toEqual({
101+
data: { id: 'test_id' },
102+
meta: { request_id: 'req_123' },
103+
[rawResponse]: mockResponse,
104+
});
105+
});
106+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { NodeRuntime } from '../../index.esm.node.js';
2+
import { Client } from '../../internal/api/client.js';
3+
import { Environment } from '../../internal/index.js';
4+
import { Collection } from '../../internal/base/collection.js';
5+
import { ApiError } from '../../internal/errors/generic.js';
6+
7+
class TestCollection extends Collection<Record<string, unknown>, Record<string, unknown>> {
8+
fromJson(data: Record<string, unknown>): Record<string, unknown> {
9+
return data;
10+
}
11+
}
12+
13+
const mockFetch = jest.fn();
14+
15+
describe('Collection', () => {
16+
let client: Client;
17+
18+
beforeAll(() => {
19+
// Mock fetch globally
20+
global.fetch = mockFetch;
21+
});
22+
23+
beforeEach(() => {
24+
NodeRuntime.initialize();
25+
client = new Client('TEST_API_KEY', { environment: Environment.sandbox });
26+
mockFetch.mockClear();
27+
});
28+
29+
test('throws ApiError with retryAfter when Retry-After header present (paginated)', async () => {
30+
const mockResponse = {
31+
status: 429,
32+
headers: new Map([['Retry-After', '17']]),
33+
json: jest.fn().mockResolvedValue({
34+
error: {
35+
type: 'request_error',
36+
code: 'too_many_requests',
37+
detail:
38+
'IP address exceeded the allowed rate limit. Retry after the number of seconds in the Retry-After header.',
39+
documentation_url: 'https://developer.paddle.com/errors/shared/too_many_requests',
40+
},
41+
meta: { request_id: 'req_pag_err_1' },
42+
}),
43+
} as unknown as Response;
44+
45+
mockFetch.mockResolvedValue(mockResponse);
46+
47+
const col = new TestCollection(client, '/paginated');
48+
49+
await expect(col.next()).rejects.toBeInstanceOf(ApiError);
50+
51+
try {
52+
await col.next();
53+
} catch (err) {
54+
const e = err as ApiError;
55+
expect(e.type).toBe('request_error');
56+
expect(e.code).toBe('too_many_requests');
57+
expect(e.retryAfter).toBe(17);
58+
}
59+
});
60+
61+
test('throws ApiError with undefined retryAfter when header absent (paginated)', async () => {
62+
const mockResponse = {
63+
status: 429,
64+
headers: new Map(),
65+
json: jest.fn().mockResolvedValue({
66+
error: {
67+
type: 'request_error',
68+
code: 'conflict',
69+
detail: 'Request conflicts with another change.',
70+
documentation_url: 'https://developer.paddle.com/errors/shared/too_many_requests',
71+
},
72+
meta: { request_id: 'req_pag_err_2' },
73+
}),
74+
} as unknown as Response;
75+
76+
mockFetch.mockResolvedValue(mockResponse);
77+
78+
const col = new TestCollection(client, '/paginated');
79+
80+
await expect(col.next()).rejects.toBeInstanceOf(ApiError);
81+
82+
try {
83+
await col.next();
84+
} catch (err) {
85+
const e = err as ApiError;
86+
expect(e.type).toBe('request_error');
87+
expect(e.code).toBe('conflict');
88+
expect(e.retryAfter).toBeNull();
89+
}
90+
});
91+
92+
test('successful paginated response maps data and updates pagination', async () => {
93+
const mockResponse = {
94+
status: 200,
95+
headers: new Map(),
96+
json: jest.fn().mockResolvedValue({
97+
data: [{ id: 'a' }, { id: 'b' }],
98+
meta: {
99+
request_id: 'req_pag_ok',
100+
pagination: {
101+
per_page: 2,
102+
next: '/paginated?page=2',
103+
has_more: true,
104+
estimated_total: 4,
105+
},
106+
},
107+
}),
108+
} as unknown as Response;
109+
110+
mockFetch.mockResolvedValue(mockResponse);
111+
112+
const col = new TestCollection(client, '/paginated');
113+
const result = await col.next();
114+
115+
expect(result).toEqual([{ id: 'a' }, { id: 'b' }]);
116+
expect(col.hasMore).toBe(true);
117+
expect(col.estimatedTotal).toBe(4);
118+
});
119+
});

0 commit comments

Comments
 (0)