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
58 changes: 58 additions & 0 deletions src/client/tokenStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>((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');
Expand Down
26 changes: 24 additions & 2 deletions src/client/tokenStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>,
): Promise<string | undefined> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, 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
Expand Down Expand Up @@ -198,7 +220,7 @@ export class TokenStore {

private async fetchRefreshedToken(): Promise<string | undefined> {
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;

Expand Down Expand Up @@ -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) {
Expand Down
Loading