Skip to content
Published on

The Complete Guide to Error Handling: Designing Failure as Part of the Contract

Share
Authors

Introduction

In most codebases the success path is designed and the failure path merely happens. The names of fields in a success response get argued over in review, while the error response gets waved through with "throw a 500 for now and clean it up later." That "later" usually arrives at an incident review.

This blog has posts that dissect a specific error, like CORS Errors, and Why You Have to Fix the Server, and posts that treat errors as a budget, like Reliability Engineering with SLIs, SLOs, and Error Budgets. But there is no post on application error design itself. This one fills that gap. It puts failure not in the category of exception syntax but in the category of interface contract, and follows what gets translated and what gets discarded each time an error crosses a layer boundary.

The sources are RFC 9110, RFC 6585, RFC 9457, the cascading failures chapter of the Google SRE book, and the OWASP Authentication Cheat Sheet.


1. Two Axes for Classifying Errors

Error design starts with classification. Without it every error funnels into a single catch (Exception e), and at that point no judgment is possible.

1-1. Axis 1 — Expected Failure or Bug

  • Expected failure: an outcome the domain rules permit. Insufficient balance, a duplicate booking, an expired coupon. This is a value, and it belongs in the function signature.
  • Bug: an invariant of the code is broken. Null dereference, index out of range, an unreachable branch. This is not a value but a defect, and it must not be swallowed.

Blur the distinction and two accidents happen at once: bugs get shown to users as if they were domain outcomes, and domain outcomes get reported as 500s, producing alert fatigue.

1-2. Axis 2 — Retryable or Not

  • Retryable: sending the same request again shortly may succeed. Transient network errors, timeouts, 429, 503.
  • Not retryable: the result is the same no matter how many times you send it. 400, 401, 403, 404, 422, and most 409s.

1-3. Four Quadrants and Their Handling

RetryableNot retryable
Expected failureRetry after backoff; tell the user if it still failsTell the user how to correct it and stop
BugDoes not exist (if it does, the classification is wrong)Log and alert; show the user a generic message

"Expected failure that is retryable" is the quadrant that needs the most code. That is the subject of sections 5 and 6.

1-4. A Third Axis — Whose Fault Is It

The client, this server, or a downstream dependency. This axis maps straight onto the HTTP status code choice in section 4. Report a dependency failure as a 4xx and you are asking the client to fix something it cannot.


2. Exceptions vs Return Values — a Dispute With No Winner

Every language community answers this differently and none has convinced the others. Instead of a winner, here are the axes.

Exception style                  Return-value style
compiles even if the caller      hard for the caller to forget
forgets                          handling
may not appear in the signature  failure appears in the signature
intermediate layers write no     every layer writes propagation
code                             code
handled far from the origin      handled locally at the call site

2-1. Five Axes

  • Enforcement: can the caller ignore the failure? Return values are hard to ignore; unchecked exceptions are easy to forget.
  • Signature visibility: can you tell which failures are possible by reading the function alone?
  • Propagation cost: how much boilerplate it takes to move an error upward. Exceptions win here by a wide margin.
  • Where handling happens: do you want to deal with failure near where it occurred, or gather it far above?
  • Information preservation: how much of the cause chain and context survives propagation.

2-2. What Each Approach Pays

Exception-centric languages pay near-zero propagation cost, and pay for it with not being able to tell from a signature which failures come from where. Checked exceptions lift that back into the signature, but at the cost of interfaces polluted by implementation detail and a strong tendency for developers to route around them with empty catch blocks. Return-value-centric languages express failure in the type system, and pay with propagation code at every layer plus the discipline required to attach context along the way.

There is only one practical conclusion: following the idiom of the language your team uses is the cheapest option. Fighting the idiom means fighting the library ecosystem, the static analysis tooling, and every new hire's expectations at once.

2-3. Rules That Hold Regardless of Style

  • Failure must appear in either the signature or the documentation, without exception.
  • Do not swallow errors. An empty catch block is code that deletes information.
  • Do not express errors only as string messages. You need a type or code that can be branched on.
  • Do not use exceptions for control flow. Ending a loop by exception deceives both the reader and the profiler.

3. Translating Errors at Boundaries

Most of error design is deciding what to translate and what to discard at each boundary.

external API client ─┐
                     ├─▶ domain error ─▶ HTTP error contract ─▶ user-facing text
data store ──────────┘        │
                              └─▶ queue reprocessing policy

3-1. Translation Rules per Boundary

BoundaryWhat comes inWhat goes outWhat to preserve
Store → domainConstraint violations, connection errorsDomain error, infrastructure errorCause chain, retryability
External API → domainStatus codes, timeoutsDomain errorPeer service name, correlation ID
Domain → HTTPDomain errorStatus code plus problem detailsError type identifier
Domain → queueDomain errorRetry, delayed retry, dead letterAttempt count, last cause

3-2. Two Anti-Patterns

Layer leakage is the first. If a store's constraint-violation exception surfaces all the way up to the controller, the controller has to know the storage technology, and swapping the store breaks the controller.

Over-wrapping is the second. Wrap in a new exception at every layer while discarding the cause and the log ends up with five nested copies of "an error occurred during processing" and no actual cause. The rule is simple: when you wrap, always attach the cause.

3-3. Carrying the Retryable Flag Across the Boundary

There is one piece of information only the lower layer has: whether the failure was transient. If you do not carry it upward on the domain error, upper layers end up guessing from message strings. Putting retryability on the domain error type as an explicit attribute is one of the cheapest improvements this guide recommends.


4. The HTTP Error Contract: Status Codes and Problem Details

4-1. Status Codes Are Read by the Whole Stack

Here are the ones people get wrong, per RFC 9110. 400 is when the server cannot or will not process the request due to a client error; 401 is "the request lacks valid authentication credentials for the target resource"; 403 is "the server understood the request but refuses to fulfill it"; 409 is "the request conflicts with the current state of the target resource"; 422 is when the content type and syntax are understood but the instructions cannot be processed. RFC 9110 defines 401 in §15.5.2 and 403 in §15.5.4. 401 is authentication, 403 is authorization.

429 Too Many Requests lives in RFC 6585, not RFC 9110. Its text says it means "the user has sent too many requests in a given amount of time ('rate limiting')," and that the response "MAY include a Retry-After header."

4-2. Use RFC 9457 for the Body

RFC 9457 obsoletes RFC 7807 and defines the application/problem+json media type. The §3.1 members are type (a URI reference, the primary identifier of the problem type, defaulting to about:blank), status (advisory, matching the real status code), title (which, localization aside, should not change from occurrence to occurrence), detail (an explanation of this occurrence), and instance (a URI identifying this occurrence).

On detail the spec says it should "focus on helping the client correct the problem, rather than giving debugging information." That one sentence is the standard for writing error messages.

Example — an error response that also carries retryability.

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 30

{
  "type": "https://api.example.com/problems/upstream-unavailable",
  "title": "Upstream service unavailable",
  "status": 503,
  "detail": "The payment authorization service is temporarily unreachable. Send the same request again in 30 seconds.",
  "instance": "/v1/payments/req_01J9XQ",
  "retryable": true,
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

§3.2 permits extension members and requires clients to "ignore any such extensions that they don't recognize," so adding fields like retryable and traceId later is not a breaking change.

4-3. Make the Error Type the Only Basis for Client Branching

What clients branch on must be the type value, never the detail string. Leave that rule out of the documentation and clients start regex-matching messages, at which point fixing a phrase requires a deployment.


5. Retries — No Retries Without Idempotency

5-1. Idempotency First

RFC 9110 §9.2.2 defines idempotent as "the intended effect on the server of multiple identical requests is the same as for a single request," and includes GET, HEAD, PUT, DELETE, OPTIONS, and TRACE. POST is not idempotent. So retrying a POST requires an idempotency key, and without one that request is simply not retryable. In systems where this judgment does not exist explicitly in code, retries quietly produce duplicate payments.

5-2. Always Randomize the Backoff

The Google SRE book states flatly: "Always use randomized exponential backoff when scheduling retries." The reason is clear. Fixed intervals or pure exponential backoff bring clients that failed at the same moment back at the same moment, and that knocks over a recovering server again.

attempt 1 fails → wait = random(0, 1s)
attempt 2 fails → wait = random(0, 2s)
attempt 3 fails → wait = random(0, 4s)
cap reached     → give up and propagate the error upward

5-3. Retry Budgets and Caps

The same document recommends a per-process retry budget. The value it gives as an example is "only allow 60 retries per minute in a process." It also says "don't retry a given request indefinitely." When the budget is exhausted, stop retrying and propagate the error as it is. Retries without a budget are a mechanism for doubling load at exactly the moment load is the problem.

5-4. Multi-Layer Retries Multiply

This is the most commonly missed item. The Google SRE book points out that three layers each retrying four times turns one user action into 4 × 4 × 4 = 64 attempts. It happens whenever the client SDK, the API gateway, and the service-to-service client each enable their own "reasonable" retries.

The fix is to pick one retry layer. Usually only the layer closest to the user retries, and every other layer propagates failure immediately. Then mark retries in logs and traces so the real attempt count is observable. For the relationship between retry count and success probability, the Retry and Cumulative Probability Calculator gives a feel for the numbers.

5-5. What to Retry and What Not To

  • Retry: connection failures, timeouts (when idempotent), 429 (respecting Retry-After), 503, some 500s
  • Do not retry: 400, 401, 403, 404, 422, most 409s

The Google SRE book says to distinguish retriable from non-retriable errors with clear error codes and never to retry permanent errors. A client that retries a 400 converts its own bug into server load.

5-6. Rejection Is Also a Strategy

The same chapter covers load shedding: reject early instead of queueing indefinitely. Reject quickly with a 503 and the client backs off while the server serves the requests that are still alive. Queue them instead and queue latency exceeds the client's timeout, burning resources producing responses nobody will receive. Deciding when to open the circuit is covered in The Complete Guide to the Circuit Breaker Pattern.


6. Partial Failure and Timeout Budgets

6-1. A Timeout Is a Budget

If a user-facing request has a three-second deadline, those three seconds are a budget shared by the downstream calls. Set each call's timeout independently and the sum exceeds the deadline, so the user has already left while the server keeps working.

user deadline 3000ms
 ├─ auth check           150ms
 ├─ order lookup         400ms
 ├─ payment authorize   1500ms  (includes one retry → real cap 2 × 700ms)
 └─ slack                950ms  (serialization, GC, network variance)

Propagating the remaining time to downstream calls is what makes this budget real. A downstream service invoked after two seconds have already been spent must receive a one-second deadline. Without that propagation every service waits its own maximum.

6-2. Timeouts and Retries Multiply

A one-second timeout with three retries is three seconds in the worst case. Add an upper layer retrying again and you get the multiplication from 5-4. The timeout budget must be computed with retries included.

6-3. Designing the Partial-Failure Response

Decide the contract for "some items in a batch failed" in advance. There are three options.

  • Fail the whole thing: simple, but you must be able to undo the operations that succeeded.
  • Return a per-item status array: carry success and error type for each item, so the client can retry only what failed.
  • Make it asynchronous behind a job resource: accept the request and let the client poll progress.

The second is the most widely used, but you must document what the overall status code will be. Leave that undecided and every client interprets it differently.

6-4. Cascading Failures

The Google SRE book defines a cascading failure as "a failure that grows over time as a result of positive feedback." The classic causes are server overload — traffic redirected from a failed cluster — and resource exhaustion across CPU, memory, threads, and file descriptors, where the exhaustions compound each other. Short on threads means higher latency, and higher latency means waiting requests hold more memory.

The lesson from an error-handling perspective is single: do not let failed requests hold resources for long. Calls without timeouts, unbounded queues, and unbounded retries all produce the same failure.


7. Observability — Errors in Logs, Metrics, and Traces

7-1. One Error, One Log Line

The most common waste is logging at every layer. Five layers logging the same error turns one error into five lines, inflating error-rate calculations and alerts alike. The rule is log once, where the error is handled. Intermediate layers attach context and propagate instead of logging.

Example — the fields an error log must carry.

{
  "level": "error",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "errorType": "upstream_unavailable",
  "retryable": true,
  "attempt": 2,
  "upstream": "payment-gateway",
  "durationMs": 1043,
  "message": "payment authorization failed after retry"
}

7-2. In Metrics, the Denominator Is the Point

Count errors alone and the count rises automatically with traffic. What you need is an error rate, and what the denominator is becomes a contract in itself. Whether a request that succeeded after retries counts as a success or a failure changes the number substantially, so it is better to measure both the user-perceived success rate and the per-attempt success rate. Tying error rates to an SLO is covered in Reliability Engineering with SLIs, SLOs, and Error Budgets.

7-3. Avoid Cardinality Explosions

Use error message strings as metric labels and your time series explode. Labels should carry only the finite set produced by the classification in section 1: error type, retryability, peer service name. Free-form strings belong in logs, never in metrics.

7-4. Alert on Burn Rate, Not on Errors

A system that alerts on every error is soon ignored. Alerts belong on the burn rate of the error budget, not on errors themselves. And put the correlation ID and error type in the alert body so you can jump from alert to trace in one step.


8. What to Tell the User

8-1. The Security Boundary

RFC 9457 §5 states that information in problem details "must be carefully vetted" and that you should avoid exposing "implementation details such as a stack dump." Stack traces, internal hostnames, SQL statements, and library versions are all useful to an attacker.

Authentication failures deserve particular care. The OWASP Authentication Cheat Sheet says an application "should respond (both HTTP and HTML) in a generic manner," offering wording like "Login failed; Invalid user ID or password" as the example, in order to prevent username enumeration. The same document adds that "the HTTP response code may differ which can leak information about whether the account is valid or not." Unifying the wording while letting status codes or response times diverge is no defense at all.

8-2. Three Elements of a User Message

  • What happened: one sentence, no technical vocabulary
  • What they can do: retry, correct a value, wait, or contact support
  • An identifier for support: the correlation ID. Without it, support cannot find the incident in the logs

8-3. Tell Them How Long to Wait

For a retryable error, say how long to wait. RFC 6585 says a 429 response MAY include a Retry-After header, and the same header works for 503. The UI reads that value and renders "retrying automatically in 30 seconds." This one change sharply reduces manual refreshes, and with them the server load.

8-4. Separate Machine Values From Human Sentences

type is for machines; title and detail are for humans. That is exactly why the spec says title should not change from occurrence to occurrence. Human sentences should change often, for localization and wording improvements; machine values must never change.


9. Anti-Pattern Checklist

  • Empty catch: code that deletes errors. At minimum log it, and if the ignore is deliberate, express the reason in code rather than a comment.
  • Everything as a 500: mixes errors the client can fix with errors it cannot, breaking retry logic and alerting at the same time.
  • Errors inside a 200: proxies, monitors, and retry middleware all read it as success.
  • Returning null for an error: callers cannot distinguish "no value" from "failed," so null checks and exception handling end up interleaved.
  • Branching on string matches: the error message becomes the contract, and a wording fix becomes an outage.
  • Calls without timeouts: one slow dependency locks up the entire thread pool.
  • Multi-layer retries: the 4 × 4 × 4 = 64 problem. Pick one retry layer.
  • Logging at every layer: error rates inflate and alerts lose credibility.
  • Wrapping that discards the cause: always attach the cause chain when wrapping.
  • Exceptions for control flow: you lose performance and readability at once.
  • Exposing stack traces to users: the pattern RFC 9457 §5 explicitly prohibits.
  • No retry budget: doubles load at exactly the moment load is the problem.

Quiz: Check Your Understanding

Quiz 1: A payment request timed out. Is it safe for the client to retry automatically?

Answer: Only if the request carries an idempotency key and the server supports it. Otherwise, do not retry.

Explanation: A timeout does not even tell you whether the request reached the server. The idempotency defined in RFC 9110 §9.2.2 applies to GET, HEAD, PUT, DELETE, OPTIONS, and TRACE, not to POST, so retrying a POST payment request can produce a duplicate authorization. With an idempotency key the server answers the second request with the first one's result, which makes it safe. Carrying retryability explicitly in the error response means the client never has to guess.

Quiz 2: During an incident the error-rate dashboard spiked to five times normal, but the number of actually failed requests was nowhere near that. What do you suspect first?

Answer: Either logging at every layer, or each retry attempt being counted as its own error.

Explanation: One error logged at five layers makes the metric five times larger. Retries do the same: counted per attempt, three retries become three errors, while from the user's point of view it is one failure. The fix is to log once where the error is handled, and to measure the user-perceived success rate separately from the per-attempt success rate. You need both numbers; looking at only one misleads you in a different direction each time.

Quiz 3: The client SDK, the gateway, and the service-to-service client are each configured with four retries. What is the problem?

Answer: Retries multiply, so a single user action becomes up to 4 × 4 × 4 = 64 requests.

Explanation: This is the classic amplification path the Google SRE book names when discussing cascading failures. Apply this amplification to a downstream service already slowed by load and it knocks the recovering service over again. The fix is to pick one retry layer and have the others propagate failure immediately, plus a per-process retry budget. The same document gives 60 retries per minute per process as an example and states that a given request must not be retried indefinitely.

Quiz 4: The login screen distinguishes "no such user ID" from "wrong password." What is the risk?

Answer: Username enumeration. An attacker can confirm which accounts actually exist.

Explanation: The OWASP Authentication Cheat Sheet says an application should respond in a generic manner in both HTTP and HTML, offering "Login failed; Invalid user ID or password" as the example wording. The catch is that unified wording alone is not enough: the same document points out that a differing HTTP response code can by itself leak whether an account is valid. Differences in response time leak the same information, so those must be aligned too.

Quiz 5: A user-facing request has a three-second deadline, but its four downstream calls each have a two-second timeout. What is wrong?

Answer: The individual timeouts sum to far more than the deadline. A timeout should be a share of the deadline's budget, not an independent value.

Explanation: In the worst case it takes eight seconds, during which the user has already left while the server keeps holding resources and working. That is the resource-exhaustion path into cascading failure. The fix is to allocate each call's budget backward from the deadline and to propagate the remaining time downstream. And if retries exist, their multiplier must be included in the budget calculation.

Quiz 6: You polished the wording of an error response's detail field, and a particular screen in the mobile app stopped working. What is the root cause?

Answer: The client was branching on a human-readable sentence. Machine values and human sentences were never separated.

Explanation: RFC 9457 makes type the primary identifier of the problem type, while detail explains this occurrence and should "focus on helping the client correct the problem." In other words detail is prose for humans and may change at any time. The value clients branch on is type, and that rule has to be stated in the API documentation. Without it, wording improvements and localization all become breaking changes.


Conclusion

Error handling is not a syntax problem but a contract problem. Which failures are possible, whether each is retryable, what survives a boundary crossing, and what the user is told are all contract — which means they belong in documentation, and changing them is a breaking change.

The three cheapest improvements are these. First, put retryability on the domain error type as an explicit attribute, so upper layers stop guessing from strings. Second, pick one retry layer and have every other layer propagate failure immediately; the 4 × 4 × 4 problem disappears. Third, standardize error responses on RFC 9457 and nail down in the documentation that clients branch on type only, which buys you the freedom to fix wording.

None of the three needs a new library, and all three cost far less than a single incident.


References

  • RFC 9110 — HTTP Semantics — quoted for the §9.2.2 idempotent definition and its methods, the fact that POST is not idempotent, the definitions of 400, 401, 403, 409, 422, 500, and 503, and the 401 (authentication) vs 403 (authorization) split per §15.5.2 and §15.5.4. Retrieved 2026-08-15.
  • RFC 6585 — Additional HTTP Status Codes — quoted for the definition of 429 ("the user has sent too many requests in a given amount of time") and the fact that Retry-After is a MAY. Retrieved 2026-08-15.
  • RFC 9457 — Problem Details for HTTP APIs — quoted for obsoleting RFC 7807, the application/problem+json media type, the five §3.1 members and the rule that detail should focus on helping the client correct the problem, §3.2 extension members and the client's obligation to ignore unrecognized ones, and the §5 prohibition on exposing stack dumps. Retrieved 2026-08-15.
  • Addressing Cascading Failures — Google SRE Book — quoted for the definition of cascading failure ("a failure that grows over time as a result of positive feedback"), server overload and resource exhaustion as causes, "always use randomized exponential backoff when scheduling retries," the 60-retries-per-minute budget example, the prohibition on retrying indefinitely, the observation that three layers retrying four times each becomes 64 attempts, the distinction between retriable and non-retriable errors, and load shedding by rejecting early. Retrieved 2026-08-15.
  • Authentication Cheat Sheet — OWASP — quoted for the recommendation that authentication failures respond generically in both HTTP and HTML with the example wording, and the observation that a differing HTTP response code can leak whether an account is valid. Retrieved 2026-08-15.
  • The two classification axes and their four quadrants, the per-boundary translation table, carrying a retryable flag on the domain error, the timeout budget allocation, the required fields of an error log, and the anti-pattern checklist are not taken verbatim from the sources above; they are the procedure organized in this post.

Further reading

Complete Guide Series