From 78f3f460e9fb940e65d7ea15f4b0116eb7f38021 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 8 May 2026 18:12:08 +0000 Subject: [PATCH 1/3] fix: handle CloudFormation throttling in import gateway polling Adds a shared poll() utility with throttle-aware retry and migrates phase2-import.ts to use it. Previously, Rate exceeded errors from CloudFormation during concurrent e2e tests would crash the import operation. Now throttle errors are retried on the next poll iteration. Fixes: import-gateway e2e test failures under parallel execution --- src/cli/commands/import/phase2-import.ts | 88 +++++------- src/lib/utils/__tests__/polling.test.ts | 176 +++++++++++++++++++++++ src/lib/utils/index.ts | 1 + src/lib/utils/polling.ts | 107 ++++++++++++++ 4 files changed, 320 insertions(+), 52 deletions(-) create mode 100644 src/lib/utils/__tests__/polling.test.ts create mode 100644 src/lib/utils/polling.ts diff --git a/src/cli/commands/import/phase2-import.ts b/src/cli/commands/import/phase2-import.ts index d898785f3..5db8c7ac8 100644 --- a/src/cli/commands/import/phase2-import.ts +++ b/src/cli/commands/import/phase2-import.ts @@ -1,3 +1,4 @@ +import { isThrottlingError, poll } from '../../../lib/utils/polling'; import { getCredentialProvider } from '../../aws/account'; import type { CfnTemplate } from './template-utils'; import { buildImportTemplate } from './template-utils'; @@ -141,64 +142,47 @@ async function waitForChangeSetReady( stackName: string, changeSetName: string ): Promise { - const maxAttempts = 60; - const delay = 5000; // 5 seconds - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const response = await cfn.send( - new DescribeChangeSetCommand({ - StackName: stackName, - ChangeSetName: changeSetName, - }) - ); - - const status = response.Status; - - if (status === 'CREATE_COMPLETE') { - return; - } - - if (status === 'FAILED') { - throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`); - } - - // CREATE_PENDING, CREATE_IN_PROGRESS — keep waiting - await new Promise(resolve => setTimeout(resolve, delay)); - } - - throw new Error('Timed out waiting for change set creation'); + await poll({ + fn: async () => { + const response = await cfn.send( + new DescribeChangeSetCommand({ + StackName: stackName, + ChangeSetName: changeSetName, + }) + ); + const status = response.Status; + if (status === 'CREATE_COMPLETE') return { done: true, value: undefined }; + if (status === 'FAILED') { + throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`); + } + return { done: false }; + }, + maxAttempts: 60, + delayMs: 5000, + onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'), + }); } /** * Wait for stack to reach IMPORT_COMPLETE status. */ async function waitForStackImportComplete(cfn: CloudFormationClient, stackName: string): Promise { - const maxAttempts = 120; - const delay = 5000; // 5 seconds - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); - const stack = response.Stacks?.[0]; - - if (!stack) { - throw new Error(`Stack ${stackName} not found during import wait`); - } - - const status = stack.StackStatus ?? ''; - - if (status === 'IMPORT_COMPLETE') { - return; - } - - if (status.includes('FAILED') || status.includes('ROLLBACK')) { - throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`); - } - - // IMPORT_IN_PROGRESS — keep waiting - await new Promise(resolve => setTimeout(resolve, delay)); - } - - throw new Error('Timed out waiting for import to complete'); + await poll({ + fn: async () => { + const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); + const stack = response.Stacks?.[0]; + if (!stack) throw new Error(`Stack ${stackName} not found during import wait`); + const status = stack.StackStatus ?? ''; + if (status === 'IMPORT_COMPLETE') return { done: true, value: undefined }; + if (status.includes('FAILED') || status.includes('ROLLBACK')) { + throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`); + } + return { done: false }; + }, + maxAttempts: 120, + delayMs: 5000, + onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'), + }); } /** diff --git a/src/lib/utils/__tests__/polling.test.ts b/src/lib/utils/__tests__/polling.test.ts new file mode 100644 index 000000000..9b8b0bbda --- /dev/null +++ b/src/lib/utils/__tests__/polling.test.ts @@ -0,0 +1,176 @@ +import { PollExhaustedError, PollTimeoutError, isThrottlingError, poll } from '../polling.js'; +import { describe, expect, it, vi } from 'vitest'; + +/* eslint-disable @typescript-eslint/require-await */ + +describe('poll', () => { + it('returns immediately on first success', async () => { + const result = await poll({ fn: async () => ({ done: true, value: 42 }), maxAttempts: 5 }); + expect(result).toBe(42); + }); + + it('polls until success', async () => { + let count = 0; + const result = await poll({ + fn: async () => { + count++; + return count === 3 ? { done: true, value: 'ok' } : { done: false }; + }, + maxAttempts: 5, + delayMs: 1, + }); + expect(result).toBe('ok'); + expect(count).toBe(3); + }); + + it('throws PollExhaustedError when maxAttempts exceeded', async () => { + await expect(poll({ fn: async () => ({ done: false }), maxAttempts: 3, delayMs: 1 })).rejects.toThrow( + PollExhaustedError + ); + }); + + it('throws PollTimeoutError when timeout exceeded', async () => { + await expect(poll({ fn: async () => ({ done: false }), timeoutMs: 50, delayMs: 20 })).rejects.toThrow( + PollTimeoutError + ); + }); + + it('applies exponential backoff', async () => { + vi.useFakeTimers(); + let count = 0; + const promise = poll({ + fn: async () => { + count++; + return count === 4 ? { done: true, value: 'done' } : { done: false }; + }, + maxAttempts: 5, + delayMs: 100, + backoffFactor: 2, + }); + // Advance through iterations + await vi.advanceTimersByTimeAsync(100); // 1st delay: 100 + await vi.advanceTimersByTimeAsync(200); // 2nd delay: 200 + await vi.advanceTimersByTimeAsync(400); // 3rd delay: 400 + const result = await promise; + expect(result).toBe('done'); + vi.useRealTimers(); + }); + + it('caps delay at maxDelayMs', async () => { + vi.useFakeTimers(); + let count = 0; + const promise = poll({ + fn: async () => { + count++; + return count === 4 ? { done: true, value: 'done' } : { done: false }; + }, + maxAttempts: 5, + delayMs: 100, + backoffFactor: 10, + maxDelayMs: 500, + }); + await vi.advanceTimersByTimeAsync(100); // 1st: 100 + await vi.advanceTimersByTimeAsync(500); // 2nd: capped at 500 + await vi.advanceTimersByTimeAsync(500); // 3rd: capped at 500 + const result = await promise; + expect(result).toBe('done'); + vi.useRealTimers(); + }); + + it('retries on error by default', async () => { + let count = 0; + const result = await poll({ + fn: async () => { + count++; + if (count < 3) throw new Error('transient'); + return { done: true, value: 'ok' }; + }, + maxAttempts: 5, + delayMs: 1, + }); + expect(result).toBe('ok'); + expect(count).toBe(3); + }); + + it('aborts on error when onError returns abort', async () => { + const err = new Error('fatal'); + await expect( + poll({ + fn: async () => { + throw err; + }, + maxAttempts: 5, + delayMs: 1, + onError: () => 'abort', + }) + ).rejects.toThrow('fatal'); + }); + + it('throws PollExhaustedError after maxConsecutiveErrors', async () => { + await expect( + poll({ + fn: async () => { + throw new Error('fail'); + }, + maxAttempts: 10, + delayMs: 1, + maxConsecutiveErrors: 3, + }) + ).rejects.toThrow(PollExhaustedError); + }); + + it('resets consecutive error count on success', async () => { + let count = 0; + const result = await poll({ + fn: async () => { + count++; + if (count === 1) throw new Error('err1'); + if (count === 2) throw new Error('err2'); + if (count === 3) return { done: false }; // success resets counter + if (count === 4) throw new Error('err3'); + if (count === 5) throw new Error('err4'); + return { done: true, value: 'ok' }; + }, + maxAttempts: 10, + delayMs: 1, + maxConsecutiveErrors: 3, + }); + expect(result).toBe('ok'); + }); + + it('throws if neither maxAttempts nor timeoutMs provided', async () => { + await expect(poll({ fn: async () => ({ done: true, value: 1 }) })).rejects.toThrow( + 'poll() requires at least one of maxAttempts or timeoutMs' + ); + }); + + it('supports both maxAttempts and timeoutMs together', async () => { + // maxAttempts hit first + await expect( + poll({ fn: async () => ({ done: false }), maxAttempts: 2, timeoutMs: 10000, delayMs: 1 }) + ).rejects.toThrow(PollExhaustedError); + }); +}); + +describe('isThrottlingError', () => { + it('detects ThrottlingException by name', () => { + expect(isThrottlingError({ name: 'ThrottlingException', message: '' })).toBe(true); + }); + + it('detects Rate exceeded in message', () => { + expect(isThrottlingError(new Error('Rate exceeded'))).toBe(true); + }); + + it('detects TooManyRequestsException', () => { + expect(isThrottlingError({ name: 'TooManyRequestsException', message: '' })).toBe(true); + }); + + it('returns false for non-throttle errors', () => { + expect(isThrottlingError(new Error('Stack not found'))).toBe(false); + }); + + it('returns false for null/undefined', () => { + expect(isThrottlingError(null)).toBe(false); + expect(isThrottlingError(undefined)).toBe(false); + }); +}); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index d595d9bf7..8902fca44 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -11,4 +11,5 @@ export { } from './subprocess'; export { parseTimeString } from './time-parser'; export { parseJsonRpcResponse } from './json-rpc'; +export { poll, isThrottlingError, PollTimeoutError, PollExhaustedError } from './polling'; export { validateAgentSchema, validateProjectSchema } from './zod'; diff --git a/src/lib/utils/polling.ts b/src/lib/utils/polling.ts new file mode 100644 index 000000000..aabbf2c43 --- /dev/null +++ b/src/lib/utils/polling.ts @@ -0,0 +1,107 @@ +/** + * Shared polling/retry utility for async operations. + */ + +export type PollResult = { done: true; value: T } | { done: false }; + +export interface PollOptions { + /** Async function called each iteration. Return {done: true, value} when complete, {done: false} to keep polling. */ + fn: () => Promise>; + /** Max number of attempts before throwing PollExhaustedError. */ + maxAttempts?: number; + /** Max total time in ms before throwing PollTimeoutError. */ + timeoutMs?: number; + /** Delay between iterations in ms. Default 5000. */ + delayMs?: number; + /** Multiply delay by this factor each iteration. Default 1 (fixed). */ + backoffFactor?: number; + /** Cap on delay in ms. */ + maxDelayMs?: number; + /** Abort after this many consecutive errors. */ + maxConsecutiveErrors?: number; + /** Called when fn throws. Return 'retry' to continue or 'abort' to rethrow. Default: 'retry'. */ + onError?: (err: unknown) => 'retry' | 'abort'; +} + +export class PollTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Polling timed out after ${timeoutMs}ms`); + this.name = 'PollTimeoutError'; + } +} + +export class PollExhaustedError extends Error { + constructor(maxAttempts: number) { + super(`Polling exhausted after ${maxAttempts} attempts`); + this.name = 'PollExhaustedError'; + } +} + +export async function poll(options: PollOptions): Promise { + const { + fn, + maxAttempts, + timeoutMs, + delayMs = 5000, + backoffFactor = 1, + maxDelayMs, + maxConsecutiveErrors, + onError, + } = options; + + if (maxAttempts === undefined && timeoutMs === undefined) { + throw new Error('poll() requires at least one of maxAttempts or timeoutMs'); + } + + const start = Date.now(); + let attempts = 0; + let consecutiveErrors = 0; + let currentDelay = delayMs; + + while (true) { + if (maxAttempts !== undefined && attempts >= maxAttempts) { + throw new PollExhaustedError(maxAttempts); + } + if (timeoutMs !== undefined && Date.now() - start >= timeoutMs) { + throw new PollTimeoutError(timeoutMs); + } + + attempts++; + + try { + const result = await fn(); + consecutiveErrors = 0; + if (result.done) return result.value; + } catch (err: unknown) { + const action = onError ? onError(err) : 'retry'; + if (action === 'abort') throw err; + consecutiveErrors++; + if (maxConsecutiveErrors && consecutiveErrors >= maxConsecutiveErrors) { + throw new PollExhaustedError(maxConsecutiveErrors); + } + } + + // Don't sleep if we're about to exceed timeout + if (timeoutMs !== undefined && Date.now() - start + currentDelay >= timeoutMs) { + throw new PollTimeoutError(timeoutMs); + } + + await new Promise(resolve => setTimeout(resolve, currentDelay)); + currentDelay = maxDelayMs ? Math.min(currentDelay * backoffFactor, maxDelayMs) : currentDelay * backoffFactor; + } +} + +/** Check if an error is an AWS throttling/rate-limit error. */ +export function isThrottlingError(err: unknown): boolean { + if (err == null || typeof err !== 'object') return false; + const name = (err as { name?: string }).name ?? ''; + const message = (err as { message?: string }).message ?? ''; + return ( + name === 'ThrottlingException' || + name === 'Throttling' || + name === 'TooManyRequestsException' || + name === 'RequestLimitExceeded' || + message.includes('Rate exceeded') || + message.includes('Throttling') + ); +} From 39164a313e0762764248d04ede3485feb954f5a9 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 8 May 2026 18:27:50 +0000 Subject: [PATCH 2/3] fix: preserve lastError as cause in poll errors, add domain error wrapping Addresses PR review feedback: - PollExhaustedError and PollTimeoutError now include the last error as `cause` for debuggability (e.g., shows 'Rate exceeded' when throttling exhausts retries) - phase2-import.ts wraps poll errors with operation-specific messages ('Timed out waiting for change set creation') preserving original error as cause - Fixed misleading message when maxConsecutiveErrors triggers (now reports actual attempt count) - Added 3 tests verifying cause propagation --- src/cli/commands/import/phase2-import.ts | 86 ++++++++++++++---------- src/lib/utils/__tests__/polling.test.ts | 36 ++++++++++ src/lib/utils/polling.ts | 18 ++--- 3 files changed, 96 insertions(+), 44 deletions(-) diff --git a/src/cli/commands/import/phase2-import.ts b/src/cli/commands/import/phase2-import.ts index 5db8c7ac8..c980110bd 100644 --- a/src/cli/commands/import/phase2-import.ts +++ b/src/cli/commands/import/phase2-import.ts @@ -1,4 +1,4 @@ -import { isThrottlingError, poll } from '../../../lib/utils/polling'; +import { PollExhaustedError, PollTimeoutError, isThrottlingError, poll } from '../../../lib/utils/polling'; import { getCredentialProvider } from '../../aws/account'; import type { CfnTemplate } from './template-utils'; import { buildImportTemplate } from './template-utils'; @@ -142,47 +142,61 @@ async function waitForChangeSetReady( stackName: string, changeSetName: string ): Promise { - await poll({ - fn: async () => { - const response = await cfn.send( - new DescribeChangeSetCommand({ - StackName: stackName, - ChangeSetName: changeSetName, - }) - ); - const status = response.Status; - if (status === 'CREATE_COMPLETE') return { done: true, value: undefined }; - if (status === 'FAILED') { - throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`); - } - return { done: false }; - }, - maxAttempts: 60, - delayMs: 5000, - onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'), - }); + try { + await poll({ + fn: async () => { + const response = await cfn.send( + new DescribeChangeSetCommand({ + StackName: stackName, + ChangeSetName: changeSetName, + }) + ); + const status = response.Status; + if (status === 'CREATE_COMPLETE') return { done: true, value: undefined }; + if (status === 'FAILED') { + throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`); + } + return { done: false }; + }, + maxAttempts: 60, + delayMs: 5000, + onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'), + }); + } catch (err) { + if (err instanceof PollExhaustedError || err instanceof PollTimeoutError) { + throw new Error('Timed out waiting for change set creation', { cause: err }); + } + throw err; + } } /** * Wait for stack to reach IMPORT_COMPLETE status. */ async function waitForStackImportComplete(cfn: CloudFormationClient, stackName: string): Promise { - await poll({ - fn: async () => { - const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); - const stack = response.Stacks?.[0]; - if (!stack) throw new Error(`Stack ${stackName} not found during import wait`); - const status = stack.StackStatus ?? ''; - if (status === 'IMPORT_COMPLETE') return { done: true, value: undefined }; - if (status.includes('FAILED') || status.includes('ROLLBACK')) { - throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`); - } - return { done: false }; - }, - maxAttempts: 120, - delayMs: 5000, - onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'), - }); + try { + await poll({ + fn: async () => { + const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); + const stack = response.Stacks?.[0]; + if (!stack) throw new Error(`Stack ${stackName} not found during import wait`); + const status = stack.StackStatus ?? ''; + if (status === 'IMPORT_COMPLETE') return { done: true, value: undefined }; + if (status.includes('FAILED') || status.includes('ROLLBACK')) { + throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`); + } + return { done: false }; + }, + maxAttempts: 120, + delayMs: 5000, + onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'), + }); + } catch (err) { + if (err instanceof PollExhaustedError || err instanceof PollTimeoutError) { + throw new Error('Timed out waiting for import to complete', { cause: err }); + } + throw err; + } } /** diff --git a/src/lib/utils/__tests__/polling.test.ts b/src/lib/utils/__tests__/polling.test.ts index 9b8b0bbda..7f51e12c8 100644 --- a/src/lib/utils/__tests__/polling.test.ts +++ b/src/lib/utils/__tests__/polling.test.ts @@ -119,6 +119,42 @@ describe('poll', () => { ).rejects.toThrow(PollExhaustedError); }); + it('PollExhaustedError includes cause with the last error', async () => { + const err = await poll({ + fn: async () => { + throw new Error('Rate exceeded'); + }, + maxAttempts: 3, + delayMs: 1, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PollExhaustedError); + expect((err as PollExhaustedError).cause).toBeInstanceOf(Error); + expect(((err as PollExhaustedError).cause as Error).message).toBe('Rate exceeded'); + }); + + it('PollTimeoutError includes cause with the last error', async () => { + const err = await poll({ + fn: async () => { + throw new Error('service unavailable'); + }, + timeoutMs: 50, + delayMs: 10, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PollTimeoutError); + expect((err as PollTimeoutError).cause).toBeInstanceOf(Error); + expect(((err as PollTimeoutError).cause as Error).message).toBe('service unavailable'); + }); + + it('cause is undefined when no errors occurred during polling', async () => { + const err = await poll({ + fn: async () => ({ done: false }), + maxAttempts: 2, + delayMs: 1, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PollExhaustedError); + expect((err as PollExhaustedError).cause).toBeUndefined(); + }); + it('resets consecutive error count on success', async () => { let count = 0; const result = await poll({ diff --git a/src/lib/utils/polling.ts b/src/lib/utils/polling.ts index aabbf2c43..3486e634f 100644 --- a/src/lib/utils/polling.ts +++ b/src/lib/utils/polling.ts @@ -24,15 +24,15 @@ export interface PollOptions { } export class PollTimeoutError extends Error { - constructor(timeoutMs: number) { - super(`Polling timed out after ${timeoutMs}ms`); + constructor(timeoutMs: number, options?: { cause?: unknown }) { + super(`Polling timed out after ${timeoutMs}ms`, options); this.name = 'PollTimeoutError'; } } export class PollExhaustedError extends Error { - constructor(maxAttempts: number) { - super(`Polling exhausted after ${maxAttempts} attempts`); + constructor(maxAttempts: number, options?: { cause?: unknown }) { + super(`Polling exhausted after ${maxAttempts} attempts`, options); this.name = 'PollExhaustedError'; } } @@ -57,13 +57,14 @@ export async function poll(options: PollOptions): Promise { let attempts = 0; let consecutiveErrors = 0; let currentDelay = delayMs; + let lastError: unknown = undefined; while (true) { if (maxAttempts !== undefined && attempts >= maxAttempts) { - throw new PollExhaustedError(maxAttempts); + throw new PollExhaustedError(maxAttempts, { cause: lastError }); } if (timeoutMs !== undefined && Date.now() - start >= timeoutMs) { - throw new PollTimeoutError(timeoutMs); + throw new PollTimeoutError(timeoutMs, { cause: lastError }); } attempts++; @@ -75,15 +76,16 @@ export async function poll(options: PollOptions): Promise { } catch (err: unknown) { const action = onError ? onError(err) : 'retry'; if (action === 'abort') throw err; + lastError = err; consecutiveErrors++; if (maxConsecutiveErrors && consecutiveErrors >= maxConsecutiveErrors) { - throw new PollExhaustedError(maxConsecutiveErrors); + throw new PollExhaustedError(attempts, { cause: lastError }); } } // Don't sleep if we're about to exceed timeout if (timeoutMs !== undefined && Date.now() - start + currentDelay >= timeoutMs) { - throw new PollTimeoutError(timeoutMs); + throw new PollTimeoutError(timeoutMs, { cause: lastError }); } await new Promise(resolve => setTimeout(resolve, currentDelay)); From e3d827ae19ac9cd69073a6ab9054d36c7cea755e Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 8 May 2026 19:13:49 +0000 Subject: [PATCH 3/3] test: scope fake timers to beforeEach/afterEach to prevent leaks --- src/lib/utils/__tests__/polling.test.ts | 81 +++++++++++++------------ 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/src/lib/utils/__tests__/polling.test.ts b/src/lib/utils/__tests__/polling.test.ts index 7f51e12c8..c3440b669 100644 --- a/src/lib/utils/__tests__/polling.test.ts +++ b/src/lib/utils/__tests__/polling.test.ts @@ -1,5 +1,5 @@ import { PollExhaustedError, PollTimeoutError, isThrottlingError, poll } from '../polling.js'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /* eslint-disable @typescript-eslint/require-await */ @@ -35,46 +35,51 @@ describe('poll', () => { ); }); - it('applies exponential backoff', async () => { - vi.useFakeTimers(); - let count = 0; - const promise = poll({ - fn: async () => { - count++; - return count === 4 ? { done: true, value: 'done' } : { done: false }; - }, - maxAttempts: 5, - delayMs: 100, - backoffFactor: 2, + describe('backoff', () => { + beforeEach(() => { + vi.useFakeTimers(); }); - // Advance through iterations - await vi.advanceTimersByTimeAsync(100); // 1st delay: 100 - await vi.advanceTimersByTimeAsync(200); // 2nd delay: 200 - await vi.advanceTimersByTimeAsync(400); // 3rd delay: 400 - const result = await promise; - expect(result).toBe('done'); - vi.useRealTimers(); - }); - it('caps delay at maxDelayMs', async () => { - vi.useFakeTimers(); - let count = 0; - const promise = poll({ - fn: async () => { - count++; - return count === 4 ? { done: true, value: 'done' } : { done: false }; - }, - maxAttempts: 5, - delayMs: 100, - backoffFactor: 10, - maxDelayMs: 500, + afterEach(() => { + vi.useRealTimers(); + }); + + it('applies exponential backoff', async () => { + let count = 0; + const promise = poll({ + fn: async () => { + count++; + return count === 4 ? { done: true, value: 'done' } : { done: false }; + }, + maxAttempts: 5, + delayMs: 100, + backoffFactor: 2, + }); + await vi.advanceTimersByTimeAsync(100); // 1st delay: 100 + await vi.advanceTimersByTimeAsync(200); // 2nd delay: 200 + await vi.advanceTimersByTimeAsync(400); // 3rd delay: 400 + const result = await promise; + expect(result).toBe('done'); + }); + + it('caps delay at maxDelayMs', async () => { + let count = 0; + const promise = poll({ + fn: async () => { + count++; + return count === 4 ? { done: true, value: 'done' } : { done: false }; + }, + maxAttempts: 5, + delayMs: 100, + backoffFactor: 10, + maxDelayMs: 500, + }); + await vi.advanceTimersByTimeAsync(100); // 1st: 100 + await vi.advanceTimersByTimeAsync(500); // 2nd: capped at 500 + await vi.advanceTimersByTimeAsync(500); // 3rd: capped at 500 + const result = await promise; + expect(result).toBe('done'); }); - await vi.advanceTimersByTimeAsync(100); // 1st: 100 - await vi.advanceTimersByTimeAsync(500); // 2nd: capped at 500 - await vi.advanceTimersByTimeAsync(500); // 3rd: capped at 500 - const result = await promise; - expect(result).toBe('done'); - vi.useRealTimers(); }); it('retries on error by default', async () => {