Error handling is the design of what people and dependent systems can safely do when an intended operation cannot finish. A form validation error, an authorization denial, a slow payment provider, and a crashed worker may all become an exception in code, but they do not deserve the same message or recovery action. Engineering teams need a shared model that answers three questions: what outcome was attempted, what is known about its state, and who can act next. That model should be honest about uncertainty, protect sensitive diagnostics, and leave enough evidence for a later investigation.

Begin with the decision a failure interrupts
Describe the interrupted business action before choosing an exception class. A user submitting an invalid tax identifier can correct the input. A caller without permission should receive a safe denial. A provider timeout after a payment request may require a pending state because the provider could have completed the charge. A nightly import with malformed rows needs a report and a bounded replay path. The same HTTP 500 or JavaScript Error object cannot express these different situations. Write the expected outcome, the uncertainty window, the safe action, and the owner of an unresolved case as part of the feature contract.
Name who can act on each failure
A useful classification assigns the next action to a user, API client, operator, or system. User-correctable failures should identify the field or rule without erasing valid work. Client-retryable failures need a signal that the operation is safe to repeat. Operator-owned failures need a queue, evidence, and an escalation target. System-owned failures need bounded retries, a terminal state, and an alert that does not require someone to parse a stack trace. If no actor can say what to do next, the failure state is incomplete.
| Failure class | What the caller should learn | Safe next action |
|---|---|---|
| Invalid input | Which value or rule needs correction | Edit and resubmit |
| Not authorized | The requested action is not available | Use an approved path or request access |
| Temporary dependency | Completion is delayed or being checked | Wait or retry under a stated limit |
| Conflict or duplicate | The current state differs from the request | Refresh, reconcile, or choose a state |
| Unexpected defect | The action did not complete | Use a safe support or recovery route |
Give each boundary a stable error contract
At an HTTP boundary, use the transport status for broad semantics and a stable application code or problem type for the condition a client can handle. RFC 9457 Problem Details provides a standard shape for machine-readable errors; its detail text is intended to help a client correct a problem, not to expose debugging internals. HTTP Semantics remains the authority for method and status meaning. Keep the public contract versioned and testable. A client should not have to parse a changing sentence to distinguish an invalid request from an unavailable dependency.
Keep public detail useful and private detail protected
Show the implication and correction that a person can safely act on. Keep stack traces, query text, provider topology, account-existence clues, credentials, and unnecessary identifiers in protected telemetry. The OWASP Error Handling Cheat Sheet treats technical disclosure as a security concern because detailed errors help reconnaissance. Return a correlation reference that support can share without turning it into a bearer secret. The adjacent REST API contracts guide is useful when the error response is itself a dependency for other teams.
Make retry a property of the operation
Retrying is safe only when the operation and the failure class support it. A read after a transient network interruption may be retried with a limit. A payment or notification write needs idempotency, a durable request key, or a reconciliation check before a second attempt. If the client timed out after the provider accepted the request, report that the outcome is being checked rather than claiming a clean failure. Use exponential delay and a terminal state, and make the retry budget visible in metrics. A broad catch-and-retry wrapper is convenient at first but can amplify an outage or duplicate a side effect.
| Boundary | Unsafe default | Better recovery design |
|---|---|---|
| Form | Clear every field and show a generic alert | Preserve valid values and identify the correction |
| API client | Retry every 4xx and 5xx | Use status, problem type, idempotency, and a limit |
| Worker | Retry forever on one poison item | Bound attempts and move to an owned terminal queue |
| Payment | Ask the user to submit again after timeout | Reconcile the provider outcome with a request key |
| Operator console | Expose the raw exception | Show safe context, correlation, and a repair action |
Preserve investigation context with restraint
A diagnostic record should connect the operation, deployment version, dependency, timestamp, tenant-safe identifier, outcome, and correlation reference. It should not become a second customer database. OpenTelemetry's recording-errors guidance distinguishes an operation that truly failed from an error that was handled and allowed the operation to complete. Use a low-cardinality error type for grouping, record an exception once at the right boundary, and keep sensitive details out of broad spans and metrics. Define retention, access, redaction, and sampling with security and privacy owners.
Test negative paths as first-class journeys
Write tests around outcomes, not only thrown values. Include malformed input, expired authority, a duplicate command, a slow dependency, a dependency that completes after the caller times out, a process restart during a write, a partial batch, and a denied operator action. Check the response, durable state, logs, metrics, alerts, and recovery instructions together. Background job design and test strategy provide useful adjacent patterns for asynchronous and failure-oriented tests.
Use error trends to change the system
Review errors by user outcome and recovery path, not only by exception name. Rising validation errors can indicate a confusing interface or a contract mismatch. A cluster of timeouts may indicate dependency capacity, a missing timeout, or a queue that should be asynchronous. Repeated manual replays can reveal an absent product state. Pair rates with a few real cases and segment by workflow, client, tenant, or release when the consequence differs. Turn a finding into a specific change: a new validation rule, a safer status, a bounded retry, a test, a dashboard, or a runbook owner.
- Can a user or caller tell whether the action completed, is pending, or did not start?
- Does every public error have a stable code, type, or field context that clients can test?
- Are retry and duplicate-effect rules defined for every consequential write?
- Can support use a correlation reference without seeing secrets or unnecessary personal data?
- Does telemetry distinguish handled business outcomes from failed operations?
- Does each terminal error have an operator, a repair path, and a review signal?
Turn repeated failures into a maintained catalog
Pair the catalog with a small set of user-visible examples and operator cases. An example should show the input, public response, durable state, telemetry grouping, and safe next step. That makes review concrete and catches a common mismatch: a response says 'try again' while the server has already accepted the command. Keep examples versioned with the contract so they are exercised whenever a client or dependency changes.
A useful error catalog is smaller than the exception hierarchy and richer than a list of messages. For each public type, record the business condition, status, safe detail, retry meaning, telemetry grouping, support route, and retirement owner. Include one example of the durable state that confirms the outcome. When a dependency, client, or product state changes, update the catalog and its tests together. This gives a new maintainer a way to preserve recovery semantics without guessing which details were accidental.
Review the catalog against real cases each quarter or after a material incident. Remove categories that no longer describe a recovery path, merge types that have the same action, and split a type when callers need different behavior. A stable vocabulary makes dashboards and support guidance more useful, but it should remain accountable to the user outcome rather than becoming a taxonomy project.
Error handling principles worth carrying into design
- Classify a failure by the next safe action, not by the exception class alone.
- Use stable public contracts and keep implementation diagnostics private.
- Make retry, idempotency, reconciliation, and terminal ownership explicit.
- Record evidence that explains the operation without collecting secrets.
- Turn recurring failure patterns into focused product, test, and operating changes.
Questions teams ask about error handling
Should users ever see a technical exception message?
Usually no. Show what the person needs to understand and do next, such as correcting a field, waiting for a pending result, or contacting support with a reference. Keep stack traces and internal topology in protected diagnostics.
When is retry safe?
Retry is safest when the failure is plausibly temporary and the operation is read-only, idempotent, or protected by a durable duplicate-effect check. It is not a universal remedy for validation errors, authorization denials, poison messages, or ambiguous writes.
Are HTTP status codes enough for an API error?
They provide important transport semantics, but clients commonly need a stable problem type, field information, retry meaning, or a safe occurrence reference. Keep those additions consistent and documented rather than making clients parse prose.
Conclusion: make failure a navigable state
Good error handling makes a difficult moment legible. Define the interrupted outcome, classify the recovery path, return a stable and safe contract, preserve restrained evidence, and review trends by consequence. When users, callers, operators, and systems can all see the next responsible action, failure stops being a dead end and becomes a managed part of the product.