feat: implement rate limiting handling in JMAPClient #104

This commit is contained in:
Linus Rath
2026-03-27 00:22:40 +01:00
parent 6696636df8
commit a46fb9c8af
17 changed files with 481 additions and 67 deletions
@@ -22,6 +22,16 @@ function mockFetchResponse(status: number, body?: unknown): Response {
});
}
function mockFetchResponseWithHeaders(status: number, headers: Record<string, string>, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: {
'Content-Type': 'application/json',
...headers,
},
});
}
describe('JMAPClient resilience', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
@@ -162,6 +172,36 @@ describe('JMAPClient resilience', () => {
});
});
describe('authenticatedFetch — 429 rate limiting', () => {
it('stops sending authenticated requests until the retry window expires', async () => {
const client = await createConnectedClient();
fetchSpy.mockResolvedValueOnce(
mockFetchResponseWithHeaders(429, { 'Retry-After': '120' }, {
type: 'about:blank',
status: 429,
title: 'Too Many Authentication Attempts',
})
);
await expect(client.ping()).rejects.toThrow('Rate limited by server');
expect(fetchSpy).toHaveBeenCalledTimes(1);
fetchSpy.mockClear();
await expect(client.ping()).rejects.toThrow('Rate limited by server');
expect(fetchSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(120_000);
fetchSpy.mockClear();
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
await expect(client.ping()).resolves.toBeUndefined();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
describe('refreshSession', () => {
it('updates session fields from server response', async () => {
const client = await createConnectedClient();
@@ -260,6 +300,32 @@ describe('JMAPClient resilience', () => {
const trueCall = callback.mock.calls.find((c) => c[0] === true);
expect(trueCall).toBeUndefined();
});
it('does not mark the connection lost or reconnect repeatedly while rate limited', async () => {
const client = await createConnectedClient();
const callback = vi.fn();
client.onConnectionChange(callback);
fetchSpy.mockResolvedValueOnce(
mockFetchResponseWithHeaders(429, { 'Retry-After': '120' }, {
type: 'about:blank',
status: 429,
title: 'Too Many Authentication Attempts',
})
);
await vi.advanceTimersByTimeAsync(30_000);
await vi.advanceTimersByTimeAsync(0);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(callback).not.toHaveBeenCalledWith(false);
fetchSpy.mockClear();
await vi.advanceTimersByTimeAsync(30_000);
await vi.advanceTimersByTimeAsync(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('disconnect', () => {