| name | nodejs-error-handling |
| description | Guides expert-level Node.js error handling: Express error middleware, unhandled rejection handling, domain errors, structured logging integration, error classification, and graceful degradation.
Use when the user asks about Node.js error handling, Express error middleware, unhandled rejections, structured logging, error propagation, graceful shutdown.
Do NOT use when the user asks about JavaScript idioms (use `javascript-idioms`), Node.js async patterns (use `nodejs-async-patterns`), general error handling concepts (use language-specific skills).
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"javascript backend debugging","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"intermediate"} |
Node.js Error Handling
When to Use
Use this skill when the user is:
- Designing or refactoring error handling architecture for a Node.js application or service, including Express, Fastify, Koa, or raw
http module servers
- Debugging unhandled promise rejections, uncaught exceptions, or silent error swallowing in production or development
- Building Express (or Fastify/Koa) error middleware and needs to understand the four-argument
(err, req, res, next) signature, ordering rules, and async pitfalls
- Setting up
process.on('unhandledRejection') and process.on('uncaughtException') handlers with correct shutdown sequencing
- Integrating structured error logging (Pino, Winston, Bunyan) with error classification, correlation IDs, and severity levels
- Implementing domain-specific error classes, error codes, and typed error hierarchies for a module or package boundary
- Designing graceful shutdown logic that drains in-flight requests before terminating
- Classifying errors by recoverability (operational vs. programmer errors) and choosing appropriate propagation strategies
Do NOT use this skill when the user is asking about:
- General JavaScript async/await patterns, Promise chaining, or EventEmitter usage -- use
nodejs-async-patterns instead
- JavaScript language idioms, destructuring, closures, or prototype chains -- use
javascript-idioms instead
- General software error handling theory not specific to Node.js -- use a language-agnostic design skill
- Python, Go, Java, or other runtime error handling -- use a language-specific skill for that runtime
- Client-side browser JavaScript error handling (window.onerror, error boundaries) -- use a frontend-specific skill
- Database query error handling specifics (PostgreSQL error codes, MongoDB write concern errors) -- use the relevant database skill and reference this skill only for the Node.js integration layer
Process
1. Classify the Error Before Writing Any Handler
Node.js errors fall into two fundamental categories defined by Joyent's original design philosophy. Every handling decision flows from this classification.
- Operational errors are expected failures at runtime: network timeouts, DNS resolution failures, ENOENT file-not-found, ECONNRESET, EACCES, HTTP 4xx/5xx from downstream services, invalid user input, out-of-disk-space. These are conditions a correct program must anticipate and handle gracefully.
- Programmer errors are bugs: calling a function with wrong argument types, reading a property on
undefined, off-by-one array access, unresolved promises left floating. These should never be caught and silently swallowed -- they should crash the process or at minimum emit a loud alert so the bug is found and fixed.
- Apply the litmus test: "Could this happen in production even with correct code?" -- yes means operational, no means programmer error.
- Never use
try/catch around code specifically to suppress programmer errors. A caught TypeError: Cannot read properties of undefined that logs "something went wrong" is the worst outcome -- the bug hides.
- Mark operational errors explicitly in custom error classes with
this.isOperational = true. Use this flag in top-level handlers to decide whether to restart the process.
- Errors from third-party modules may not follow this convention -- check if the library exposes typed errors or error codes (e.g., Axios wraps responses in
AxiosError with error.response.status).
2. Design a Typed Error Hierarchy
Raw Error objects carry only a message and a stack. Build a hierarchy that carries machine-readable context without leaking implementation details.
- Create a
BaseError class that extends Error and sets this.name, this.isOperational, an errorCode string (e.g., 'PAYMENT_GATEWAY_TIMEOUT'), and an HTTP statusCode (e.g., 503):
class BaseError extends Error {
constructor(message, { errorCode, statusCode = 500, isOperational = true, cause } = {}) {
super(message, { cause });
this.name = this.constructor.name;
this.errorCode = errorCode;
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
Error.captureStackTrace removes the constructor frame from the stack trace, making it cleaner in logs. Always include it.
- The
cause option (available natively in Node 16.9+) chains the originating error. Always preserve the original cause when wrapping.
- Derive specific error subtypes:
ValidationError extends BaseError (statusCode 400, isOperational true), NotFoundError (404), AuthenticationError (401), AuthorizationError (403), ConflictError (409), ServiceUnavailableError (503), DatabaseError (500, isOperational may vary).
- Export these from a dedicated
errors/ directory. Application code imports named error types -- never constructs raw new Error('...') at boundaries.
- Include a structured
context payload for machine-readable detail: { userId, resourceId, attemptedOperation }. This populates log fields without leaking to API consumers.
- Define error codes as constants in a shared enum or object:
ERROR_CODES.USER_NOT_FOUND = 'USER_NOT_FOUND'. This prevents magic strings and enables exact matching in tests and monitoring dashboards.
3. Handle Errors at the Correct Layer
Each architectural layer has a specific responsibility. Getting this wrong creates either leaky abstractions or silent swallowing.
- Data/repository layer: Translate database driver errors (
pg DatabaseError, Mongoose ValidationError, ECONNREFUSED on pool exhaustion) into domain DatabaseError or ValidationError types. Never let pg.DatabaseError propagate above this layer. Wrap with cause: throw new DatabaseError('User lookup failed', { cause: pgError, context: { userId } }).
- Service layer: Handle business rule violations here. Throw
ConflictError for duplicate resource creation. Do not catch errors you cannot meaningfully recover from -- let them propagate.
- Controller/route layer: This is the last catch point before the framework error handler. In async routes, all unhandled rejections must be caught. In Express 4.x, async route handlers do NOT automatically forward errors -- use
next(err) explicitly or wrap handlers.
- Framework error middleware: This is the centralized point for translating domain errors to HTTP responses and emitting structured log entries. It never throws.
- Process level:
uncaughtException and unhandledRejection -- last resort only. These are not substitutes for proper per-layer handling.
4. Wire Up Express Error Middleware Correctly
Express error handling has specific ordering and signature requirements that cause subtle bugs when violated.
- Error middleware must have exactly four parameters
(err, req, res, next). Express detects this by function.length. Arrow functions that destructure or rest-collect arguments may report incorrect length -- always use named function declarations or expressions for error middleware.
- Register error middleware AFTER all route definitions and non-error middleware. Registration order in Express is the execution order.
- Distinguish between
404 Not Found (no route matched) and actual errors. Insert a catch-all route handler just before the error middleware:
app.use((req, res, next) => {
next(new NotFoundError(`Route ${req.method} ${req.path} not found`, { errorCode: 'ROUTE_NOT_FOUND' }));
});
app.use(errorHandler);
- The central
errorHandler function follows this pattern:
function errorHandler(err, req, res, next) {
if (res.headersSent) {
return next(err);
}
const statusCode = err.statusCode ?? 500;
const isOperational = err.isOperational ?? false;
req.log.error({
err,
errorCode: err.errorCode,
statusCode,
requestId: req.id,
userId: req.user?.id,
}, 'Request error');
if (!isOperational) {
triggerGracefulShutdown(err);
}
res.status(statusCode).json({
error: {
code: err.errorCode ?? 'INTERNAL_ERROR',
message: isOperational ? err.message : 'An unexpected error occurred',
requestId: req.id,
},
});
}
- For async route handlers in Express 4.x, create a
asyncHandler wrapper:
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
router.get('/users/:id', asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
res.json(user);
}));
- Express 5.x (currently in release candidate as of Node.js LTS compatibility) natively handles async route errors --
next is called automatically on rejected promises. If using Express 5, the asyncHandler wrapper is unnecessary but harmless.
- Never call
next(err) after res.json() or res.send() -- this causes "Cannot set headers after they are sent" errors. The if (res.headersSent) guard in the error middleware is the safety net, but always structure routes to avoid this.
5. Implement Process-Level Handlers with Correct Shutdown Sequencing
Process-level handlers are safety nets, not primary error handling. But they must be implemented correctly or they create worse problems than they solve.
- Set up
unhandledRejection and uncaughtException before any other application code runs -- in the very first lines of your entry point:
process.on('unhandledRejection', (reason, promise) => {
logger.fatal({
err: reason instanceof Error ? reason : new Error(String(reason)),
promise,
}, 'Unhandled promise rejection -- initiating shutdown');
triggerGracefulShutdown(reason);
});
process.on('uncaughtException', (err, origin) => {
logger.fatal({ err, origin }, 'Uncaught exception -- initiating shutdown');
triggerGracefulShutdown(err);
});
- Critical: After an
uncaughtException, the process is in an undefined state. The Node.js documentation explicitly warns that continuing after uncaughtException can cause memory leaks, file descriptor leaks, and unpredictable behavior. Always exit. Calling triggerGracefulShutdown is appropriate -- but ensure the shutdown itself cannot throw.
- In Node.js 15+, an unhandled rejection causes process exit by default with exit code 1. In Node.js 14 and earlier, it only emits a deprecation warning. Ensure your version-aware handler matches the runtime behavior.
- Use
--unhandled-rejections=throw flag (available since Node 12.0) in Node.js 14 and earlier to opt into the Node.js 15+ behavior explicitly.
- The
graceful shutdown function:
let isShuttingDown = false;
async function triggerGracefulShutdown(err) {
if (isShuttingDown) return;
isShuttingDown = true;
logger.info('Graceful shutdown initiated');
server.close(async () => {
try {
await Promise.all([
db.pool.end(),
redisClient.quit(),
]);
logger.info('Graceful shutdown complete');
process.exit(err ? 1 : 0);
} catch (cleanupErr) {
logger.error({ err: cleanupErr }, 'Error during cleanup -- forcing exit');
process.exit(1);
}
});
setTimeout(() => {
logger.error('Graceful shutdown timeout -- forcing exit');
process.exit(1);
}, 10_000).unref();
}
- Wire shutdown to
SIGTERM and SIGINT (for Ctrl-C in development and container orchestrator termination in production):
process.on('SIGTERM', () => triggerGracefulShutdown(null));
process.on('SIGINT', () => triggerGracefulShutdown(null));
- Kubernetes sends
SIGTERM and then SIGKILL after a configurable grace period (default 30 seconds). Your shutdown timeout should be shorter than the pod's terminationGracePeriodSeconds.
6. Integrate Structured Logging with Error Context
Errors are only actionable if log records contain enough context to diagnose and reproduce.
- Use Pino as the preferred logger -- it is 5-8x faster than Winston for high-throughput services due to its JSON serialization strategy and avoidance of synchronous string formatting. For teams that need Winston's transport ecosystem, it remains a valid choice.
- Configure Pino's built-in
err serializer, which automatically captures message, stack, type, and any enumerable properties on the error object:
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
serializers: {
err: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
});
- Always log errors with
logger.error({ err }, 'Descriptive message') -- not logger.error(err.message). The structured form preserves the full error object in the JSON payload.
- Attach a
requestId (also called correlationId or traceId) to every log record in the request lifecycle. Use express-request-id or set req.id = crypto.randomUUID() in a first-middleware. Create a child logger per request: req.log = logger.child({ requestId: req.id, userId: req.user?.id }).
- Severity mapping for errors:
warn for recoverable operational errors (rate limiting, cache miss with fallback), error for operational errors that failed a request, fatal for programmer errors or process-threatening conditions.
- Never log sensitive data in error context: passwords, credit card numbers, session tokens, full PII. Redact these fields at the logger configuration level using Pino's
redact option: redact: ['req.headers.authorization', 'req.body.password'].
- In distributed systems, propagate the trace ID through downstream HTTP calls using headers (
X-Request-ID, X-Trace-ID, or OpenTelemetry traceparent). This connects log records across services.
7. Design API Error Response Contracts
The shape of error responses must be consistent and agreed upon with API consumers. Inconsistency is a primary source of client-side bugs.
- Adopt a consistent error response body schema. The following schema is production-proven and compatible with most API client expectations:
{
"error": {
"code": "USER_NOT_FOUND",
"message": "No user exists with the specified identifier",
"requestId": "a3f9c12b-8d4e-4f2a-b1c7-0e9d5a2f8b3c",
"details": [
{ "field": "userId", "issue": "Resource does not exist" }
]
}
}
- The
code field is machine-readable and stable across versions. The message field is human-readable and may change. Client code should branch on code, not message.
- The
details array carries field-level validation errors, modeled after Google's API error design guide. This is particularly useful for 400 responses with multiple invalid fields.
- Map status codes with precision:
- 400 -- malformed request body, type coercion failure, missing required field
- 401 -- authentication required or token expired (include
WWW-Authenticate header)
- 403 -- authenticated but lacks permission (do not reveal whether the resource exists)
- 404 -- resource not found OR route not found
- 409 -- conflict (duplicate email, optimistic lock version mismatch)
- 422 -- request is syntactically valid but semantically invalid (business rule violation)
- 429 -- rate limit exceeded (include
Retry-After header in seconds)
- 500 -- programmer error or unclassified failure (never expose internals)
- 502/503/504 -- upstream dependency failure (safe to retry with backoff)
- For validation libraries: Zod's
ZodError can be converted to the details array format; Joi's ValidationError has a details array with path and message per field. Build adapters in the error middleware to normalize these into your schema.
8. Validate and Test the Error Handling System
Error handling code is frequently untested because errors are hard to trigger deliberately. This gap causes production surprises.
- Test the
errorHandler middleware directly: create a mock err, req, res, next and call it. Assert on res.status() and res.json() call arguments.
- Test that async route handlers propagate to error middleware: use Supertest to make a request, mock the service to throw a specific error type, and assert on the HTTP response code and body
error.code.
- Test the custom error classes: instantiate each, assert on
isOperational, statusCode, errorCode, stack presence, and cause chaining.
- Test
unhandledRejection and uncaughtException handlers using process.emit('unhandledRejection', new Error('test')) in a sandboxed test context. Assert that the shutdown function is called without actually calling process.exit (mock it).
- Use fault injection to verify integration: in development, instrument a middleware that randomly throws or rejects based on an environment variable (
CHAOS_ERROR_RATE=0.01). This stress-tests error paths before production exposes them.
- Monitor error rates in production by exposing a
/metrics endpoint (Prometheus format) with counters per errorCode. A sudden spike in DATABASE_CONNECTION_FAILED errors is immediately actionable; a spike in generic 500s is not.
Output Format
When helping a user with Node.js error handling, produce output organized as follows:
## Error Handling Architecture: [Context Summary]
### Error Classification
| Error Type | Class Name | statusCode | isOperational | errorCode Example |
|--------------------|-------------------------|------------|---------------|----------------------------|
| Validation failure | ValidationError | 400 | true | INVALID_EMAIL_FORMAT |
| Auth required | AuthenticationError | 401 | true | TOKEN_EXPIRED |
| Permission denied | AuthorizationError | 403 | true | INSUFFICIENT_PERMISSIONS |
| Resource missing | NotFoundError | 404 | true | USER_NOT_FOUND |
| Business conflict | ConflictError | 409 | true | EMAIL_ALREADY_REGISTERED |
| Upstream failure | ServiceUnavailableError | 503 | true | PAYMENT_GATEWAY_TIMEOUT |
| Programmer error | (native Error) | 500 | false | (none -- crash and fix) |
### Error Class Definitions
[Concrete TypeScript or JavaScript code for BaseError and all relevant subtypes]
### Express Middleware Stack Order
[Numbered list of middleware in registration order, with notes on what each handles]
### Central Error Handler Implementation
[Complete errorHandler function code]
### Process-Level Handler Setup
[process.on('unhandledRejection') and process.on('uncaughtException') with shutdown logic]
### Graceful Shutdown Function
[Complete triggerGracefulShutdown implementation]
### Structured Log Integration
[Logger setup and per-request child logger pattern]
### API Error Response Schema
[JSON schema and example response body]
### Testing Checklist
- [ ] BaseError and subclass unit tests (isOperational, statusCode, errorCode, cause)
- [ ] errorHandler middleware unit test (mocked req/res/next)
- [ ] Async route error propagation integration test (Supertest)
- [ ] unhandledRejection handler test (sandboxed process.emit)
- [ ] Shutdown sequencing test (mock process.exit)
- [ ] 404 catch-all route test
- [ ] Sensitive field redaction test (assert logs contain no password field)
Rules
-
Never swallow errors silently. An empty catch (err) {} block is always wrong. At minimum, throw err to re-propagate. Silently swallowed errors create ghost failures that appear as data corruption or inconsistency rather than errors, which are orders of magnitude harder to diagnose.
-
Never use uncaughtException as a general-purpose error handler. It exists solely to log and initiate shutdown. Recovering application state after an uncaught exception is undefined behavior -- process memory may be corrupted, file descriptors may be leaked, and ongoing transactions may be in unknown states.
-
Always use Error.captureStackTrace(this, this.constructor) in custom error constructors. Without it, the stack trace includes frames from within the error constructor itself, adding noise and obscuring the actual call site.
-
Always preserve the original error as cause when wrapping. Throwing new DatabaseError('Failed') and discarding the original pgError destroys the ability to diagnose root causes. Use new DatabaseError('Failed', { cause: pgError }).
-
Never let database-layer error types (pg DatabaseError, Mongoose MongoServerError) escape their layer. These expose implementation details (table names, constraint names, driver-specific codes) to layers that should not know about them. Map them to domain error types at the repository boundary.
-
Always guard against res.headersSent in Express error middleware. If a route handler partially writes a response before throwing, the error middleware must delegate to next(err) rather than attempt to write another response. Failing to check this causes "ERR_HTTP_HEADERS_SENT" crashes.
-
Always set a shutdown timeout and call .unref() on it. A shutdown that waits indefinitely for in-flight requests will block container orchestration (Kubernetes will SIGKILL after terminationGracePeriodSeconds, but this is configurable and often misconfigured). Setting a hard timeout -- typically 5-15 seconds -- ensures deterministic shutdown. .unref() prevents the timer itself from keeping the process alive if everything else exits cleanly first.
-
Never log the full error object with logger.error(err.message) as a string. This discards the stack, error code, and context fields. Always log as a structured field so the logger's serializer captures all properties.
Edge Cases
Middleware That Calls next() After Sending a Response
This occurs when a developer calls res.json() and then returns to later logic (or a callback) that also calls next(). The result is either a duplicate response attempt or ERR_HTTP_HEADERS_SENT. Handle by treating every response-writing operation as a function exit: use return res.json(...) always, never bare res.json(...). In the error handler, the if (res.headersSent) return next(err) guard is the last line of defense but should not be relied upon as the primary mechanism.
Errors Inside Error Middleware
If the central error handler itself throws (e.g., the logger is not initialized, or the errorCode lookup fails), Express will invoke its own built-in error handler, which returns a plain text response and may expose a stack trace in development. Protect against this by wrapping the entire error handler body in a try/catch and using a fallback response:
function errorHandler(err, req, res, next) {
try {
} catch (handlerErr) {
console.error('Error in error handler:', handlerErr);
if (!res.headersSent) {
res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } });
}
}
}
Async Errors in Non-Route Middleware
Express 4.x only automatically propagates errors to the error handler when next(err) is called. In middleware that runs outside of the route dispatch cycle (e.g., a background job, a WebSocket handler, a setInterval callback), there is no next to call. These errors must use process.on('uncaughtException') as a catch point, which means they trigger shutdown. To avoid this, wrap every background operation's top-level function in a try/catch that logs and makes a recovery decision explicitly.
Rate-Limiting and Retry-After Headers
429 Too Many Requests responses require a Retry-After header to be useful to clients. When throwing a rate limit error, include the retry delay in seconds as part of the error's context: new RateLimitError('Rate limit exceeded', { retryAfterSeconds: 60 }). The error middleware should read this value and set the header: res.set('Retry-After', String(err.retryAfterSeconds ?? 60)). Without this header, clients implementing automatic retry logic will hammer the service immediately again.
Validation Errors with Multiple Field Failures
Throwing and catching one ValidationError per field produces poor user experience -- the user fixes one error, resubmits, and discovers the next. Accumulate all validation failures before throwing. If using Zod, call schema.safeParse(data) and collect error.issues. If using Joi, pass { abortEarly: false } to schema.validate(). The error middleware then maps the array of issues to the details[] field in the response body. Test that the error response for a multi-field invalid body contains all failures in a single response.
Errors in Database Transactions
When an error occurs inside a database transaction, the error must be re-thrown after rolling back -- not swallowed after rollback. A common pattern:
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
Failing to re-throw after rollback causes the calling service to believe the operation succeeded, creating data consistency bugs that are extremely difficult to trace.
TypeScript Projects: Narrowing unknown in Catch Blocks
In TypeScript with useUnknownInCatchVariables: true (the default in strict mode since TypeScript 4.4), caught errors are typed as unknown, not Error. Code that does catch (err) { logger.error(err.message) } fails to compile. Always narrow before accessing error properties:
function toError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(String(value));
}
try {
} catch (rawErr) {
const err = toError(rawErr);
logger.error({ err }, 'Operation failed');
throw err;
}
For custom error type guards: function isValidationError(err: unknown): err is ValidationError { return err instanceof ValidationError; }. Use these in middleware to extract typed properties safely.
Worker Threads and Cluster Mode
In Node.js worker_threads, unhandled errors inside a worker do not propagate to the main thread automatically -- the worker emits an 'error' event on the Worker instance. The main thread must listen: worker.on('error', (err) => { ... }). In cluster mode, unhandled exceptions in a worker process cause that worker to exit, and the master process receives a 'exit' event -- implement auto-restart logic there, not inside the worker. Never use process.on('uncaughtException') in a worker as a substitute for proper error propagation to the master.
Example
Input: "I'm building an Express REST API in TypeScript for a fintech application. We've had silent failures in production where database errors aren't reaching the error handler, and our error responses are inconsistent -- sometimes plain text, sometimes JSON. We need to fix this properly."
Output:
Error Handling Architecture: Fintech Express REST API
This diagnosis is common and has a known root cause: async route handlers in Express 4.x do not automatically propagate rejected promises to the error handler. Combined with inconsistent response formatting, the fix requires three coordinated changes.
Root Cause Diagnosis
Problem 1: Silent database errors
Express 4.x route handlers (req, res) => {} do not wrap async functions. A rejected promise from an async handler is an unhandled rejection -- it never reaches the error middleware.
Problem 2: Inconsistent response format
Without a central error handler, different routes format errors differently -- some call res.status(500).send('Error'), others call res.json({ message: err.message }). The fix centralizes all error formatting in one place.
Step 1: Error Class Hierarchy
export class BaseError extends Error {
public readonly errorCode: string;
public readonly statusCode: number;
public readonly isOperational: boolean;
public readonly context?: Record<string, unknown>;
constructor(
message: string,
options: {
errorCode: string;
statusCode?: number;
isOperational?: boolean;
context?: Record<string, unknown>;
cause?: unknown;
}
) {
super(message, { cause: options.cause });
this.name = this.constructor.name;
this.errorCode = options.errorCode;
this.statusCode = options.statusCode ?? 500;
this. = options. ?? ;
. = options.;
.(, .);
}
}
{
() {
(message, { : , : , context });
}
}
{
() {
(, {
: ,
: ,
: { resource, id },
});
}
}
{
() {
(message, { errorCode, : });
}
}
{
() {
(message, {
: ,
: ,
: ,
cause,
});
}
}
{
: ;
() {
(message, { errorCode, : });
. = retryAfterSeconds;
}
}
Step 2: asyncHandler Wrapper (Fix for Silent Failures)
import { Request, Response, NextFunction, RequestHandler } from 'express';
type AsyncRequestHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;
export function asyncHandler(fn: AsyncRequestHandler): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
Apply to every async route handler:
router.get('/accounts/:id', async (req, res) => {
const account = await accountService.findById(req.params.id);
res.json(account);
});
router.get('/accounts/:id', asyncHandler(async (req, res) => {
const account = await accountService.findById(req.params.id);
res.json(account);
}));
Step 3: Repository Layer Error Translation
import { DatabaseError } from '../errors';
export class AccountRepository {
async findById(id: string): Promise<Account> {
try {
const result = await db.query('SELECT * FROM accounts WHERE id = $1', [id]);
if (result.rows.length === 0) {
throw new NotFoundError('Account', id);
}
return result.rows[0];
} catch (err) {
if (err instanceof NotFoundError) throw err;
throw new DatabaseError('Failed to retrieve account', err);
}
}
}
Step 4: Central Error Handler
import { Request, Response, NextFunction } from 'express';
import { BaseError, ServiceUnavailableError, ValidationError } from '../errors';
interface ErrorResponseBody {
error: {
code: string;
message: string;
requestId: string;
details?: Array<{ field: string; issue: string }>;
};
}
export function errorHandler(
err: unknown,
req: Request,
res: Response,
next: NextFunction
): void {
if (res.headersSent) {
return next(err);
}
try {
let statusCode = 500;
let errorCode = 'INTERNAL_ERROR';
let message = 'An unexpected error occurred';
let : <{ : ; : }> | ;
(err ) {
statusCode = err.;
errorCode = err.;
message = err. ? err. : ;
(err && err.?.) {
details = err.. <{ : ; : }>;
}
(err ) {
res.(, (err.));
}
}
logPayload = {
err,
errorCode,
statusCode,
: req.,
: (req ).?.,
: req.,
: req.,
};
(statusCode >= ) {
req..(logPayload, );
} {
req..(logPayload, );
}
isOperational = err ? err. : ;
(!isOperational) {
( (err ? err : ((err))));
}
: = {
: { : errorCode, message, : req., ...(details ? { details } : {}) },
};
res.(statusCode).(body);
} (handlerErr) {
.(, handlerErr);
(!res.) {
res.().({ : { : , : , : req. } });
}
}
}
Step 5: App Registration Order
import express from 'express';
import pino from 'pino-http';
import { randomUUID } from 'crypto';
import { accountRouter } from './routes/accounts';
import { errorHandler } from './middleware/errorHandler';
import { NotFoundError } from './errors';
export const app = express();
app.use((req, _res, next) => { (req as any).id = randomUUID(); next(); });
app.use(pino({ logger }));
app.use(express.json());
app.use('/api/v1/accounts', accountRouter);
app.use((req, _res, next) => {
next(new NotFoundError('Route', ));
});
app.(errorHandler);
Step 6: Process-Level Setup in Entry Point
import { app } from './app';
import { logger } from './logger';
import { db } from './db';
const server = app.listen(process.env.PORT ?? 3000, () => {
logger.info({ port: process.env.PORT ?? 3000 }, 'Server started');
});
let isShuttingDown = false;
async function triggerGracefulShutdown(err: Error | null): Promise<void> {
if (isShuttingDown) return;
isShuttingDown = true;
logger.info('Graceful shutdown initiated');
server.close(async () => {
try {
await db.pool.end();
logger.info('Graceful shutdown complete');
process.exit(err ? 1 : 0);
} catch (cleanupErr) {
logger.error({ : cleanupErr }, );
process.();
}
});
( {
logger.();
process.();
}, ).();
}
process.(, {
err = reason ? reason : ((reason));
logger.({ err }, );
(err);
});
process.(, {
logger.({ err }, );
(err);
});
process.(, ());
process.(, ());
Error Classification Summary
| Error Type | Class | statusCode | isOperational | Expose Message to Client |
|---|
| Missing required field | ValidationError | 400 | true | Yes |
| JWT expired | AuthenticationError | 401 | true | Yes |
| Wrong account owner | AuthorizationError | 403 | true | Yes (generic) |
| Account not found | NotFoundError | 404 | true | Yes |
| Duplicate account | ConflictError | 409 | true | Yes |
| pg driver crash | DatabaseError | 500 | true | No (generic) |
| TypeError: undefined | (native Error) | 500 | false | No -- triggers shutdown |
| Payment gateway down | ServiceUnavailableError | 503 | true | Yes + Retry-After header |
Testing Checklist