Open
Description
Use case
As part of the Event Handler implementation (#3251), we need a comprehensive error handling system that provides:
- Centralized exception management for consistent error responses across all routes
- Pre-built HTTP error classes for common scenarios (400, 401, 404, 500, etc.)
- Custom error handler registration allowing developers to handle specific error types
- Automatic error response formatting that converts exceptions to proper HTTP responses
- Development-friendly error reporting with detailed information in debug mode
- Integration with route resolution to catch and handle errors during request processing
This system ensures that errors are handled consistently, securely, and provide good developer experience while maintaining production-ready error responses.
Solution/User Experience
Note
The code snippets below are provided as reference only - they are not exhaustive and final implementation might vary.
// Base error class for all HTTP errors
abstract class ServiceError extends Error {
abstract readonly statusCode: number;
abstract readonly errorType: string;
public readonly details?: Record<string, unknown>;
constructor(
message: string,
options?: ErrorOptions,
details?: Record<string, unknown>
) {
super(message);
this.name = 'ServiceError';
}
toJSON(): ErrorResponse {
return {
statusCode: this.statusCode,
error: this.errorType,
message: this.message,
...(this.details && { details: this.details })
};
}
}
Other error classes to be implemented, which extend ServiceError
above:
- BadRequestError
- UnauthorizedError
- ForbiddenError
- NotFoundError
- MethodNotAllowedError
- InternalServerError
- ServiceUnavailableError
When implementing, also double check in the Powertools for AWS Lambda (Python) repo if more error classes are present.
Exception Handler Manager
// Centralized exception handling
class ExceptionHandlerManager {
private handlers: Map<ErrorConstructor, ErrorHandler> = new Map();
private defaultHandler?: ErrorHandler;
// Register custom error handler for specific error types
register<T extends Error>(
errorType: ErrorConstructor<T> | ErrorConstructor<T>[],
handler: ErrorHandler<T>
): void {
const errorTypes = Array.isArray(errorType) ? errorType : [errorType];
for (const type of errorTypes) {
this.handlers.set(type, handler as ErrorHandler);
}
}
// Set default handler for unhandled errors
setDefaultHandler(handler: ErrorHandler): void {
this.defaultHandler = handler;
}
// Handle an error and return appropriate response
handle(error: Error, context: ErrorContext): ErrorResponse {
// 1. Try to find specific handler
const handler = this.findHandler(error);
if (handler) {
try {
return handler(error, context);
} catch (handlerError) {
// Handler itself threw an error - fall back to default
return this.handleDefault(handlerError, context);
}
}
// 2. Handle known ServiceError types
if (error instanceof ServiceError) {
return this.handleServiceError(error, context);
}
// 3. Use default handler or built-in fallback
return this.handleDefault(error, context);
}
private findHandler(error: Error): ErrorHandler | null {
// Try exact type match first
const exactHandler = this.handlers.get(error.constructor as ErrorConstructor);
if (exactHandler) return exactHandler;
// Try instanceof checks for inheritance
for (const [errorType, handler] of this.handlers) {
if (error instanceof errorType) {
return handler;
}
}
// Try name-based matching (for cases where instanceof fails due to bundling)
for (const [errorType, handler] of this.handlers) {
if (error.name === errorType.name) {
return handler;
}
}
return null;
}
private handleServiceError(error: ServiceError, context: ErrorContext): ErrorResponse {
return {
statusCode: error.statusCode,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(error.toJSON())
};
}
private handleDefault(error: Error, context: ErrorContext): ErrorResponse {
if (this.defaultHandler) {
return this.defaultHandler(error, context);
}
// Built-in fallback for unhandled errors
const isProduction = process.env.NODE_ENV === 'production';
const isDevelopment = process.env.POWERTOOLS_DEV === 'true' ||
process.env.POWERTOOLS_EVENT_HANDLER_DEBUG === 'true';
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
statusCode: 500,
error: 'Internal Server Error',
message: isProduction ? 'Internal Server Error' : error.message,
...(isDevelopment && {
stack: error.stack,
details: { errorName: error.name }
})
})
};
}
}
Error Handler Registration API (RFC #3500 DX)
// Integration with BaseRouter following RFC #3500 DX patterns
abstract class BaseRouter {
protected exceptionManager = new ExceptionHandlerManager();
// Primary error handler registration (matches RFC #3500)
errorHandler<T extends Error>(
errorType: ErrorConstructor<T> | ErrorConstructor<T>[],
handler: ErrorHandler<T>
): void {
this.exceptionManager.register(errorType, handler);
}
// Specialized handlers (matches RFC #3500)
notFound(handler: ErrorHandler<NotFoundError>): void {
this.exceptionManager.register(NotFoundError, handler);
}
methodNotAllowed(handler: ErrorHandler<MethodNotAllowedError>): void {
this.exceptionManager.register(MethodNotAllowedError, handler);
}
// Internal method to handle errors during route processing
protected handleError(error: Error, context: RequestContext): ErrorResponse {
const errorContext: ErrorContext = {
path: context.path,
method: context.method,
headers: context.headers,
timestamp: new Date().toISOString(),
requestId: context.requestId
};
return this.exceptionManager.handle(error, errorContext);
}
}
Developer Experience (Following RFC #3500 Patterns)
// Manual usage pattern (from RFC #3500)
const app = new APIGatewayRestResolver();
// Register error handlers using app.errorHandler()
app.errorHandler(ZodError, (error) => {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
statusCode: 400,
error: 'Validation Error',
message: 'Request validation failed',
details: error.issues
})
};
});
// Handle multiple error types with single handler
app.errorHandler([TimeoutError, ConnectionError], (error) => {
return {
statusCode: 503,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
statusCode: 503,
error: 'Service Unavailable',
message: 'External service temporarily unavailable'
})
};
});
// Specialized handlers
app.notFound((error) => {
return {
statusCode: 404,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
statusCode: 404,
error: 'Not Found',
message: `Route not found`
})
};
});
app.methodNotAllowed((error) => {
return {
statusCode: 405,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
statusCode: 405,
error: 'Method Not Allowed',
message: 'HTTP method not supported for this route'
})
};
});
// Class method decorator usage pattern (from RFC #3500)
class Lambda {
@app.errorHandler(ZodError)
public async handleValidationError(error: ZodError) {
logger.error('Request validation failed', {
path: app.currentEvent.path,
error: error.issues
});
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
statusCode: 400,
error: 'Validation Error',
message: 'Invalid request parameters'
})
};
}
@app.notFound()
public async handleNotFound(error: NotFoundError) {
logger.info('Route not found', {
path: app.currentEvent.path,
method: app.currentEvent.method
});
return {
statusCode: 404,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
statusCode: 404,
error: 'Not Found',
message: 'The requested resource was not found'
})
};
}
@app.get('/users/:id')
public async getUserById({ id }) {
if (!id || id === '0') {
throw new BadRequestError('User ID must be a positive number');
}
const user = await getUserById(id);
if (!user) {
throw new NotFoundError(`User with ID ${id} not found`);
}
return user;
}
}
export const handler = async (event, context) => app.resolve(event, context);
Pre-built Error Usage (RFC #3500 Style)
// Route handlers can throw pre-built errors directly (from RFC #3500)
app.get('/bad-request-error', () => {
throw new BadRequestError('Missing required parameter'); // HTTP 400
});
app.get('/unauthorized-error', () => {
throw new UnauthorizedError('Unauthorized'); // HTTP 401
});
app.get('/not-found-error', () => {
throw new NotFoundError(); // HTTP 404
});
app.get('/internal-server-error', () => {
throw new InternalServerError('Internal server error'); // HTTP 500
});
app.get('/service-error', () => {
throw new ServiceUnavailableError('Something went wrong!'); // HTTP 503
});
Error Response Formatting
// Consistent error response structure
interface ErrorResponse {
statusCode: number;
headers: Record<string, string>;
body: string;
}
interface ErrorContext {
path: string;
method: string;
headers: Record<string, string>;
timestamp: string;
requestId?: string;
}
interface ErrorHandler<T extends Error = Error> {
(error: T, context?: ErrorContext): ErrorResponse;
}
interface ErrorConstructor<T extends Error = Error> {
new (...args: any[]): T;
prototype: T;
}
// Error response body structure
interface ErrorResponseBody {
statusCode: number;
error: string;
message: string;
details?: Record<string, any>;
timestamp?: string;
path?: string;
requestId?: string;
}
Integration with Route Resolution
// Integration point with route resolution system
abstract class BaseRouter {
async resolve(event: any, context: any): Promise<any> {
try {
const requestContext = this.createRequestContext(event, context);
// Route matching (from Route Matching System)
const resolvedRoute = this.resolveRoute(requestContext.method, requestContext.path);
if (!resolvedRoute) {
// No route found - throw NotFoundError
throw new NotFoundError(`Route ${requestContext.method} ${requestContext.path} not found`);
}
// Execute route handler
const result = await resolvedRoute.route.handler(resolvedRoute.params, requestContext);
// Build response (from Response Handling System)
return this.buildResponse(result, resolvedRoute.route);
} catch (error) {
// All errors are handled centrally
const errorResponse = this.handleError(error as Error, requestContext);
return this.convertErrorResponseToLambdaFormat(errorResponse);
}
}
private convertErrorResponseToLambdaFormat(errorResponse: ErrorResponse): any {
return {
statusCode: errorResponse.statusCode,
headers: errorResponse.headers,
body: errorResponse.body,
isBase64Encoded: false
};
}
}
Implementation Details
Scope - In Scope:
- ServiceError hierarchy: Base class and common HTTP error classes
- ExceptionHandlerManager: Centralized error handling with custom handler registration
- RFC RFC: Event Handler for REST APIs #3500 compliant API:
errorHandler()
,notFound()
,methodNotAllowed()
methods - Class method decorator support:
@app.errorHandler(ErrorClass)
pattern - Pre-built error classes: BadRequestError, UnauthorizedError, NotFoundError, etc.
- Automatic error response formatting: Consistent JSON error responses
- Integration with route resolution: Error handling during request processing
Integration Points:
- BaseRouter: Provides error handler registration methods following RFC RFC: Event Handler for REST APIs #3500
- Route Resolution: Catches and handles errors during request processing
- Response Handling: Converts error responses to Lambda proxy format
Data Flow:
Route Handler throws Error
↓
BaseRouter.resolve() catches error
↓
ExceptionHandlerManager.handle()
↓
Find registered handler or use default
↓
Format error response
↓
Convert to Lambda proxy format
Alternative solutions
Acknowledgment
- This feature request meets Powertools for AWS Lambda (TypeScript) Tenets
- Should this be considered in other Powertools for AWS Lambda languages? i.e. Python, Java, and .NET
Future readers
Please react with 👍 and your use case to help us understand customer demand.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status
On hold