Error Handling Patterns
Failing predictably instead of crashing — consistent error shapes, status codes, and what never to leak.
3 min read
Every backend fails sometimes — bad input, a downstream service timing out, a database briefly unreachable. What separates a reliable backend from a fragile one isn't the absence of errors, it's whether those errors are handled predictably instead of producing an inconsistent mess or crashing the whole process.
A consistent error response shape
Clients need to parse errors programmatically, which means every error response from an API should follow the same shape, not a different structure per endpoint:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is already in use",
"field": "email"
}
}Pairing a machine-readable code with a human-readable message lets a frontend branch on the code (show a specific inline error) while still having something safe to log or display as a fallback for codes it doesn't specifically handle.
Matching errors to status codes
Different failure categories deserve different status codes (covered in more depth in the HTTP methods lesson): client mistakes get 4xx (400 malformed input, 404 not found, 409 conflict), server-side failures get 5xx. Returning 200 OK with an error message buried in the body — a real pattern in some older APIs — forces every client to inspect the body just to know if a request even succeeded.
Catching errors at the right layer
Individual handlers shouldn't need a try/catch around every possible failure. A common pattern is a single error-handling middleware (see the middleware lesson) at the end of the chain that catches anything unhandled and turns it into a clean response:
// pseudocode
function errorHandlingMiddleware(request, next):
try:
return next(request)
catch AppError as e:
return Response(e.statusCode, { error: { code: e.code, message: e.message } })
catch UnexpectedError as e:
log.error(e)
return Response(500, { error: { code: "INTERNAL_ERROR", message: "Something went wrong" } })
This separates expected errors your code deliberately raises (validation failures, not-found) from unexpected ones (a bug, an unhandled edge case) — both get handled safely, but only the unexpected kind needs to trigger alerting, since expected errors are just normal control flow.
Never leak internals in production
A stack trace, a raw database error message, or an internal file path in an API response is a genuine security issue, not just an aesthetic one — it can reveal your database schema, library versions, or server file structure to anyone who triggers an error on purpose. Log the full detail server-side; return a generic, safe message to the client:
// Never in production:
{ "error": "duplicate key value violates unique constraint \"users_email_key\"" }
// Instead:
{ "error": { "code": "EMAIL_TAKEN", "message": "This email is already registered" } }Fail fast on the truly unrecoverable
Not every error should be caught and smoothed over — if a backend can't connect to its database at startup at all, crashing immediately with a clear log message is more honest (and easier to detect and alert on) than limping along and failing every individual request mysteriously later.
Handling individual errors well is necessary but not sufficient — a backend also needs visibility into what's happening across all its requests over time, which is where logging and observability come in.