PushBackLog

Error Handling

Soft enforcement Complete by PushBackLog team
Topic: clean-code Topic: reliability Topic: security Skillset: backend Skillset: frontend Skillset: fullstack Technology: generic Stage: execution Stage: review

Error Handling

Status: Complete
Category: Clean Code
Default enforcement: Soft
Author: PushBackLog team


Tags

  • Topic: clean-code, reliability, security
  • Skillset: backend, frontend, fullstack
  • Technology: generic
  • Stage: execution, review

Summary

Correct error handling is the difference between a system that degrades gracefully and one that fails in ways that are invisible, misleading, or exploitable. Errors fall into several categories — operational errors, programmer errors, and validation errors — and each category requires different handling. The most common failures are swallowing errors silently, leaking internal details in error responses, and conflating different error types into a single handling path.


Rationale

Silent failures are the worst failures

A swallowed exception — caught and logged or ignored without propagation — is invisible to the caller. The caller proceeds assuming success. Downstream operations execute against corrupted or incomplete state. The actual failure surfaces later, far from its origin, in a form that is hard to trace. Silent failures are also the most dangerous class because they do not trigger alerts. A system that frequently throws unhandled exceptions is sick but observable; a system that silently swallows them is sick and invisible.

Error messages are a security boundary

Detailed stack traces, database error messages, internal file paths, and framework-specific error detail are useful to an attacker and useless to an end user. Leaking internal error detail in HTTP responses or client-visible messages is a direct contributor to OWASP A10 (Security Logging and Monitoring Failures) and A02 (Security Misconfiguration). Internal detail should be logged server-side with a correlation ID; the client should receive a safe, non-revealing error response that references the correlation ID for support purposes.


Guidance

Error taxonomy

Error typeOriginHandling strategy
Operational errorExternal: network failure, database unavailability, third-party API timeoutRetry with backoff, circuit break, return safe error response
Programmer errorInternal: null pointer, invalid argument from own code, assertion failureCrash fast (let the process restart); log with full context
Validation errorUser input that fails schema or business rulesReturn 4xx with structured error detail; do not retry
Domain errorBusiness rule violation: insufficient funds, expired subscriptionReturn structured error with domain-meaningful code; do not log as an error

Structured error types

Define explicit types for different error categories rather than throwing generic Error objects:

// Operational / infrastructure errors
class ExternalServiceError extends Error {
  constructor(
    public readonly service: string,
    public readonly operation: string,
    cause?: unknown
  ) {
    super(`External service error: ${service}.${operation}`);
    this.cause = cause;
  }
}

// Domain errors — business rule violations, not bugs
class DomainError extends Error {
  constructor(
    public readonly code: string,
    public readonly userMessage: string
  ) {
    super(userMessage);
  }
}

// Usage
throw new DomainError('INSUFFICIENT_BALANCE', 'Your balance is too low for this transaction.');
throw new ExternalServiceError('stripe', 'charge', originalError);

Error handling at the HTTP boundary

// Global error handler — maps error types to HTTP responses
function errorHandler(
  err: unknown,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const correlationId = req.headers['x-correlation-id'] ?? generateId();

  if (err instanceof ValidationError) {
    // 4xx — user's fault, include safe detail
    res.status(422).json({
      error: { code: err.code, message: err.message, correlationId }
    });
    return;
  }

  if (err instanceof DomainError) {
    // 4xx — business rule, include user-meaningful message
    res.status(400).json({
      error: { code: err.code, message: err.userMessage, correlationId }
    });
    return;
  }

  // 5xx — internal, never leak detail to caller
  logger.error({ err, correlationId, path: req.path }, 'Unhandled error');
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred.',
      correlationId
    }
  });
}

Result types over exceptions for expected failure states

In TypeScript, a Result<T, E> type makes fallible operations explicit at the type level and forces callers to handle both paths:

type Result<T, E extends Error = Error> =
  | { success: true; value: T }
  | { success: false; error: E };

async function findUser(id: string): Promise<Result<User, UserNotFoundError>> {
  const user = await userRepository.findById(id);
  if (!user) {
    return { success: false, error: new UserNotFoundError(id) };
  }
  return { success: true, value: user };
}

// Caller is forced to handle both cases
const result = await findUser(userId);
if (!result.success) {
  return res.status(404).json({ error: result.error.message });
}
// result.value is now type-narrowed to User

What not to do

// WRONG: Swallowing the error silently
try {
  await sendEmail(user.email, template);
} catch (e) {
  // ignored
}

// WRONG: Leaking internal detail to the client
catch (e) {
  res.status(500).json({ error: e.stack }); // Never
}

// WRONG: Over-broad catch that hides programmer errors
try {
  processOrder(order);
} catch (e) {
  logger.warn('Something went wrong');
  return null;
}

// BETTER: Catch what you can handle, let the rest propagate
try {
  await paymentGateway.charge(order);
} catch (e) {
  if (e instanceof PaymentDeclinedError) {
    return { declined: true, reason: e.declineCode };
  }
  throw e; // Re-throw what you cannot handle
}

Review checklist

  • No empty catch blocks or swallowed exceptions
  • Error responses do not include stack traces, internal paths, or database messages
  • All 5xx errors are logged with full context and a correlation ID
  • Errors from external services are wrapped with context before being rethrown
  • Retryable operations (external service calls) have retry logic with exponential backoff
  • Domain errors and operational errors are handled differently in the HTTP layer