Error handling across a boundary
Errors that cross a service or module boundary need different treatment from the ones that stay home.
Inside a module, an error can be anything — an exception, a sentinel, a panic — because the code that raises it and the code that handles it were written together.
The moment an error crosses a boundary, that stops being true, and most error handling that feels awkward is a boundary problem wearing an implementation costume.
what the caller actually needs#
Not your stack trace. Not your internal error type. Three things:
Is this my fault or yours? Determines whether the caller fixes their request or files a bug.
Should I retry? Determines whether the caller backs off or gives up. This is the single most important bit of information and it is the one most often missing.
What do I tell my user? Determines whether the caller can produce a useful message or has to say "something went wrong."
Everything else is detail. Design the error surface around those three and most of the awkwardness disappears.
the taxonomy that covers it#
Four categories. Every error a caller sees should fall into exactly one:
| category | caller's response | HTTP shape |
|---|---|---|
| invalid — the request is wrong | fix it, do not retry | 400, 422 |
| denied — not permitted | get access, do not retry | 401, 403 |
| absent — it is not there | maybe create it, do not retry | 404, 410 |
| transient — try again | retry with backoff | 429, 503, 504 |
Plus the residual: internal — our bug, do not retry, here is a reference.
The important property is that retryability is determined by the category, not by parsing a message. A caller should be able to write:
except TransientError:
retry_with_backoff()
except InvalidRequest as e:
surface_to_user(e.field, e.reason)without a string comparison anywhere.
structure the payload#
{
"error": {
"type": "invalid",
"code": "quantity_exceeds_stock",
"message": "Requested 12, only 3 in stock.",
"field": "items[0].quantity",
"retryable": false,
"request_id": "req_01J9F3K2M4N5P6"
}
}typeis the category — a closed set the caller can branch on.codeis stable and machine-readable. Never change its meaning; add new ones instead.messageis for a human and may change freely.fieldlets a client highlight the right input.retryableis explicit rather than inferred.request_idis what turns a support conversation into a log lookup.
The distinction between code and message is the one people collapse, and then callers parse the message, and then you cannot reword it.
what not to leak#
Stack traces. They expose your internals and file paths, and the caller cannot act on them. Log them; return a request ID that lets you find them.
Downstream errors verbatim. "connection refused to pg-primary-3.internal:5432" tells an attacker your topology and tells your caller nothing useful. Translate to transient, log the detail.
Whether a resource exists, when the caller lacks permission. Returning 404 rather than 403 for unauthorised access to an existing resource prevents enumeration. Decide this deliberately per resource type — sometimes the leak is acceptable and the clearer error is worth more.
the internal-boundary version#
The same discipline applies between modules in one process, minus the wire format.
The equivalent mistake is a module raising its internal exception types across its public interface. Now every caller imports your internals, and you cannot refactor without breaking them.
Define the errors your module raises as part of its interface, alongside the functions. Translate internal failures into those at the boundary. It is a few lines of mapping and it is what lets the inside change freely.
the rule of thumb#
Inside a boundary, optimise for the person debugging. Rich context, full traces, internal detail.
At a boundary, optimise for the person deciding what to do next. Category, stable code, retryability, a reference to find the detail.
Conflating the two is why so much error handling manages to be both verbose and uninformative.
— Dom, October 2, 2026