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 type | Origin | Handling strategy |
|---|---|---|
| Operational error | External: network failure, database unavailability, third-party API timeout | Retry with backoff, circuit break, return safe error response |
| Programmer error | Internal: null pointer, invalid argument from own code, assertion failure | Crash fast (let the process restart); log with full context |
| Validation error | User input that fails schema or business rules | Return 4xx with structured error detail; do not retry |
| Domain error | Business rule violation: insufficient funds, expired subscription | Return 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