From 0f4bfda1bd83b5fc8ab47c17f8f462571613e2c4 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Sun, 8 Nov 2026 20:29:19 -0400 Subject: [PATCH] fix(client): bound stalled token requests with a timeout --- src/client/tokenStore.spec.ts | 58 +++++++++++++++++++++++++++++++++++ src/client/tokenStore.ts | 26 ++++++++++++++-- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/client/tokenStore.spec.ts b/src/client/tokenStore.spec.ts index 5db0a7b..d884374 100644 --- a/src/client/tokenStore.spec.ts +++ b/src/client/tokenStore.spec.ts @@ -322,6 +322,64 @@ describe('TokenStore', () => { }); }); + describe('request timeouts', () => { + it.each(['get', 'refresh'] as const)('bounds a hung %s action and allows a retry', async (action) => { + const mockAction = vi.mocked(action === 'get' ? getAccessTokenAction : refreshAccessTokenAction); + let signal: AbortSignal | undefined; + let resolveLate!: (token: string) => void; + mockAction.mockImplementationOnce((options) => { + signal = options?.signal; + // Deliberately ignore cancellation to exercise the timeout rejection itself. + return new Promise((resolve) => { + resolveLate = resolve; + }); + }); + const request = () => (action === 'get' ? store.getAccessToken() : store.refreshToken()); + const results = Promise.allSettled([request(), request(), request()]); + + expect(mockAction).toHaveBeenCalledTimes(1); + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(7_999); + expect(store.isRefreshing()).toBe(true); + await vi.advanceTimersByTimeAsync(1); + + for (const result of await results) { + expect(result.status).toBe('rejected'); + if (result.status === 'rejected') { + expect(result.reason.message).toBe('Token refresh timed out after 8000 ms'); + } + } + expect(signal?.aborted).toBe(true); + expect(store.isRefreshing()).toBe(false); + expect(store.getSnapshot().loading).toBe(false); + expect(store.getSnapshot().error?.message).toBe('Token refresh timed out after 8000 ms'); + + mockAction.mockResolvedValueOnce('new-token'); + await expect(request()).resolves.toBe('new-token'); + expect(mockAction).toHaveBeenCalledTimes(2); + resolveLate('stale-token'); + await Promise.resolve(); + expect(store.getSnapshot().token).toBe('new-token'); + expect(store.getSnapshot().error).toBeNull(); + store.reset(); + }); + + it.each(['success', 'failure'] as const)('clears the deadline after %s', async (outcome) => { + let signal: AbortSignal | undefined; + vi.mocked(refreshAccessTokenAction).mockImplementationOnce(async (options) => { + signal = options?.signal; + if (outcome === 'failure') throw new Error('Network failure'); + return 'token'; + }); + await store.refreshToken().catch(() => {}); + // A failure schedules the existing retry timer; reset removes that timer. + store.reset(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(8_000); + expect(signal?.aborted).toBe(false); + }); + }); + describe('refresh error handling', () => { it('converts non-Error to Error on failure', async () => { vi.mocked(refreshAccessTokenAction).mockRejectedValue('string error'); diff --git a/src/client/tokenStore.ts b/src/client/tokenStore.ts index fc98d23..80acb8c 100644 --- a/src/client/tokenStore.ts +++ b/src/client/tokenStore.ts @@ -13,6 +13,28 @@ const SHORT_TOKEN_EXPIRY_BUFFER_SECONDS = 30; const MIN_REFRESH_DELAY_SECONDS = 15; const MAX_REFRESH_DELAY_SECONDS = 24 * 60 * 60; const RETRY_DELAY_SECONDS = 300; +const REFRESH_TIMEOUT_MS = 8_000; + +async function withRefreshTimeout( + action: (options: { signal: AbortSignal }) => Promise, +): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error(`Token refresh timed out after ${REFRESH_TIMEOUT_MS} ms`); + controller.abort(error); + reject(error); + }, REFRESH_TIMEOUT_MS); + }); + + try { + // Reject even if the action ignores cancellation, so callers can retry. + return await Promise.race([action({ signal: controller.signal }), timeout]); + } finally { + clearTimeout(timer); + } +} function getExpiryBuffer(totalTokenLifetime: number): number { return totalTokenLifetime <= SHORT_TOKEN_LIFETIME_SECONDS @@ -198,7 +220,7 @@ export class TokenStore { private async fetchRefreshedToken(): Promise { const requestedAt = Math.floor(Date.now() / 1000); - const token = await refreshAccessTokenAction(); + const token = await withRefreshTimeout(refreshAccessTokenAction); this.unverifiedToken = undefined; const iat = this.parseToken(token)?.payload.iat; @@ -234,7 +256,7 @@ export class TokenStore { token = await this.fetchRefreshedToken(); } else { if (!previousToken) { - token = await getAccessTokenAction(); + token = await withRefreshTimeout(getAccessTokenAction); const tokenData = this.parseToken(token); if (token && token !== previousToken) {