diff --git a/server.js b/server.js index 5dec9be..264c0f2 100644 --- a/server.js +++ b/server.js @@ -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', ], })); diff --git a/tests/api/cors-expose-headers.test.js b/tests/api/cors-expose-headers.test.js index db28ee9..97676c1 100644 --- a/tests/api/cors-expose-headers.test.js +++ b/tests/api/cors-expose-headers.test.js @@ -39,6 +39,7 @@ beforeAll(() => { 'RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset', + 'Retry-After', ], })); app.get('/ping', (req, res) => { @@ -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', ])); }); diff --git a/tests/api/rate-limit.test.js b/tests/api/rate-limit.test.js index eafbb69..9688966 100644 --- a/tests/api/rate-limit.test.js +++ b/tests/api/rate-limit.test.js @@ -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 () => {