Skip to content

Commit 77a992f

Browse files
feat(event-handler): add support for error handling in AppSync GraphQL (#4317)
Co-authored-by: Andrea Amorosi <[email protected]>
1 parent 801333d commit 77a992f

File tree

12 files changed

+1289
-6
lines changed

12 files changed

+1289
-6
lines changed

docs/features/event-handler/appsync-graphql.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,38 @@ You can access the original Lambda event or context for additional information.
148148

149149
1. The `event` parameter contains the original AppSync event and has type `AppSyncResolverEvent` from the `@types/aws-lambda`.
150150

151+
### Exception Handling
152+
153+
You can use the `exceptionHandler` method to handle any exception. This allows you to handle common errors outside your resolver and return a custom response.
154+
155+
The `exceptionHandler` method also supports passing an array of exceptions that you wish to handle with a single handler.
156+
157+
You can use an AppSync JavaScript resolver or a VTL response mapping template to detect these custom responses and forward them to the client gracefully.
158+
159+
=== "Exception Handling"
160+
161+
```typescript hl_lines="11-18 21-23"
162+
--8<-- "examples/snippets/event-handler/appsync-graphql/exceptionHandling.ts"
163+
```
164+
165+
=== "APPSYNC JS Resolver"
166+
167+
```js hl_lines="11-13"
168+
--8<-- "examples/snippets/event-handler/appsync-graphql/exceptionHandlingResolver.js"
169+
```
170+
171+
=== "VTL Response Mapping Template"
172+
173+
```velocity hl_lines="1-3"
174+
--8<-- "examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponseMapping.vtl"
175+
```
176+
177+
=== "Exception Handling response"
178+
179+
```json hl_lines="11 20"
180+
--8<-- "examples/snippets/event-handler/appsync-graphql/exceptionHandlingResponse.json"
181+
```
182+
151183
### Logging
152184

153185
By default, the utility uses the global `console` logger and emits only warnings and errors.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql';
2+
import { Logger } from '@aws-lambda-powertools/logger';
3+
import { AssertionError } from 'node:assert';
4+
import type { Context } from 'aws-lambda';
5+
6+
const logger = new Logger({
7+
serviceName: 'MyService',
8+
});
9+
const app = new AppSyncGraphQLResolver({ logger });
10+
11+
app.exceptionHandler(AssertionError, async (error) => {
12+
return {
13+
error: {
14+
message: error.message,
15+
type: error.name,
16+
},
17+
};
18+
});
19+
20+
app.onQuery('createSomething', async () => {
21+
throw new AssertionError({
22+
message: 'This is an assertion error',
23+
});
24+
});
25+
26+
export const handler = async (event: unknown, context: Context) =>
27+
app.resolve(event, context);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { util } from '@aws-appsync/utils';
2+
3+
export function request(ctx) {
4+
return {
5+
operation: 'Invoke',
6+
payload: ctx,
7+
};
8+
}
9+
10+
export function response(ctx) {
11+
if (ctx.result.error) {
12+
return util.error(ctx.result.error.message, ctx.result.error.type);
13+
}
14+
return ctx.result;
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"data": {
3+
"createSomething": null
4+
},
5+
"errors": [
6+
{
7+
"path": [
8+
"createSomething"
9+
],
10+
"data": null,
11+
"errorType": "AssertionError",
12+
"errorInfo": null,
13+
"locations": [
14+
{
15+
"line": 2,
16+
"column": 3,
17+
"sourceName": null
18+
}
19+
],
20+
"message": "This is an assertion Error"
21+
}
22+
]
23+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#if (!$util.isNull($ctx.result.error))
2+
$util.error($ctx.result.error.message, $ctx.result.error.type)
3+
#end
4+
5+
$utils.toJson($ctx.result)

packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ class AppSyncGraphQLResolver extends Router {
166166
}
167167
return this.#withErrorHandling(
168168
() => this.#executeBatchResolvers(event, context, options),
169-
event[0]
169+
event[0],
170+
options
170171
);
171172
}
172173
if (!isAppSyncGraphQLEvent(event)) {
@@ -178,7 +179,8 @@ class AppSyncGraphQLResolver extends Router {
178179

179180
return this.#withErrorHandling(
180181
() => this.#executeSingleResolver(event, context, options),
181-
event
182+
event,
183+
options
182184
);
183185
}
184186

@@ -189,17 +191,20 @@ class AppSyncGraphQLResolver extends Router {
189191
*
190192
* @param fn - A function returning a Promise to be executed with error handling.
191193
* @param event - The AppSync resolver event (single or first of batch).
194+
* @param options - Optional resolve options for customizing resolver behavior.
192195
*/
193196
async #withErrorHandling(
194197
fn: () => Promise<unknown>,
195-
event: AppSyncResolverEvent<Record<string, unknown>>
198+
event: AppSyncResolverEvent<Record<string, unknown>>,
199+
options?: ResolveOptions
196200
): Promise<unknown> {
197201
try {
198202
return await fn();
199203
} catch (error) {
200204
return this.#handleError(
201205
error,
202-
`An error occurred in handler ${event.info.fieldName}`
206+
`An error occurred in handler ${event.info.fieldName}`,
207+
options
203208
);
204209
}
205210
}
@@ -209,16 +214,39 @@ class AppSyncGraphQLResolver extends Router {
209214
*
210215
* Logs the provided error message and error object. If the error is an instance of
211216
* `InvalidBatchResponseException` or `ResolverNotFoundException`, it is re-thrown.
217+
* Checks for registered exception handlers and calls them if available.
212218
* Otherwise, the error is formatted into a response using `#formatErrorResponse`.
213219
*
214220
* @param error - The error object to handle.
215221
* @param errorMessage - A descriptive message to log alongside the error.
222+
* @param options - Optional resolve options for customizing resolver behavior.
216223
* @throws InvalidBatchResponseException | ResolverNotFoundException
217224
*/
218-
#handleError(error: unknown, errorMessage: string) {
225+
async #handleError(
226+
error: unknown,
227+
errorMessage: string,
228+
options?: ResolveOptions
229+
): Promise<unknown> {
219230
this.logger.error(errorMessage, error);
220231
if (error instanceof InvalidBatchResponseException) throw error;
221232
if (error instanceof ResolverNotFoundException) throw error;
233+
if (error instanceof Error) {
234+
const exceptionHandler = this.exceptionHandlerRegistry.resolve(error);
235+
if (exceptionHandler) {
236+
try {
237+
this.logger.debug(
238+
`Calling exception handler for error: ${error.name}`
239+
);
240+
return await exceptionHandler.apply(options?.scope ?? this, [error]);
241+
} catch (handlerError) {
242+
this.logger.error(
243+
`Exception handler for ${error.name} threw an error`,
244+
handlerError
245+
);
246+
}
247+
}
248+
}
249+
222250
return this.#formatErrorResponse(error);
223251
}
224252

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { GenericLogger } from '@aws-lambda-powertools/commons/types';
2+
import type {
3+
ErrorClass,
4+
ExceptionHandler,
5+
ExceptionHandlerOptions,
6+
ExceptionHandlerRegistryOptions,
7+
} from '../types/appsync-graphql.js';
8+
9+
/**
10+
* Registry for storing exception handlers for GraphQL resolvers in AWS AppSync GraphQL API's.
11+
*/
12+
class ExceptionHandlerRegistry {
13+
/**
14+
* A map of registered exception handlers, keyed by their error class name.
15+
*/
16+
protected readonly handlers: Map<string, ExceptionHandlerOptions> = new Map();
17+
/**
18+
* A logger instance to be used for logging debug and warning messages.
19+
*/
20+
readonly #logger: Pick<GenericLogger, 'debug' | 'warn' | 'error'>;
21+
22+
public constructor(options: ExceptionHandlerRegistryOptions) {
23+
this.#logger = options.logger;
24+
}
25+
26+
/**
27+
* Registers an exception handler for one or more error classes.
28+
*
29+
* If a handler for the given error class is already registered, it will be replaced and a warning will be logged.
30+
*
31+
* @param options - The options containing the error class(es) and their associated handler.
32+
* @param options.error - A single error class or an array of error classes to handle.
33+
* @param options.handler - The exception handler function that will be invoked when the error occurs.
34+
*/
35+
public register(options: ExceptionHandlerOptions<Error>): void {
36+
const { error, handler } = options;
37+
const errors = Array.isArray(error) ? error : [error];
38+
39+
for (const err of errors) {
40+
this.registerErrorHandler(err, handler);
41+
}
42+
}
43+
44+
/**
45+
* Registers a error handler for a specific error class.
46+
*
47+
* @param errorClass - The error class to register the handler for.
48+
* @param handler - The exception handler function.
49+
*/
50+
private registerErrorHandler(
51+
errorClass: ErrorClass<Error>,
52+
handler: ExceptionHandler
53+
): void {
54+
const errorName = errorClass.name;
55+
56+
this.#logger.debug(`Adding exception handler for error class ${errorName}`);
57+
58+
if (this.handlers.has(errorName)) {
59+
this.#logger.warn(
60+
`An exception handler for error class '${errorName}' is already registered. The previous handler will be replaced.`
61+
);
62+
}
63+
64+
this.handlers.set(errorName, {
65+
error: errorClass,
66+
handler,
67+
});
68+
}
69+
70+
/**
71+
* Resolves and returns the appropriate exception handler for a given error instance.
72+
*
73+
* This method attempts to find a registered exception handler based on the error class name.
74+
* If a matching handler is found, it is returned; otherwise, `null` is returned.
75+
*
76+
* @param error - The error instance for which to resolve an exception handler.
77+
*/
78+
public resolve(error: Error): ExceptionHandler | null {
79+
const errorName = error.name;
80+
this.#logger.debug(`Looking for exception handler for error: ${errorName}`);
81+
82+
const handlerOptions = this.handlers.get(errorName);
83+
if (handlerOptions) {
84+
this.#logger.debug(`Found exact match for error class: ${errorName}`);
85+
return handlerOptions.handler;
86+
}
87+
88+
this.#logger.debug(`No exception handler found for error: ${errorName}`);
89+
return null;
90+
}
91+
}
92+
93+
export { ExceptionHandlerRegistry };

0 commit comments

Comments
 (0)