From 903fca42b3f3c70dec8d436cfbec4e063aaa1b6c Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 19 May 2026 12:58:53 -0500 Subject: [PATCH] fix(cors): expose Retry-After header so browser JS can honor it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit express-rate-limit (standardHeaders: true) sets `Retry-After` on the 429 response — the integer-seconds the client should wait before retrying (RFC 7231 §7.1.3). Browser JS reading the response across CORS can't see headers outside the CORS-safelisted set unless the server explicitly exposes them via `Access-Control-Expose-Headers`. `Retry-After` is NOT on the CORS safelist (only Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma are), so a browser-side `fetch()` getting a 429 reads `headers.get('Retry-After')` as null and falls back to a fixed-delay retry instead of honoring the server's back-off. Add it to the existing exposedHeaders list alongside the other RateLimit-* headers. Update tests: - cors-expose-headers.test.js mirrors the new entry. - rate-limit.test.js asserts the 429 response actually carries `Retry-After` as a positive-integer string (defensive — the server-side path could change without the CORS list catching it). Co-Authored-By: Claude Opus 4.7 (1M context) --- server.js | 9 +++++++++ tests/api/cors-expose-headers.test.js | 5 +++++ tests/api/rate-limit.test.js | 8 ++++++++ 3 files changed, 22 insertions(+) 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 () => {