tech, developers, and the code underneath

issue 228· essay·

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:

categorycaller's responseHTTP shape
invalid — the request is wrongfix it, do not retry400, 422
denied — not permittedget access, do not retry401, 403
absent — it is not theremaybe create it, do not retry404, 410
transient — try againretry with backoff429, 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:

python
except TransientError:
    retry_with_backoff()
except InvalidRequest as e:
    surface_to_user(e.field, e.reason)

without a string comparison anywhere.

structure the payload#

json
{
  "error": {
    "type": "invalid",
    "code": "quantity_exceeds_stock",
    "message": "Requested 12, only 3 in stock.",
    "field": "items[0].quantity",
    "retryable": false,
    "request_id": "req_01J9F3K2M4N5P6"
  }
}
  • type is the category — a closed set the caller can branch on.
  • code is stable and machine-readable. Never change its meaning; add new ones instead.
  • message is for a human and may change freely.
  • field lets a client highlight the right input.
  • retryable is explicit rather than inferred.
  • request_id is 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

get README in your inbox

One dispatch, no noise. Tech and developer news, plus the occasional long piece on the craft.

subscribe →