Skip to content

Commit 76d2b9f

Browse files
committed
feat(send/frontend): handle 429 rate-limit responses gracefully (#1105)
When the backend returns HTTP 429, the app now reads the Retry-After hint, waits that long (bounded to 10s), and retries the request once instead of failing hard or retrying blindly. If it is still limited after the retry, or there is no usable Retry-After, the call reports a distinct rate_limited failure so callers can show a "please wait" message. Pairs with the backend limits from the #1072 milestone.
1 parent b03fc88 commit 76d2b9f

2 files changed

Lines changed: 198 additions & 1 deletion

File tree

packages/send/frontend/src/lib/api.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,44 @@ export function buildApiUrl(serverUrl: string, path: string): string {
7474
return url.toString();
7575
}
7676

77+
// Upper bound on how long we will wait for a Retry-After before giving up on the
78+
// automatic retry. A server should never ask a browser to sit idle for minutes;
79+
// beyond this we surface the rate-limit to the caller instead of blocking.
80+
export const MAX_RETRY_AFTER_MS = 10_000;
81+
82+
function delay(ms: number): Promise<void> {
83+
return new Promise((resolve) => setTimeout(resolve, ms));
84+
}
85+
86+
/**
87+
* Parse an HTTP `Retry-After` header into milliseconds.
88+
*
89+
* Supports both forms the spec allows: a number of seconds (`"3"`) and an
90+
* HTTP-date (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Returns null when the header is
91+
* absent or unparseable, and clamps negatives to 0 (a past date means "now").
92+
*/
93+
export function parseRetryAfterMs(
94+
header: string | null | undefined
95+
): number | null {
96+
if (!header) {
97+
return null;
98+
}
99+
const trimmed = header.trim();
100+
101+
// delta-seconds form.
102+
if (/^\d+$/.test(trimmed)) {
103+
return Number(trimmed) * 1000;
104+
}
105+
106+
// HTTP-date form.
107+
const dateMs = Date.parse(trimmed);
108+
if (!Number.isNaN(dateMs)) {
109+
return Math.max(0, dateMs - Date.now());
110+
}
111+
112+
return null;
113+
}
114+
77115
export class ApiConnection {
78116
serverUrl: string;
79117

@@ -246,6 +284,38 @@ export class ApiConnection {
246284
}
247285
}
248286

287+
// Rate limiting (429): the backend is asking us to slow down, not failing.
288+
// Wait the server-suggested amount (Retry-After) and retry once, rather than
289+
// hammering it or surfacing a hard error. This pairs with the backend limits
290+
// added under the #1072 milestone.
291+
if (resp.status === 429) {
292+
const waitMs = parseRetryAfterMs(resp.headers?.get?.('retry-after'));
293+
// Only back off for a sane, bounded wait. A missing/garbage or absurdly
294+
// long Retry-After should not hang the request behind a multi-minute
295+
// timer; in that case we skip the retry and report it to the caller.
296+
if (waitMs !== null && waitMs <= MAX_RETRY_AFTER_MS) {
297+
await delay(waitMs);
298+
try {
299+
resp = await fetch(url, opts);
300+
} catch (error) {
301+
options?.onFailure?.({ kind: 'network', status: null, error });
302+
return null;
303+
}
304+
}
305+
306+
// Still limited after the single retry (or we chose not to wait): tell the
307+
// caller explicitly so it can show a friendly "please wait" message rather
308+
// than a generic failure.
309+
if (resp.status === 429) {
310+
options?.onFailure?.({
311+
kind: 'rate_limited',
312+
status: 429,
313+
retryAfterMs: waitMs,
314+
});
315+
return null;
316+
}
317+
}
318+
249319
if (!resp.ok) {
250320
// Surface the status/body for the caller's diagnostics before discarding
251321
// the response. Reading the body is safe here because we return null
@@ -281,7 +351,11 @@ export class ApiConnection {
281351
*/
282352
export type ApiCallFailure =
283353
| { kind: 'network'; status: null; error: unknown }
284-
| { kind: 'http'; status: number; statusText: string; body?: string };
354+
| { kind: 'http'; status: number; statusText: string; body?: string }
355+
// The request was rate-limited (HTTP 429). `retryAfterMs` is the wait the
356+
// server suggested, if it sent a usable Retry-After (null otherwise). Callers
357+
// can use it to show a "too many requests, please wait" message.
358+
| { kind: 'rate_limited'; status: 429; retryAfterMs: number | null };
285359

286360
type Options = {
287361
fullResponse?: boolean;

packages/send/frontend/src/test/lib/api.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ApiCallFailure,
99
ApiConnection,
1010
buildApiUrl,
11+
parseRetryAfterMs,
1112
} from '@send-frontend/lib/api';
1213
import { afterEach, describe, expect, it, vi } from 'vitest';
1314

@@ -276,6 +277,128 @@ describe('ApiConnection.call — onFailure diagnostics', () => {
276277
});
277278
});
278279

280+
describe('parseRetryAfterMs', () => {
281+
it('parses delta-seconds into milliseconds', () => {
282+
expect(parseRetryAfterMs('3')).toBe(3000);
283+
expect(parseRetryAfterMs('0')).toBe(0);
284+
});
285+
286+
it('parses an HTTP-date into a wait relative to now', () => {
287+
const twoSecondsOut = new Date(Date.now() + 2000).toUTCString();
288+
const ms = parseRetryAfterMs(twoSecondsOut);
289+
// Allow a little slack for clock/rounding; UTCString drops sub-second parts.
290+
expect(ms).toBeGreaterThanOrEqual(0);
291+
expect(ms).toBeLessThanOrEqual(2000);
292+
});
293+
294+
it('clamps a past date to 0', () => {
295+
const past = new Date(Date.now() - 60_000).toUTCString();
296+
expect(parseRetryAfterMs(past)).toBe(0);
297+
});
298+
299+
it('returns null for missing or unparseable values', () => {
300+
expect(parseRetryAfterMs(null)).toBeNull();
301+
expect(parseRetryAfterMs(undefined)).toBeNull();
302+
expect(parseRetryAfterMs('')).toBeNull();
303+
expect(parseRetryAfterMs('soon')).toBeNull();
304+
});
305+
});
306+
307+
describe('ApiConnection.call — 429 rate limiting (#1105)', () => {
308+
afterEach(() => {
309+
vi.unstubAllGlobals();
310+
vi.restoreAllMocks();
311+
});
312+
313+
it('waits the Retry-After then retries once and returns the retry body', async () => {
314+
let call = 0;
315+
const fetchFn = mockFetch(() => {
316+
call += 1;
317+
if (call === 1) {
318+
return {
319+
ok: false,
320+
status: 429,
321+
headers: { get: (k: string) => (k === 'retry-after' ? '0' : null) },
322+
json: async () => ({ limited: true }),
323+
} as unknown as Response;
324+
}
325+
return {
326+
ok: true,
327+
status: 200,
328+
headers: { get: () => null },
329+
json: async () => ({ ok: true }),
330+
} as unknown as Response;
331+
});
332+
333+
const api = new ApiConnection(SERVER);
334+
const result = await api.call('uploads', {}, 'POST');
335+
336+
expect(fetchFn).toHaveBeenCalledTimes(2); // retried once after backoff
337+
expect(result).toEqual({ ok: true });
338+
});
339+
340+
it('reports kind=rate_limited when still limited after the retry', async () => {
341+
const fetchFn = mockFetch(
342+
() =>
343+
({
344+
ok: false,
345+
status: 429,
346+
headers: { get: (k: string) => (k === 'retry-after' ? '0' : null) },
347+
json: async () => ({ limited: true }),
348+
}) as unknown as Response
349+
);
350+
351+
const api = new ApiConnection(SERVER);
352+
let failure: ApiCallFailure | undefined;
353+
const result = await api.call(
354+
'uploads',
355+
{},
356+
'POST',
357+
{},
358+
{ onFailure: (f) => (failure = f) }
359+
);
360+
361+
expect(fetchFn).toHaveBeenCalledTimes(2); // original + one retry
362+
expect(result).toBeNull();
363+
expect(failure).toEqual({
364+
kind: 'rate_limited',
365+
status: 429,
366+
retryAfterMs: 0,
367+
});
368+
});
369+
370+
it('does not retry when Retry-After is missing, and reports rate_limited', async () => {
371+
const fetchFn = mockFetch(
372+
() =>
373+
({
374+
ok: false,
375+
status: 429,
376+
headers: { get: () => null },
377+
json: async () => ({ limited: true }),
378+
}) as unknown as Response
379+
);
380+
381+
const api = new ApiConnection(SERVER);
382+
let failure: ApiCallFailure | undefined;
383+
const result = await api.call(
384+
'uploads',
385+
{},
386+
'POST',
387+
{},
388+
{ onFailure: (f) => (failure = f) }
389+
);
390+
391+
// No usable Retry-After -> no automatic retry, just surface it.
392+
expect(fetchFn).toHaveBeenCalledTimes(1);
393+
expect(result).toBeNull();
394+
expect(failure).toEqual({
395+
kind: 'rate_limited',
396+
status: 429,
397+
retryAfterMs: null,
398+
});
399+
});
400+
});
401+
279402
describe('ApiConnection.call — x-logout session recovery (#960/#974)', () => {
280403
afterEach(() => {
281404
vi.unstubAllGlobals();

0 commit comments

Comments
 (0)