Skip to content
Merged
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
9 changes: 9 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ app.use(cors({
'RateLimit-Limit',
'RateLimit-Remaining',
'RateLimit-Reset',
// Retry-After is set on the 429 response by express-rate-limit
// (standardHeaders: true). It carries the seconds the client
// should wait before retrying — without exposing it across
// CORS, browser JS gets `undefined` and clients have to fall
// back to a fixed-delay retry instead of honoring the value
// the server intended. Retry-After is NOT on the CORS
// safelisted-response-headers list, so explicit exposure is
// required.
'Retry-After',
],
}));

Expand Down
5 changes: 5 additions & 0 deletions tests/api/cors-expose-headers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ beforeAll(() => {
'RateLimit-Limit',
'RateLimit-Remaining',
'RateLimit-Reset',
'Retry-After',
],
}));
app.get('/ping', (req, res) => {
Expand All @@ -64,6 +65,10 @@ describe('CORS Access-Control-Expose-Headers', () => {
'RateLimit-Limit',
'RateLimit-Remaining',
'RateLimit-Reset',
// Retry-After lands on the 429 response from express-rate-
// limit. Not on the CORS safelist, so browser JS clients
// can't read it without explicit exposure here.
'Retry-After',
]));
});

Expand Down
8 changes: 8 additions & 0 deletions tests/api/rate-limit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ describe('rate limiting', () => {
const r3 = await request(app).get('/v1/customer/1').set('authKey', 'k');
expect(r3.status).toBe(429);
expect(r3.body.message).toMatch(/too many requests/i);
// 429 must carry `Retry-After` so browser JS clients can
// honor the suggested back-off instead of falling back to a
// fixed-delay retry. server.js's CORS expose-headers list
// includes it (#322) — this asserts the server-side path
// actually emits it.
expect(r3.headers['retry-after']).toBeDefined();
// Value is a positive integer (seconds, RFC 7231 §7.1.3).
expect(/^\d+$/.test(r3.headers['retry-after'])).toBe(true);
});

test('does not apply to /healthz', async () => {
Expand Down