Phase 2 — Building REST APIs lesson-0013

Error Handling

Production error handling is a system, not an afterthought. Get it right and every bug becomes a clear log entry. Get it wrong and every production incident is a forensic investigation.

Two Categories of Error

Every error in a backend falls into one of two categories. Treating them differently is essential:

Operational Errors

Expected problems with external inputs or state. The app is healthy — this specific operation failed.

  • User not found (404)
  • Invalid input data (400)
  • Email already registered (409)
  • Unauthenticated request (401)
  • Database record missing (404)

→ Return a helpful error response. Continue running.

Programmer Errors

Bugs in your code. Unexpected state. The app itself is unhealthy.

  • Cannot read property of undefined
  • Stack overflow
  • Incorrect function argument types
  • Unhandled edge cases
  • Memory leaks

→ Log fully. Consider restarting. Never trust state.

Step 1 — Custom Error Class Hierarchy

// src/errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

// 400 — malformed request
class BadRequestError extends AppError {
  constructor(message = 'Bad request') {
    super(message, 400, 'BAD_REQUEST');
  }
}

// 400 — with field-level validation details
class ValidationError extends AppError {
  constructor(details) {
    super('Validation failed', 400, 'VALIDATION_ERROR');
    this.details = details; // field-level errors from Zod
  }
}

// 401 — not authenticated
class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

// 403 — authenticated but not allowed
class ForbiddenError extends AppError {
  constructor(message = 'Access denied') {
    super(message, 403, 'FORBIDDEN');
  }
}

// 404 — resource not found
class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

// 409 — conflict (duplicate email, etc)
class ConflictError extends AppError {
  constructor(message = 'Conflict') {
    super(message, 409, 'CONFLICT');
  }
}

// 422 — semantically invalid
class UnprocessableError extends AppError {
  constructor(message = 'Unprocessable entity') {
    super(message, 422, 'UNPROCESSABLE');
  }
}

// 429 — too many requests
class TooManyRequestsError extends AppError {
  constructor(retryAfter) {
    super('Too many requests', 429, 'RATE_LIMITED');
    this.retryAfter = retryAfter;
  }
}

module.exports = {
  AppError, BadRequestError, ValidationError, UnauthorizedError,
  ForbiddenError, NotFoundError, ConflictError, UnprocessableError,
  TooManyRequestsError,
};

Step 2 — Throw From Anywhere

const { NotFoundError, ForbiddenError, ConflictError } = require('../errors/AppError');

const createUser = async (req, res) => {
  // Check for duplicate email
  const existing = await db.user.findUnique({ where: { email: req.body.email } });
  if (existing) throw new ConflictError('Email already in use');

  const user = await db.user.create({ data: req.body });
  res.status(201).json({ data: user });
};

const updateUser = async (req, res) => {
  const user = await db.user.findUnique({ where: { id: +req.params.id } });
  if (!user) throw new NotFoundError('User');

  // Only the owner or admin can update
  if (user.id !== req.user.id && req.user.role !== 'admin') {
    throw new ForbiddenError('Cannot update another user\'s profile');
  }

  const updated = await db.user.update({
    where: { id: user.id },
    data: req.body,
  });
  res.json({ data: updated });
};

Step 3 — The Central Error Handler

// src/middleware/errorHandler.js
const { AppError } = require('../errors/AppError');

// Translate third-party library errors into our AppError format
const normalise = (err) => {
  // Prisma errors — https://www.prisma.io/docs/orm/reference/error-reference
  if (err.code === 'P2002')  // Unique constraint violation
    return new AppError(`${err.meta?.target?.join(', ')} already exists`, 409, 'CONFLICT');
  if (err.code === 'P2025')  // Record not found
    return new AppError('Record not found', 404, 'NOT_FOUND');
  if (err.code === 'P2003')  // Foreign key constraint
    return new AppError('Related record not found', 400, 'BAD_REQUEST');

  // JWT errors
  if (err.name === 'JsonWebTokenError')
    return new AppError('Invalid token', 401, 'UNAUTHORIZED');
  if (err.name === 'TokenExpiredError')
    return new AppError('Token expired — please log in again', 401, 'TOKEN_EXPIRED');
  if (err.name === 'NotBeforeError')
    return new AppError('Token not yet valid', 401, 'UNAUTHORIZED');

  // express.json() body parse failure
  if (err.type === 'entity.parse.failed')
    return new AppError('Invalid JSON body', 400, 'INVALID_JSON');

  // express.json() body too large
  if (err.type === 'entity.too.large')
    return new AppError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');

  return err; // already an AppError, or unknown — handled below
};

// Production error handler — MUST have 4 parameters
const errorHandler = (err, req, res, next) => {
  const error = normalise(err);

  // Build structured log payload
  const logPayload = {
    type:      'error',
    requestId: req.id,
    method:    req.method,
    path:      req.path,
    userId:    req.user?.id,
    status:    error.statusCode ?? 500,
    code:      error.code,
    message:   error.message,
    stack:     error.stack,
  };

  // Operational errors are expected — log as warning
  // Programmer errors are bugs — log as error, consider restarting
  if (error.isOperational) {
    console.warn(JSON.stringify(logPayload));
  } else {
    console.error('[CRITICAL]', JSON.stringify(logPayload));
    // TODO: alert on-call engineer (PagerDuty, OpsGenie, etc.)
  }

  // If headers already sent, delegate to Express default handler
  if (res.headersSent) {
    return next(err);
  }

  // Operational: reveal the specific error message
  if (error.isOperational) {
    return res.status(error.statusCode).json({
      error: {
        code:      error.code,
        message:   error.message,
        requestId: req.id,
        ...(error.details   && { details: error.details }),
        ...(error.retryAfter && { retryAfter: error.retryAfter }),
      }
    });
  }

  // Unknown/programmer error: hide internals, expose only requestId
  res.status(500).json({
    error: {
      code:      'INTERNAL_ERROR',
      message:   'An unexpected error occurred',
      requestId: req.id,
    }
  });
};

module.exports = errorHandler;

Error Flow — The Complete Picture

Request arrives
POST /api/v1/users with invalid JSON body
express.json()
Fails to parse body. Calls next(SyntaxError).
↙ next(err) called — skips all remaining regular middleware and routes ↘
errorHandler(err, req, res, next)
Receives the SyntaxError. normalise() maps it to 400 INVALID_JSON. isOperational = true. Returns { error: { code: 'INVALID_JSON', message: 'Invalid JSON body' } }.
Response sent
HTTP 400 with consistent error shape.

Handling Unhandled Rejections & Exceptions

// src/index.js — these catch anything that escapes Express entirely

process.on('unhandledRejection', (reason, promise) => {
  // An async function threw and the rejection was never caught
  console.error('[FATAL] Unhandled rejection:', reason);
  // Graceful shutdown — don't accept new requests, finish existing
  server.close(() => process.exit(1));
  setTimeout(() => process.exit(1), 10_000);
});

process.on('uncaughtException', (err) => {
  // A synchronous throw that wasn't caught anywhere
  // The process is in an unknown state — you MUST exit
  console.error('[FATAL] Uncaught exception:', err);
  process.exit(1);
});
Why Exit on uncaughtException?
After an uncaught exception, the process is in an indeterminate state — variables may be corrupted, file handles left open, database transactions incomplete. Continuing to serve requests from this state causes unpredictable bugs. Always exit and let your process manager (PM2, Kubernetes) restart the process cleanly.

Structured Error Logging for Production

// Production apps use structured JSON logs so tools like Datadog,
// CloudWatch, or Elastic can parse and alert on them automatically

// Instead of:
console.error('Error processing payment:', err.message);

// Do this — every field is queryable in your log aggregation tool:
logger.error({
  event:     'payment_failed',
  requestId: req.id,
  userId:    req.user?.id,
  orderId:   req.params.id,
  error:     {
    code:    err.code,
    message: err.message,
    stack:   err.stack,
  },
});

🧠 Check Your Understanding


Go Deeper

Primary source: Express — Error Handling — read the entire page, especially the section on writing error handlers.

Book: Node.js Design Patterns Ch. 3 — async error handling patterns in depth.

Ask your teacher: "How do I integrate Sentry error monitoring into this error handler?" or "Show me the isOperational pattern from Joyent's production Node.js best practices."