Skip to content

Commit f08b366

Browse files
sdangolsvozza
andauthored
improv(event-handler): made error handler responses versatile (#4536)
Co-authored-by: Stefano Vozza <[email protected]>
1 parent fd156aa commit f08b366

File tree

6 files changed

+219
-25
lines changed

6 files changed

+219
-25
lines changed

packages/event-handler/src/rest/Router.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
isDevMode,
55
} from '@aws-lambda-powertools/commons/utils/env';
66
import type { APIGatewayProxyResult, Context } from 'aws-lambda';
7-
import type { ResolveOptions } from '../types/index.js';
7+
import type {
8+
HandlerResponse,
9+
HttpStatusCode,
10+
ResolveOptions,
11+
} from '../types/index.js';
812
import type {
913
ErrorConstructor,
1014
ErrorHandler,
@@ -22,7 +26,6 @@ import {
2226
handlerResultToProxyResult,
2327
handlerResultToWebResponse,
2428
proxyEventToWebRequest,
25-
webResponseToProxyResult,
2629
} from './converters.js';
2730
import { ErrorHandlerRegistry } from './ErrorHandlerRegistry.js';
2831
import {
@@ -36,6 +39,7 @@ import { RouteHandlerRegistry } from './RouteHandlerRegistry.js';
3639
import {
3740
composeMiddleware,
3841
isAPIGatewayProxyEvent,
42+
isAPIGatewayProxyResult,
3943
isHttpMethod,
4044
} from './utils.js';
4145

@@ -280,7 +284,9 @@ class Router {
280284
...requestContext,
281285
scope: options?.scope,
282286
});
283-
return await webResponseToProxyResult(result);
287+
const statusCode =
288+
result instanceof Response ? result.status : result.statusCode;
289+
return handlerResultToProxyResult(result, statusCode as HttpStatusCode);
284290
}
285291
}
286292

@@ -310,17 +316,31 @@ class Router {
310316
protected async handleError(
311317
error: Error,
312318
options: ErrorResolveOptions
313-
): Promise<Response> {
319+
): Promise<HandlerResponse> {
314320
const handler = this.errorHandlerRegistry.resolve(error);
315321
if (handler !== null) {
316322
try {
317323
const { scope, ...reqCtx } = options;
318324
const body = await handler.apply(scope ?? this, [error, reqCtx]);
325+
if (body instanceof Response || isAPIGatewayProxyResult(body)) {
326+
return body;
327+
}
328+
if (!body.statusCode) {
329+
if (error instanceof NotFoundError) {
330+
body.statusCode = HttpErrorCodes.NOT_FOUND;
331+
} else if (error instanceof MethodNotAllowedError) {
332+
body.statusCode = HttpErrorCodes.METHOD_NOT_ALLOWED;
333+
}
334+
}
319335
return new Response(JSON.stringify(body), {
320-
status: body.statusCode,
336+
status:
337+
(body.statusCode as number) ?? HttpErrorCodes.INTERNAL_SERVER_ERROR,
321338
headers: { 'Content-Type': 'application/json' },
322339
});
323340
} catch (handlerError) {
341+
if (handlerError instanceof ServiceError) {
342+
return await this.handleError(handlerError, options);
343+
}
324344
return this.#defaultErrorHandler(handlerError as Error);
325345
}
326346
}

packages/event-handler/src/rest/converters.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
2-
import type { CompressionOptions, HandlerResponse } from '../types/rest.js';
3-
import { COMPRESSION_ENCODING_TYPES } from './constants.js';
2+
import type {
3+
CompressionOptions,
4+
HandlerResponse,
5+
HttpStatusCode,
6+
} from '../types/rest.js';
7+
import { COMPRESSION_ENCODING_TYPES, HttpErrorCodes } from './constants.js';
48
import { isAPIGatewayProxyResult } from './utils.js';
59

610
/**
@@ -181,10 +185,12 @@ export const handlerResultToWebResponse = (
181185
* Handles APIGatewayProxyResult, Response objects, and plain objects.
182186
*
183187
* @param response - The handler response (APIGatewayProxyResult, Response, or plain object)
188+
* @param statusCode - The response status code to return
184189
* @returns An API Gateway proxy result
185190
*/
186191
export const handlerResultToProxyResult = async (
187-
response: HandlerResponse
192+
response: HandlerResponse,
193+
statusCode: HttpStatusCode = HttpErrorCodes.OK
188194
): Promise<APIGatewayProxyResult> => {
189195
if (isAPIGatewayProxyResult(response)) {
190196
return response;
@@ -193,7 +199,7 @@ export const handlerResultToProxyResult = async (
193199
return await webResponseToProxyResult(response);
194200
}
195201
return {
196-
statusCode: 200,
202+
statusCode,
197203
body: JSON.stringify(response),
198204
headers: { 'content-type': 'application/json' },
199205
isBase64Encoded: false,

packages/event-handler/src/rest/errors.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { ErrorResponse, HttpStatusCode } from '../types/rest.js';
1+
import type { JSONValue } from '@aws-lambda-powertools/commons/types';
2+
import type { HandlerResponse, HttpStatusCode } from '../types/rest.js';
23
import { HttpErrorCodes } from './constants.js';
34

45
export class RouteMatchingError extends Error {
@@ -34,12 +35,14 @@ export abstract class ServiceError extends Error {
3435
this.details = details;
3536
}
3637

37-
toJSON(): ErrorResponse {
38+
toJSON(): HandlerResponse {
3839
return {
3940
statusCode: this.statusCode,
4041
error: this.errorType,
4142
message: this.message,
42-
...(this.details && { details: this.details }),
43+
...(this.details && {
44+
details: this.details as Record<string, JSONValue>,
45+
}),
4346
};
4447
}
4548
}

packages/event-handler/src/types/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export type {
3535
CorsOptions,
3636
ErrorHandler,
3737
ErrorResolveOptions,
38-
ErrorResponse,
3938
HandlerResponse,
4039
HttpMethod,
4140
HttpStatusCode,

packages/event-handler/src/types/rest.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,6 @@ import type { Route } from '../rest/Route.js';
88
import type { Router } from '../rest/Router.js';
99
import type { ResolveOptions } from './common.js';
1010

11-
type ErrorResponse = {
12-
statusCode: HttpStatusCode;
13-
error: string;
14-
message: string;
15-
};
16-
1711
type RequestContext = {
1812
req: Request;
1913
event: APIGatewayProxyEvent;
@@ -27,7 +21,7 @@ type ErrorResolveOptions = RequestContext & ResolveOptions;
2721
type ErrorHandler<T extends Error = Error> = (
2822
error: T,
2923
reqCtx: RequestContext
30-
) => Promise<ErrorResponse>;
24+
) => Promise<HandlerResponse>;
3125

3226
interface ErrorConstructor<T extends Error = Error> {
3327
new (...args: any[]): T;
@@ -165,7 +159,6 @@ export type {
165159
CompiledRoute,
166160
CorsOptions,
167161
DynamicRoute,
168-
ErrorResponse,
169162
ErrorConstructor,
170163
ErrorHandlerRegistryOptions,
171164
ErrorHandler,

packages/event-handler/tests/unit/rest/Router/error-handling.test.ts

Lines changed: 177 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
HttpErrorCodes,
66
InternalServerError,
77
MethodNotAllowedError,
8+
NotFoundError,
89
Router,
910
} from '../../../../src/rest/index.js';
1011
import { createTestEvent } from '../helpers.js';
@@ -49,7 +50,6 @@ describe('Class: Router - Error Handling', () => {
4950
const app = new Router();
5051

5152
app.notFound(async (error) => ({
52-
statusCode: HttpErrorCodes.NOT_FOUND,
5353
error: 'Not Found',
5454
message: `Custom: ${error.message}`,
5555
}));
@@ -64,9 +64,9 @@ describe('Class: Router - Error Handling', () => {
6464
expect(result).toEqual({
6565
statusCode: HttpErrorCodes.NOT_FOUND,
6666
body: JSON.stringify({
67-
statusCode: HttpErrorCodes.NOT_FOUND,
6867
error: 'Not Found',
6968
message: 'Custom: Route /nonexistent for method GET not found',
69+
statusCode: HttpErrorCodes.NOT_FOUND,
7070
}),
7171
headers: { 'content-type': 'application/json' },
7272
isBase64Encoded: false,
@@ -78,7 +78,6 @@ describe('Class: Router - Error Handling', () => {
7878
const app = new Router();
7979

8080
app.methodNotAllowed(async (error) => ({
81-
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
8281
error: 'Method Not Allowed',
8382
message: `Custom: ${error.message}`,
8483
}));
@@ -94,9 +93,9 @@ describe('Class: Router - Error Handling', () => {
9493
expect(result).toEqual({
9594
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
9695
body: JSON.stringify({
97-
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
9896
error: 'Method Not Allowed',
9997
message: 'Custom: POST not allowed',
98+
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
10099
}),
101100
headers: { 'content-type': 'application/json' },
102101
isBase64Encoded: false,
@@ -393,4 +392,178 @@ describe('Class: Router - Error Handling', () => {
393392
expect(body.hasEvent).toBe(true);
394393
expect(body.hasContext).toBe(true);
395394
});
395+
396+
it('handles returning a Response from the error handler', async () => {
397+
// Prepare
398+
const app = new Router();
399+
400+
app.errorHandler(
401+
BadRequestError,
402+
async () =>
403+
new Response(
404+
JSON.stringify({
405+
foo: 'bar',
406+
}),
407+
{
408+
status: HttpErrorCodes.BAD_REQUEST,
409+
headers: {
410+
'content-type': 'application/json',
411+
},
412+
}
413+
)
414+
);
415+
416+
app.get('/test', () => {
417+
throw new BadRequestError('test error');
418+
});
419+
420+
// Act
421+
const result = await app.resolve(createTestEvent('/test', 'GET'), context);
422+
423+
// Assess
424+
expect(result).toEqual({
425+
statusCode: HttpErrorCodes.BAD_REQUEST,
426+
body: JSON.stringify({
427+
foo: 'bar',
428+
}),
429+
headers: { 'content-type': 'application/json' },
430+
isBase64Encoded: false,
431+
});
432+
});
433+
434+
it('handles returning an API Gateway Proxy result from the error handler', async () => {
435+
// Prepare
436+
const app = new Router();
437+
438+
app.errorHandler(BadRequestError, async () => ({
439+
statusCode: HttpErrorCodes.BAD_REQUEST,
440+
body: JSON.stringify({
441+
foo: 'bar',
442+
}),
443+
}));
444+
445+
app.get('/test', () => {
446+
throw new BadRequestError('test error');
447+
});
448+
449+
// Act
450+
const result = await app.resolve(createTestEvent('/test', 'GET'), context);
451+
452+
// Assess
453+
expect(result).toEqual({
454+
statusCode: HttpErrorCodes.BAD_REQUEST,
455+
body: JSON.stringify({
456+
foo: 'bar',
457+
}),
458+
});
459+
});
460+
461+
it('handles returning a JSONObject from the error handler', async () => {
462+
// Prepare
463+
const app = new Router();
464+
465+
app.errorHandler(BadRequestError, async () => ({ foo: 'bar' }));
466+
467+
app.get('/test', () => {
468+
throw new BadRequestError('test error');
469+
});
470+
471+
// Act
472+
const result = await app.resolve(createTestEvent('/test', 'GET'), context);
473+
474+
// Assess
475+
expect(result).toEqual({
476+
statusCode: HttpErrorCodes.INTERNAL_SERVER_ERROR,
477+
body: JSON.stringify({
478+
foo: 'bar',
479+
}),
480+
headers: { 'content-type': 'application/json' },
481+
isBase64Encoded: false,
482+
});
483+
});
484+
485+
it('handles throwing a built in NotFound error from the error handler', async () => {
486+
// Prepare
487+
const app = new Router();
488+
489+
app.errorHandler(BadRequestError, async () => {
490+
throw new NotFoundError('This error is thrown from the error handler');
491+
});
492+
493+
app.get('/test', () => {
494+
throw new BadRequestError('test error');
495+
});
496+
497+
// Act
498+
const result = await app.resolve(createTestEvent('/test', 'GET'), context);
499+
500+
// Assess
501+
expect(result).toEqual({
502+
statusCode: HttpErrorCodes.NOT_FOUND,
503+
body: JSON.stringify({
504+
statusCode: HttpErrorCodes.NOT_FOUND,
505+
error: 'NotFoundError',
506+
message: 'This error is thrown from the error handler',
507+
}),
508+
headers: { 'content-type': 'application/json' },
509+
isBase64Encoded: false,
510+
});
511+
});
512+
513+
it('handles throwing a built in MethodNotAllowedError error from the error handler', async () => {
514+
// Prepare
515+
const app = new Router();
516+
517+
app.errorHandler(BadRequestError, async () => {
518+
throw new MethodNotAllowedError(
519+
'This error is thrown from the error handler'
520+
);
521+
});
522+
523+
app.get('/test', () => {
524+
throw new BadRequestError('test error');
525+
});
526+
527+
// Act
528+
const result = await app.resolve(createTestEvent('/test', 'GET'), context);
529+
530+
// Assess
531+
expect(result).toEqual({
532+
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
533+
body: JSON.stringify({
534+
statusCode: HttpErrorCodes.METHOD_NOT_ALLOWED,
535+
error: 'MethodNotAllowedError',
536+
message: 'This error is thrown from the error handler',
537+
}),
538+
headers: { 'content-type': 'application/json' },
539+
isBase64Encoded: false,
540+
});
541+
});
542+
543+
it('handles throwing a generic error from the error handler', async () => {
544+
// Prepare
545+
vi.stubEnv('POWERTOOLS_DEV', 'true');
546+
const app = new Router();
547+
548+
app.errorHandler(BadRequestError, async () => {
549+
throw new Error('This error is thrown from the error handler');
550+
});
551+
552+
app.get('/test', () => {
553+
throw new BadRequestError('test error');
554+
});
555+
556+
// Act
557+
const result = await app.resolve(createTestEvent('/test', 'GET'), context);
558+
559+
// Assess
560+
expect(result.statusCode).toBe(HttpErrorCodes.INTERNAL_SERVER_ERROR);
561+
const body = JSON.parse(result.body);
562+
expect(body.error).toBe('Internal Server Error');
563+
expect(body.message).toBe('This error is thrown from the error handler');
564+
expect(body.stack).toBeDefined();
565+
expect(body.details).toEqual({
566+
errorName: 'Error',
567+
});
568+
});
396569
});

0 commit comments

Comments
 (0)