From 8661f1fe755048749f6a7d0871e660b8292ece39 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 28 May 2026 08:59:24 -0500 Subject: [PATCH 1/4] fix: retry token refresh once on rate limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-pod stateless deployments without session affinity can race on token refresh — multiple pods read the same expired cookie and call authenticateWithRefreshToken simultaneously. WorkOS accepts reused refresh tokens within a grace window (~10-30s), but rate limiting can still reject some of these concurrent calls. Catch RateLimitExceededException inside the existing dedup promise and retry once after the Retry-After delay (clamped 1-10s, default 1s). All concurrent in-process waiters share the single retry result. --- src/core/AuthKitCore.spec.ts | 283 +++++++++++++++++++++++++++++++++++ src/core/AuthKitCore.ts | 31 +++- 2 files changed, 311 insertions(+), 3 deletions(-) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index ae96583..d31caf7 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -1,3 +1,4 @@ +import { RateLimitExceededException } from '@workos-inc/node'; import { AuthKitCore } from './AuthKitCore.js'; import { SessionEncryptionError, TokenRefreshError } from './errors.js'; @@ -404,6 +405,288 @@ describe('AuthKitCore', () => { expect(getCallCount()).toBe(2); vi.useRealTimers(); }); + + it('retries once after a RateLimitExceededException', async () => { + vi.useFakeTimers(); + let callCount = 0; + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + if (callCount === 1) { + throw new RateLimitExceededException('Too Many Requests', 'req_1', 2); + } + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = testCore.refreshTokens('rt-1'); + await vi.advanceTimersByTimeAsync(2000); + const result = await pending; + + expect(callCount).toBe(2); + expect(result.accessToken).toBe(newJwt); + vi.useRealTimers(); + }); + + it('honors retryAfter from the exception', async () => { + vi.useFakeTimers(); + let callCount = 0; + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + if (callCount === 1) { + throw new RateLimitExceededException('Too Many Requests', 'req_1', 5); + } + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = testCore.refreshTokens('rt-1'); + // Advance less than the retryAfter — should not have retried yet + await vi.advanceTimersByTimeAsync(3000); + expect(callCount).toBe(1); + // Advance past retryAfter + await vi.advanceTimersByTimeAsync(2000); + const result = await pending; + + expect(callCount).toBe(2); + expect(result.accessToken).toBe(newJwt); + vi.useRealTimers(); + }); + + it('defaults to 1s delay when retryAfter is null', async () => { + vi.useFakeTimers(); + let callCount = 0; + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + if (callCount === 1) { + throw new RateLimitExceededException('Too Many Requests', 'req_1', null); + } + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = testCore.refreshTokens('rt-1'); + await vi.advanceTimersByTimeAsync(1000); + const result = await pending; + + expect(callCount).toBe(2); + expect(result.accessToken).toBe(newJwt); + vi.useRealTimers(); + }); + + it('throws TokenRefreshError when retry also hits rate limit', async () => { + vi.useFakeTimers(); + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + throw new RateLimitExceededException('Too Many Requests', 'req_1', 1); + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = testCore.refreshTokens('rt-1').catch(e => e); + await vi.advanceTimersByTimeAsync(1000); + const error = await pending; + + expect(error).toBeInstanceOf(TokenRefreshError); + expect(error.message).toContain('after rate-limit retry'); + vi.useRealTimers(); + }); + + it('caps retryAfter at 10s', async () => { + vi.useFakeTimers(); + let callCount = 0; + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + if (callCount === 1) { + throw new RateLimitExceededException('Too Many Requests', 'req_1', 300); + } + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = testCore.refreshTokens('rt-1'); + // Should be capped at 10s, not 300s + await vi.advanceTimersByTimeAsync(10_000); + const result = await pending; + + expect(callCount).toBe(2); + expect(result.accessToken).toBe(newJwt); + vi.useRealTimers(); + }); + + it('defaults to 1s for non-finite retryAfter values', async () => { + vi.useFakeTimers(); + let callCount = 0; + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + if (callCount === 1) { + throw new RateLimitExceededException('Too Many Requests', 'req_1', Infinity as any); + } + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = testCore.refreshTokens('rt-1'); + await vi.advanceTimersByTimeAsync(1000); + const result = await pending; + + expect(callCount).toBe(2); + expect(result.accessToken).toBe(newJwt); + vi.useRealTimers(); + }); + + it('wraps non-rate-limit retry errors correctly', async () => { + vi.useFakeTimers(); + let callCount = 0; + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + if (callCount === 1) { + throw new RateLimitExceededException('Too Many Requests', 'req_1', 1); + } + throw new Error('Network failure'); + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = testCore.refreshTokens('rt-1').catch(e => e); + await vi.advanceTimersByTimeAsync(1000); + const error = await pending; + + expect(callCount).toBe(2); + expect(error).toBeInstanceOf(TokenRefreshError); + expect(error.message).toContain('after rate-limit retry'); + expect(error.cause).toBeInstanceOf(Error); + expect((error.cause as Error).message).toBe('Network failure'); + vi.useRealTimers(); + }); + + it('shares retry result with concurrent dedup waiters', async () => { + vi.useFakeTimers(); + let callCount = 0; + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + await new Promise(r => setTimeout(r, 50)); + if (callCount === 1) { + throw new RateLimitExceededException('Too Many Requests', 'req_1', 1); + } + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = Promise.all([ + testCore.refreshTokens('rt-1'), + testCore.refreshTokens('rt-1'), + testCore.refreshTokens('rt-1'), + ]); + + // First attempt (50ms) + retry delay (1000ms) + retry attempt (50ms) + await vi.advanceTimersByTimeAsync(1100); + const results = await pending; + + expect(callCount).toBe(2); + for (const r of results) { + expect(r.accessToken).toBe(newJwt); + } + vi.useRealTimers(); + }); }); describe('validateAndRefresh()', () => { diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index ff3f9a0..ef6ea6f 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -1,4 +1,9 @@ -import type { Impersonator, User, WorkOS } from '@workos-inc/node'; +import { + RateLimitExceededException, + type Impersonator, + type User, + type WorkOS, +} from '@workos-inc/node'; import { createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose'; import { constantTimeEqual, once } from '../utils.js'; import type { AuthKitConfig } from './config/types.js'; @@ -228,21 +233,41 @@ export class AuthKitCore { this.inflightRefreshes.delete(key); }; const promise = (async () => { - try { + const attempt = async () => { const result = await this.client.userManagement.authenticateWithRefreshToken({ refreshToken, clientId: this.clientId, organizationId, }); - return { accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user, impersonator: result.impersonator, }; + }; + + try { + return await attempt(); } catch (error) { + if (error instanceof RateLimitExceededException) { + const raw = error.retryAfter; + const delaySec = + typeof raw === 'number' && Number.isFinite(raw) && raw > 0 + ? Math.min(raw, 10) + : 1; + await new Promise((r) => setTimeout(r, delaySec * 1000)); + try { + return await attempt(); + } catch (retryError) { + throw new TokenRefreshError( + 'Failed to refresh tokens after rate-limit retry', + retryError, + context, + ); + } + } throw new TokenRefreshError('Failed to refresh tokens', error, context); } })(); From 3a215c27c1f0850172b3896c5cabd671e40cd022 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 28 May 2026 10:49:52 -0500 Subject: [PATCH 2/4] fix: clamp retryAfter and apply formatting Validate retryAfter is finite and positive, cap at 10s to prevent holding the dedup entry too long. Default to 1s for null/non-finite values. Add tests for edge cases: large values, Infinity, and non-rate-limit errors on retry. Run oxfmt. --- src/core/AuthKitCore.spec.ts | 48 ++++++++++++++++++++++++++++++------ src/core/AuthKitCore.ts | 2 +- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index d31caf7..39819f0 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -415,7 +415,11 @@ describe('AuthKitCore', () => { authenticateWithRefreshToken: async () => { callCount++; if (callCount === 1) { - throw new RateLimitExceededException('Too Many Requests', 'req_1', 2); + throw new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + 2, + ); } return { accessToken: newJwt, @@ -450,7 +454,11 @@ describe('AuthKitCore', () => { authenticateWithRefreshToken: async () => { callCount++; if (callCount === 1) { - throw new RateLimitExceededException('Too Many Requests', 'req_1', 5); + throw new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + 5, + ); } return { accessToken: newJwt, @@ -489,7 +497,11 @@ describe('AuthKitCore', () => { authenticateWithRefreshToken: async () => { callCount++; if (callCount === 1) { - throw new RateLimitExceededException('Too Many Requests', 'req_1', null); + throw new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + null, + ); } return { accessToken: newJwt, @@ -521,7 +533,11 @@ describe('AuthKitCore', () => { userManagement: { getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', authenticateWithRefreshToken: async () => { - throw new RateLimitExceededException('Too Many Requests', 'req_1', 1); + throw new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + 1, + ); }, }, }; @@ -549,7 +565,11 @@ describe('AuthKitCore', () => { authenticateWithRefreshToken: async () => { callCount++; if (callCount === 1) { - throw new RateLimitExceededException('Too Many Requests', 'req_1', 300); + throw new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + 300, + ); } return { accessToken: newJwt, @@ -585,7 +605,11 @@ describe('AuthKitCore', () => { authenticateWithRefreshToken: async () => { callCount++; if (callCount === 1) { - throw new RateLimitExceededException('Too Many Requests', 'req_1', Infinity as any); + throw new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + Infinity as any, + ); } return { accessToken: newJwt, @@ -620,7 +644,11 @@ describe('AuthKitCore', () => { authenticateWithRefreshToken: async () => { callCount++; if (callCount === 1) { - throw new RateLimitExceededException('Too Many Requests', 'req_1', 1); + throw new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + 1, + ); } throw new Error('Network failure'); }, @@ -654,7 +682,11 @@ describe('AuthKitCore', () => { callCount++; await new Promise(r => setTimeout(r, 50)); if (callCount === 1) { - throw new RateLimitExceededException('Too Many Requests', 'req_1', 1); + throw new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + 1, + ); } return { accessToken: newJwt, diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index ef6ea6f..771b0d9 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -257,7 +257,7 @@ export class AuthKitCore { typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? Math.min(raw, 10) : 1; - await new Promise((r) => setTimeout(r, delaySec * 1000)); + await new Promise(r => setTimeout(r, delaySec * 1000)); try { return await attempt(); } catch (retryError) { From 77a32560c81d5bdc36ccc501d31bc6cb5daadb72 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 28 May 2026 10:57:15 -0500 Subject: [PATCH 3/4] fix: preserve original rate-limit error in cause chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the retry also fails, attach the original RateLimitExceededException as the cause of retryError so the full sequence is visible: TokenRefreshError → retryError → RateLimitExceededException (original). Addresses Greptile review feedback on PR #39. --- src/core/AuthKitCore.spec.ts | 9 +++++++++ src/core/AuthKitCore.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index 39819f0..ae7a186 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -553,6 +553,11 @@ describe('AuthKitCore', () => { expect(error).toBeInstanceOf(TokenRefreshError); expect(error.message).toContain('after rate-limit retry'); + // Cause chain: TokenRefreshError → RateLimitExceededException (retry) → RateLimitExceededException (original) + expect(error.cause).toBeInstanceOf(RateLimitExceededException); + expect((error.cause as any).cause).toBeInstanceOf( + RateLimitExceededException, + ); vi.useRealTimers(); }); @@ -669,6 +674,10 @@ describe('AuthKitCore', () => { expect(error.message).toContain('after rate-limit retry'); expect(error.cause).toBeInstanceOf(Error); expect((error.cause as Error).message).toBe('Network failure'); + // Original rate-limit error preserved in chain + expect((error.cause as Error).cause).toBeInstanceOf( + RateLimitExceededException, + ); vi.useRealTimers(); }); diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index 771b0d9..66dc1fa 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -261,6 +261,9 @@ export class AuthKitCore { try { return await attempt(); } catch (retryError) { + if (retryError instanceof Error && !retryError.cause) { + retryError.cause = error; + } throw new TokenRefreshError( 'Failed to refresh tokens after rate-limit retry', retryError, From 804c0762aba0fbbc87f536ccc24f2e99b0d149b1 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 4 Jun 2026 13:12:10 -0500 Subject: [PATCH 4/4] fix: enforce 1s floor on rate-limit retry delay A retryAfter below 1 (e.g. 0.3) previously passed through as a sub-second delay, contradicting the documented 1-10s clamp. Apply Math.max(1, ...) so the lower bound matches the upper bound. Also extract a createRateLimitClient test factory, collapsing eight duplicated inline mocks in the rate-limit suite (review feedback). --- src/core/AuthKitCore.spec.ts | 264 +++++++++++++---------------------- src/core/AuthKitCore.ts | 2 +- 2 files changed, 95 insertions(+), 171 deletions(-) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index ae7a186..47ff52c 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -79,6 +79,45 @@ function makeCountingClient(opts?: { fail?: () => boolean }) { return { client, getCallCount: () => callCount }; } +/** + * Builds a userManagement client whose first refresh attempt throws a + * RateLimitExceededException. By default the retry succeeds; `onRetry` can make + * the retry throw another rate-limit error or an arbitrary error instead. + */ +function createRateLimitClient(opts?: { + retryAfter?: number | null; + delayMs?: number; + onRetry?: 'succeed' | 'rateLimit' | Error; +}) { + const { retryAfter = 1, delayMs = 0, onRetry = 'succeed' } = opts ?? {}; + let callCount = 0; + const rateLimit = () => + new RateLimitExceededException( + 'Too Many Requests', + 'req_1', + retryAfter as any, + ); + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + if (delayMs) await new Promise(r => setTimeout(r, delayMs)); + if (callCount === 1) throw rateLimit(); + if (onRetry === 'rateLimit') throw rateLimit(); + if (onRetry instanceof Error) throw onRetry; + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + return { client, getCallCount: () => callCount }; +} + describe('AuthKitCore', () => { let core: AuthKitCore; @@ -408,28 +447,7 @@ describe('AuthKitCore', () => { it('retries once after a RateLimitExceededException', async () => { vi.useFakeTimers(); - let callCount = 0; - const client = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - if (callCount === 1) { - throw new RateLimitExceededException( - 'Too Many Requests', - 'req_1', - 2, - ); - } - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = createRateLimitClient({ retryAfter: 2 }); const testCore = new AuthKitCore( mockConfig as any, client as any, @@ -440,35 +458,14 @@ describe('AuthKitCore', () => { await vi.advanceTimersByTimeAsync(2000); const result = await pending; - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); expect(result.accessToken).toBe(newJwt); vi.useRealTimers(); }); it('honors retryAfter from the exception', async () => { vi.useFakeTimers(); - let callCount = 0; - const client = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - if (callCount === 1) { - throw new RateLimitExceededException( - 'Too Many Requests', - 'req_1', - 5, - ); - } - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = createRateLimitClient({ retryAfter: 5 }); const testCore = new AuthKitCore( mockConfig as any, client as any, @@ -478,40 +475,21 @@ describe('AuthKitCore', () => { const pending = testCore.refreshTokens('rt-1'); // Advance less than the retryAfter — should not have retried yet await vi.advanceTimersByTimeAsync(3000); - expect(callCount).toBe(1); + expect(getCallCount()).toBe(1); // Advance past retryAfter await vi.advanceTimersByTimeAsync(2000); const result = await pending; - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); expect(result.accessToken).toBe(newJwt); vi.useRealTimers(); }); it('defaults to 1s delay when retryAfter is null', async () => { vi.useFakeTimers(); - let callCount = 0; - const client = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - if (callCount === 1) { - throw new RateLimitExceededException( - 'Too Many Requests', - 'req_1', - null, - ); - } - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = createRateLimitClient({ + retryAfter: null, + }); const testCore = new AuthKitCore( mockConfig as any, client as any, @@ -522,25 +500,17 @@ describe('AuthKitCore', () => { await vi.advanceTimersByTimeAsync(1000); const result = await pending; - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); expect(result.accessToken).toBe(newJwt); vi.useRealTimers(); }); it('throws TokenRefreshError when retry also hits rate limit', async () => { vi.useFakeTimers(); - const client = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - throw new RateLimitExceededException( - 'Too Many Requests', - 'req_1', - 1, - ); - }, - }, - }; + const { client } = createRateLimitClient({ + retryAfter: 1, + onRetry: 'rateLimit', + }); const testCore = new AuthKitCore( mockConfig as any, client as any, @@ -563,28 +533,9 @@ describe('AuthKitCore', () => { it('caps retryAfter at 10s', async () => { vi.useFakeTimers(); - let callCount = 0; - const client = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - if (callCount === 1) { - throw new RateLimitExceededException( - 'Too Many Requests', - 'req_1', - 300, - ); - } - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = createRateLimitClient({ + retryAfter: 300, + }); const testCore = new AuthKitCore( mockConfig as any, client as any, @@ -596,35 +547,40 @@ describe('AuthKitCore', () => { await vi.advanceTimersByTimeAsync(10_000); const result = await pending; - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); + expect(result.accessToken).toBe(newJwt); + vi.useRealTimers(); + }); + + it('clamps sub-1s retryAfter up to a 1s floor', async () => { + vi.useFakeTimers(); + const { client, getCallCount } = createRateLimitClient({ + retryAfter: 0.3, + }); + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const pending = testCore.refreshTokens('rt-1'); + // 0.3s would be enough without a floor — the retry must NOT have fired yet + await vi.advanceTimersByTimeAsync(300); + expect(getCallCount()).toBe(1); + // Past the 1s floor — retry fires + await vi.advanceTimersByTimeAsync(700); + const result = await pending; + + expect(getCallCount()).toBe(2); expect(result.accessToken).toBe(newJwt); vi.useRealTimers(); }); it('defaults to 1s for non-finite retryAfter values', async () => { vi.useFakeTimers(); - let callCount = 0; - const client = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - if (callCount === 1) { - throw new RateLimitExceededException( - 'Too Many Requests', - 'req_1', - Infinity as any, - ); - } - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = createRateLimitClient({ + retryAfter: Infinity, + }); const testCore = new AuthKitCore( mockConfig as any, client as any, @@ -635,30 +591,17 @@ describe('AuthKitCore', () => { await vi.advanceTimersByTimeAsync(1000); const result = await pending; - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); expect(result.accessToken).toBe(newJwt); vi.useRealTimers(); }); it('wraps non-rate-limit retry errors correctly', async () => { vi.useFakeTimers(); - let callCount = 0; - const client = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - if (callCount === 1) { - throw new RateLimitExceededException( - 'Too Many Requests', - 'req_1', - 1, - ); - } - throw new Error('Network failure'); - }, - }, - }; + const { client, getCallCount } = createRateLimitClient({ + retryAfter: 1, + onRetry: new Error('Network failure'), + }); const testCore = new AuthKitCore( mockConfig as any, client as any, @@ -669,7 +612,7 @@ describe('AuthKitCore', () => { await vi.advanceTimersByTimeAsync(1000); const error = await pending; - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); expect(error).toBeInstanceOf(TokenRefreshError); expect(error.message).toContain('after rate-limit retry'); expect(error.cause).toBeInstanceOf(Error); @@ -683,29 +626,10 @@ describe('AuthKitCore', () => { it('shares retry result with concurrent dedup waiters', async () => { vi.useFakeTimers(); - let callCount = 0; - const client = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - await new Promise(r => setTimeout(r, 50)); - if (callCount === 1) { - throw new RateLimitExceededException( - 'Too Many Requests', - 'req_1', - 1, - ); - } - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = createRateLimitClient({ + retryAfter: 1, + delayMs: 50, + }); const testCore = new AuthKitCore( mockConfig as any, client as any, @@ -722,7 +646,7 @@ describe('AuthKitCore', () => { await vi.advanceTimersByTimeAsync(1100); const results = await pending; - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); for (const r of results) { expect(r.accessToken).toBe(newJwt); } diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index 66dc1fa..168a02a 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -255,7 +255,7 @@ export class AuthKitCore { const raw = error.retryAfter; const delaySec = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 - ? Math.min(raw, 10) + ? Math.max(1, Math.min(raw, 10)) : 1; await new Promise(r => setTimeout(r, delaySec * 1000)); try {