Skip to content

Commit 7e62d5f

Browse files
committed
add error_handler.ts and tests
1 parent 2dab201 commit 7e62d5f

File tree

3 files changed

+363
-8
lines changed

3 files changed

+363
-8
lines changed

packages/create-amplify/src/create_amplify.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@
77
If customers have a cached version of the create-amplify package, they might execute that cached version even after we publish features and fixes to the package on npm.
88
*/
99

10-
import {
11-
LogLevel,
12-
PackageManagerControllerFactory,
13-
format,
14-
printer,
15-
} from '@aws-amplify/cli-core';
10+
import { PackageManagerControllerFactory, format } from '@aws-amplify/cli-core';
1611
import { ProjectRootValidator } from './project_root_validator.js';
1712
import { AmplifyProjectCreator } from './amplify_project_creator.js';
1813
import { getProjectRoot } from './get_project_root.js';
1914
import { GitIgnoreInitializer } from './gitignore_initializer.js';
2015
import { InitialProjectFileGenerator } from './initial_project_file_generator.js';
16+
import {
17+
attachUnhandledExceptionListeners,
18+
generateCommandFailureHandler,
19+
} from './error_handler.js';
20+
21+
attachUnhandledExceptionListeners();
22+
const errorHandler = generateCommandFailureHandler();
2123

2224
const projectRoot = await getProjectRoot();
2325

@@ -39,6 +41,7 @@ const amplifyProjectCreator = new AmplifyProjectCreator(
3941
try {
4042
await amplifyProjectCreator.create();
4143
} catch (err) {
42-
printer.log(format.error(err), LogLevel.ERROR);
43-
process.exitCode = 1;
44+
if (err instanceof Error) {
45+
await errorHandler(format.error(err), err);
46+
}
4447
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { after, before, beforeEach, describe, it, mock } from 'node:test';
2+
import {
3+
attachUnhandledExceptionListeners,
4+
generateCommandFailureHandler,
5+
} from './error_handler.js';
6+
import { LogLevel, printer } from '@aws-amplify/cli-core';
7+
import assert from 'node:assert';
8+
import { AmplifyUserError } from '@aws-amplify/platform-core';
9+
10+
const mockPrint = mock.method(printer, 'print');
11+
const mockLog = mock.method(printer, 'log');
12+
13+
void describe('generateCommandFailureHandler', () => {
14+
beforeEach(() => {
15+
mockPrint.mock.resetCalls();
16+
mockLog.mock.resetCalls();
17+
});
18+
19+
void it('prints specified message with undefined error', async () => {
20+
const someMsg = 'some msg';
21+
// undefined error is encountered with --help option.
22+
await generateCommandFailureHandler()(
23+
someMsg,
24+
undefined as unknown as Error
25+
);
26+
assert.equal(mockPrint.mock.callCount(), 1);
27+
assert.match(mockPrint.mock.calls[0].arguments[0], new RegExp(someMsg));
28+
});
29+
30+
void it('prints message from error object', async () => {
31+
const errMsg = 'some error msg';
32+
await generateCommandFailureHandler()('', new Error(errMsg));
33+
assert.equal(mockPrint.mock.callCount(), 1);
34+
assert.match(
35+
mockPrint.mock.calls[0].arguments[0] as string,
36+
new RegExp(errMsg)
37+
);
38+
});
39+
40+
void it('handles a prompt force close error', async () => {
41+
await generateCommandFailureHandler()(
42+
'',
43+
new Error('User force closed the prompt')
44+
);
45+
assert.equal(mockPrint.mock.callCount(), 0);
46+
});
47+
48+
void it('prints error cause message, if any', async () => {
49+
const errorMessage = 'this is the upstream cause';
50+
await generateCommandFailureHandler()(
51+
'',
52+
new Error('some error msg', { cause: new Error(errorMessage) })
53+
);
54+
assert.equal(mockPrint.mock.callCount(), 2);
55+
assert.match(
56+
mockPrint.mock.calls[1].arguments[0] as string,
57+
new RegExp(errorMessage)
58+
);
59+
});
60+
61+
void it('prints AmplifyErrors', async () => {
62+
await generateCommandFailureHandler()(
63+
'',
64+
new AmplifyUserError('TestNameError', {
65+
message: 'test error message',
66+
resolution: 'test resolution',
67+
details: 'test details',
68+
})
69+
);
70+
71+
assert.equal(mockPrint.mock.callCount(), 3);
72+
assert.match(
73+
mockPrint.mock.calls[0].arguments[0],
74+
/TestNameError: test error message/
75+
);
76+
assert.equal(
77+
mockPrint.mock.calls[1].arguments[0],
78+
'Resolution: test resolution'
79+
);
80+
assert.equal(mockPrint.mock.calls[2].arguments[0], 'Details: test details');
81+
});
82+
83+
void it('prints debug stack traces', async () => {
84+
const causeError = new Error('test underlying cause error');
85+
const amplifyError = new AmplifyUserError(
86+
'TestNameError',
87+
{
88+
message: 'test error message',
89+
resolution: 'test resolution',
90+
details: 'test details',
91+
},
92+
causeError
93+
);
94+
await generateCommandFailureHandler()('', amplifyError);
95+
assert.equal(mockLog.mock.callCount(), 2);
96+
assert.deepStrictEqual(mockLog.mock.calls[0].arguments, [
97+
amplifyError.stack,
98+
LogLevel.DEBUG,
99+
]);
100+
assert.deepStrictEqual(mockLog.mock.calls[1].arguments, [
101+
causeError.stack,
102+
LogLevel.DEBUG,
103+
]);
104+
});
105+
});
106+
107+
void describe(
108+
'attachUnhandledExceptionListeners',
109+
{ concurrency: 1 },
110+
async () => {
111+
before(async () => {
112+
attachUnhandledExceptionListeners();
113+
});
114+
115+
beforeEach(() => {
116+
mockPrint.mock.resetCalls();
117+
});
118+
119+
after(() => {
120+
// remove the exception listeners that were added during setup
121+
process.listeners('unhandledRejection').pop();
122+
process.listeners('uncaughtException').pop();
123+
});
124+
void it('handles rejected errors', () => {
125+
process.listeners('unhandledRejection').at(-1)?.(
126+
new Error('test error'),
127+
Promise.resolve()
128+
);
129+
assert.ok(
130+
mockPrint.mock.calls.findIndex((call) =>
131+
call.arguments[0].includes('test error')
132+
) >= 0
133+
);
134+
expectProcessExitCode1AndReset();
135+
});
136+
137+
void it('handles rejected strings', () => {
138+
process.listeners('unhandledRejection').at(-1)?.(
139+
'test error',
140+
Promise.resolve()
141+
);
142+
assert.ok(
143+
mockPrint.mock.calls.findIndex((call) =>
144+
call.arguments[0].includes('test error')
145+
) >= 0
146+
);
147+
expectProcessExitCode1AndReset();
148+
});
149+
150+
void it('handles rejected symbols of other types', () => {
151+
process.listeners('unhandledRejection').at(-1)?.(
152+
{ something: 'weird' },
153+
Promise.resolve()
154+
);
155+
assert.ok(
156+
mockPrint.mock.calls.findIndex((call) =>
157+
call.arguments[0].includes(
158+
'Error: Unhandled rejection of type [object]'
159+
)
160+
) >= 0
161+
);
162+
expectProcessExitCode1AndReset();
163+
});
164+
165+
void it('handles uncaught errors', () => {
166+
process.listeners('uncaughtException').at(-1)?.(
167+
new Error('test error'),
168+
'uncaughtException'
169+
);
170+
assert.ok(
171+
mockPrint.mock.calls.findIndex((call) =>
172+
call.arguments[0].includes('test error')
173+
) >= 0
174+
);
175+
expectProcessExitCode1AndReset();
176+
});
177+
178+
void it('does nothing when called multiple times', () => {
179+
// note the first call happened in the before() setup
180+
181+
const unhandledRejectionListenerCount =
182+
process.listenerCount('unhandledRejection');
183+
const uncaughtExceptionListenerCount =
184+
process.listenerCount('uncaughtException');
185+
186+
attachUnhandledExceptionListeners();
187+
attachUnhandledExceptionListeners();
188+
189+
assert.equal(
190+
process.listenerCount('unhandledRejection'),
191+
unhandledRejectionListenerCount
192+
);
193+
assert.equal(
194+
process.listenerCount('uncaughtException'),
195+
uncaughtExceptionListenerCount
196+
);
197+
});
198+
}
199+
);
200+
201+
const expectProcessExitCode1AndReset = () => {
202+
assert.equal(process.exitCode, 1);
203+
process.exitCode = 0;
204+
};
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { LogLevel, format, printer } from '@aws-amplify/cli-core';
2+
import { AmplifyError } from '@aws-amplify/platform-core';
3+
4+
let hasAttachUnhandledExceptionListenersBeenCalled = false;
5+
6+
type HandleErrorProps = {
7+
error?: Error;
8+
printMessagePreamble?: () => void;
9+
message?: string;
10+
};
11+
12+
/**
13+
* Attaches process listeners to handle unhandled exceptions and rejections
14+
*/
15+
export const attachUnhandledExceptionListeners = (): void => {
16+
if (hasAttachUnhandledExceptionListenersBeenCalled) {
17+
return;
18+
}
19+
process.on('unhandledRejection', (reason) => {
20+
process.exitCode = 1;
21+
if (reason instanceof Error) {
22+
void handleErrorSafe({ error: reason });
23+
} else if (typeof reason === 'string') {
24+
// eslint-disable-next-line amplify-backend-rules/prefer-amplify-errors
25+
void handleErrorSafe({ error: new Error(reason) });
26+
} else {
27+
void handleErrorSafe({
28+
// eslint-disable-next-line amplify-backend-rules/prefer-amplify-errors
29+
error: new Error(`Unhandled rejection of type [${typeof reason}]`, {
30+
cause: reason,
31+
}),
32+
});
33+
}
34+
});
35+
36+
process.on('uncaughtException', (error) => {
37+
process.exitCode = 1;
38+
void handleErrorSafe({ error });
39+
});
40+
hasAttachUnhandledExceptionListenersBeenCalled = true;
41+
};
42+
43+
/**
44+
* Generates a function that is intended to be used as a callback to yargs.fail()
45+
* All logic for actually handling errors should be delegated to handleError.
46+
*/
47+
export const generateCommandFailureHandler = (): ((
48+
message: string,
49+
error: Error
50+
) => Promise<void>) => {
51+
/**
52+
* Format error output when a command fails
53+
* @param message error message
54+
* @param error error
55+
*/
56+
const handleCommandFailure = async (message: string, error?: Error) => {
57+
await handleErrorSafe({
58+
error,
59+
message,
60+
});
61+
};
62+
return handleCommandFailure;
63+
};
64+
65+
const handleErrorSafe = async (props: HandleErrorProps) => {
66+
try {
67+
await handleError(props);
68+
} catch (e) {
69+
printer.log(format.error(e), LogLevel.DEBUG);
70+
// no-op should gracefully exit
71+
return;
72+
}
73+
};
74+
75+
/**
76+
* Error handling for uncaught errors during CLI command execution.
77+
*
78+
* This should be the one and only place where we handle unexpected errors.
79+
* This includes console logging, debug logging, metrics recording, etc.
80+
* (Note that we don't do all of those things yet, but this is where they should go)
81+
*/
82+
const handleError = async ({
83+
error,
84+
printMessagePreamble,
85+
message,
86+
}: HandleErrorProps) => {
87+
// If yargs threw an error because the customer force-closed a prompt (ie Ctrl+C during a prompt) then the intent to exit the process is clear
88+
if (isUserForceClosePromptError(error)) {
89+
return;
90+
}
91+
92+
printMessagePreamble?.();
93+
94+
if (error instanceof AmplifyError) {
95+
printer.print(format.error(`${error.name}: ${error.message}`));
96+
97+
if (error.resolution) {
98+
printer.print(`Resolution: ${error.resolution}`);
99+
}
100+
if (error.details) {
101+
printer.print(`Details: ${error.details}`);
102+
}
103+
if (errorHasCauseMessage(error)) {
104+
printer.print(`Cause: ${error.cause.message}`);
105+
}
106+
} else {
107+
// non-Amplify Error object
108+
printer.print(format.error(message || String(error)));
109+
110+
if (errorHasCauseMessage(error)) {
111+
printer.print(`Cause: ${error.cause.message}`);
112+
}
113+
}
114+
115+
// additional debug logging for the stack traces
116+
if (error?.stack) {
117+
printer.log(error.stack, LogLevel.DEBUG);
118+
}
119+
if (errorHasCauseStackTrace(error)) {
120+
printer.log(error.cause.stack, LogLevel.DEBUG);
121+
}
122+
};
123+
124+
const isUserForceClosePromptError = (err?: Error): boolean => {
125+
return !!err && err?.message.includes('User force closed the prompt');
126+
};
127+
128+
const errorHasCauseStackTrace = (
129+
error?: Error
130+
): error is Error & { cause: { stack: string } } => {
131+
return (
132+
typeof error?.cause === 'object' &&
133+
!!error.cause &&
134+
'stack' in error.cause &&
135+
typeof error.cause.stack === 'string'
136+
);
137+
};
138+
139+
const errorHasCauseMessage = (
140+
error?: Error
141+
): error is Error & { cause: { message: string } } => {
142+
return (
143+
typeof error?.cause === 'object' &&
144+
!!error.cause &&
145+
'message' in error.cause &&
146+
typeof error.cause.message === 'string'
147+
);
148+
};

0 commit comments

Comments
 (0)