Skip to content

Commit 39164a3

Browse files
committed
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
1 parent 78f3f46 commit 39164a3

3 files changed

Lines changed: 96 additions & 44 deletions

File tree

src/cli/commands/import/phase2-import.ts

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isThrottlingError, poll } from '../../../lib/utils/polling';
1+
import { PollExhaustedError, PollTimeoutError, isThrottlingError, poll } from '../../../lib/utils/polling';
22
import { getCredentialProvider } from '../../aws/account';
33
import type { CfnTemplate } from './template-utils';
44
import { buildImportTemplate } from './template-utils';
@@ -142,47 +142,61 @@ async function waitForChangeSetReady(
142142
stackName: string,
143143
changeSetName: string
144144
): Promise<void> {
145-
await poll({
146-
fn: async () => {
147-
const response = await cfn.send(
148-
new DescribeChangeSetCommand({
149-
StackName: stackName,
150-
ChangeSetName: changeSetName,
151-
})
152-
);
153-
const status = response.Status;
154-
if (status === 'CREATE_COMPLETE') return { done: true, value: undefined };
155-
if (status === 'FAILED') {
156-
throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`);
157-
}
158-
return { done: false };
159-
},
160-
maxAttempts: 60,
161-
delayMs: 5000,
162-
onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'),
163-
});
145+
try {
146+
await poll({
147+
fn: async () => {
148+
const response = await cfn.send(
149+
new DescribeChangeSetCommand({
150+
StackName: stackName,
151+
ChangeSetName: changeSetName,
152+
})
153+
);
154+
const status = response.Status;
155+
if (status === 'CREATE_COMPLETE') return { done: true, value: undefined };
156+
if (status === 'FAILED') {
157+
throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`);
158+
}
159+
return { done: false };
160+
},
161+
maxAttempts: 60,
162+
delayMs: 5000,
163+
onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'),
164+
});
165+
} catch (err) {
166+
if (err instanceof PollExhaustedError || err instanceof PollTimeoutError) {
167+
throw new Error('Timed out waiting for change set creation', { cause: err });
168+
}
169+
throw err;
170+
}
164171
}
165172

166173
/**
167174
* Wait for stack to reach IMPORT_COMPLETE status.
168175
*/
169176
async function waitForStackImportComplete(cfn: CloudFormationClient, stackName: string): Promise<void> {
170-
await poll({
171-
fn: async () => {
172-
const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName }));
173-
const stack = response.Stacks?.[0];
174-
if (!stack) throw new Error(`Stack ${stackName} not found during import wait`);
175-
const status = stack.StackStatus ?? '';
176-
if (status === 'IMPORT_COMPLETE') return { done: true, value: undefined };
177-
if (status.includes('FAILED') || status.includes('ROLLBACK')) {
178-
throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`);
179-
}
180-
return { done: false };
181-
},
182-
maxAttempts: 120,
183-
delayMs: 5000,
184-
onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'),
185-
});
177+
try {
178+
await poll({
179+
fn: async () => {
180+
const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName }));
181+
const stack = response.Stacks?.[0];
182+
if (!stack) throw new Error(`Stack ${stackName} not found during import wait`);
183+
const status = stack.StackStatus ?? '';
184+
if (status === 'IMPORT_COMPLETE') return { done: true, value: undefined };
185+
if (status.includes('FAILED') || status.includes('ROLLBACK')) {
186+
throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`);
187+
}
188+
return { done: false };
189+
},
190+
maxAttempts: 120,
191+
delayMs: 5000,
192+
onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'),
193+
});
194+
} catch (err) {
195+
if (err instanceof PollExhaustedError || err instanceof PollTimeoutError) {
196+
throw new Error('Timed out waiting for import to complete', { cause: err });
197+
}
198+
throw err;
199+
}
186200
}
187201

188202
/**

src/lib/utils/__tests__/polling.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,42 @@ describe('poll', () => {
119119
).rejects.toThrow(PollExhaustedError);
120120
});
121121

122+
it('PollExhaustedError includes cause with the last error', async () => {
123+
const err = await poll({
124+
fn: async () => {
125+
throw new Error('Rate exceeded');
126+
},
127+
maxAttempts: 3,
128+
delayMs: 1,
129+
}).catch((e: unknown) => e);
130+
expect(err).toBeInstanceOf(PollExhaustedError);
131+
expect((err as PollExhaustedError).cause).toBeInstanceOf(Error);
132+
expect(((err as PollExhaustedError).cause as Error).message).toBe('Rate exceeded');
133+
});
134+
135+
it('PollTimeoutError includes cause with the last error', async () => {
136+
const err = await poll({
137+
fn: async () => {
138+
throw new Error('service unavailable');
139+
},
140+
timeoutMs: 50,
141+
delayMs: 10,
142+
}).catch((e: unknown) => e);
143+
expect(err).toBeInstanceOf(PollTimeoutError);
144+
expect((err as PollTimeoutError).cause).toBeInstanceOf(Error);
145+
expect(((err as PollTimeoutError).cause as Error).message).toBe('service unavailable');
146+
});
147+
148+
it('cause is undefined when no errors occurred during polling', async () => {
149+
const err = await poll({
150+
fn: async () => ({ done: false }),
151+
maxAttempts: 2,
152+
delayMs: 1,
153+
}).catch((e: unknown) => e);
154+
expect(err).toBeInstanceOf(PollExhaustedError);
155+
expect((err as PollExhaustedError).cause).toBeUndefined();
156+
});
157+
122158
it('resets consecutive error count on success', async () => {
123159
let count = 0;
124160
const result = await poll({

src/lib/utils/polling.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@ export interface PollOptions<T> {
2424
}
2525

2626
export class PollTimeoutError extends Error {
27-
constructor(timeoutMs: number) {
28-
super(`Polling timed out after ${timeoutMs}ms`);
27+
constructor(timeoutMs: number, options?: { cause?: unknown }) {
28+
super(`Polling timed out after ${timeoutMs}ms`, options);
2929
this.name = 'PollTimeoutError';
3030
}
3131
}
3232

3333
export class PollExhaustedError extends Error {
34-
constructor(maxAttempts: number) {
35-
super(`Polling exhausted after ${maxAttempts} attempts`);
34+
constructor(maxAttempts: number, options?: { cause?: unknown }) {
35+
super(`Polling exhausted after ${maxAttempts} attempts`, options);
3636
this.name = 'PollExhaustedError';
3737
}
3838
}
@@ -57,13 +57,14 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
5757
let attempts = 0;
5858
let consecutiveErrors = 0;
5959
let currentDelay = delayMs;
60+
let lastError: unknown = undefined;
6061

6162
while (true) {
6263
if (maxAttempts !== undefined && attempts >= maxAttempts) {
63-
throw new PollExhaustedError(maxAttempts);
64+
throw new PollExhaustedError(maxAttempts, { cause: lastError });
6465
}
6566
if (timeoutMs !== undefined && Date.now() - start >= timeoutMs) {
66-
throw new PollTimeoutError(timeoutMs);
67+
throw new PollTimeoutError(timeoutMs, { cause: lastError });
6768
}
6869

6970
attempts++;
@@ -75,15 +76,16 @@ export async function poll<T>(options: PollOptions<T>): Promise<T> {
7576
} catch (err: unknown) {
7677
const action = onError ? onError(err) : 'retry';
7778
if (action === 'abort') throw err;
79+
lastError = err;
7880
consecutiveErrors++;
7981
if (maxConsecutiveErrors && consecutiveErrors >= maxConsecutiveErrors) {
80-
throw new PollExhaustedError(maxConsecutiveErrors);
82+
throw new PollExhaustedError(attempts, { cause: lastError });
8183
}
8284
}
8385

8486
// Don't sleep if we're about to exceed timeout
8587
if (timeoutMs !== undefined && Date.now() - start + currentDelay >= timeoutMs) {
86-
throw new PollTimeoutError(timeoutMs);
88+
throw new PollTimeoutError(timeoutMs, { cause: lastError });
8789
}
8890

8991
await new Promise(resolve => setTimeout(resolve, currentDelay));

0 commit comments

Comments
 (0)