Errors¶
A use case does not know which transport is calling it. But "this order does not exist" has to
become a 404 over HTTP and a NOT_FOUND over gRPC, with the same rule about what the client is
allowed to see.
servicewright solves that with three replaceable pieces:
ServiceError— what your business code raises.ErrorInfo— the normalized view of any failure, built by the transport handler.HttpErrorRendererProtocol— turns anErrorInfointo a wire response.
flowchart LR
E["raise OrderNotFoundError"] --> N["ErrorInfo<br/>kind · code · detail · params · public"]
X["raise anything else"] --> N
N --> M["mask_private_error()"]
M --> R["HttpErrorRenderer"]
M --> K["ErrorKind → grpc.StatusCode"]
R --> H["404 application/problem+json"]
K --> G["abort NOT_FOUND<br/>x-error-code: order_not_found"]
Raise a typed error¶
from servicewright import ErrorKind, ServiceError
class OrderNotFoundError(ServiceError):
kind = ErrorKind.NOT_FOUND # code auto-derives to "order_not_found"
raise OrderNotFoundError("no such order", params={"order_id": order_id})
Subclassing is the normal path: the class name becomes the machine-readable code
(OrderNotFoundError → order_not_found), and the kind decides the status.
You can also raise the base class directly:
Every attribute is resolved onto the instance, so catching works the way you would expect:
except ServiceError as exc:
if exc.code == "order_not_found": # the resolved code, not the class default
...
The kinds¶
ErrorKind |
HTTP | gRPC |
|---|---|---|
INVALID |
400 | INVALID_ARGUMENT |
UNAUTHENTICATED |
401 | UNAUTHENTICATED |
FORBIDDEN |
403 | PERMISSION_DENIED |
NOT_FOUND |
404 | NOT_FOUND |
CONFLICT |
409 | ALREADY_EXISTS |
PRECONDITION_FAILED |
412 | FAILED_PRECONDITION |
TOO_MANY_REQUESTS |
429 | RESOURCE_EXHAUSTED |
NOT_IMPLEMENTED |
501 | UNIMPLEMENTED |
UNAVAILABLE |
503 | UNAVAILABLE |
DEADLINE_EXCEEDED |
504 | DEADLINE_EXCEEDED |
INTERNAL |
500 | INTERNAL |
Both columns are injective, so a status maps back to exactly one kind — which is what lets a gateway translate between the two without guessing.
One row is worth stating out loud, because it has a plausible-looking alternative: CONFLICT
renders as ALREADY_EXISTS and not as ABORTED, although both mean 409 to a gRPC gateway.
ABORTED is the transaction-conflict code, and the gRPC contract tells clients to retry it at a
higher level — the wrong advice for "this order was already paid", which will never succeed on a
retry. FAILED_PRECONDITION is spoken for by PRECONDITION_FAILED. A client that has to tell a
duplicate key from a state conflict branches on x-error-code, which carries the exact code
either way, rather than on the status.
What the client sees¶
Over HTTP, an RFC 9457 problem document with Content-Type: application/problem+json:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"code": "order_not_found",
"detail": "no such order",
"params": { "order_id": "42" }
}
Over gRPC, the RPC aborts with NOT_FOUND, the detail becomes the status message, and the machine
code travels in the x-error-code trailing metadata so clients can branch without parsing
English.
Public and private errors¶
public=False masks the error on every transport. The client gets a generic internal error;
the real code, detail and params go to your logs only.
Masking strips everything — detail, params, extra headers, any status override — so nothing can leak through a custom renderer by accident.
Tip
Make it a class attribute for whole families of errors:
Errors you never declared¶
An exception that is not a ServiceError at all — a KeyError off a dict, a RuntimeError from
a driver — is masked the same way, and by the same rule, on both transports:
| HTTP | gRPC | |
|---|---|---|
| status | 500 | INTERNAL |
| machine code | "code": "internal_error" |
x-error-code: internal_error |
| message | none | internal_error |
| the exception itself | logged with its traceback | logged with its traceback |
So a caller cannot tell an error you hid from an error you never knew about, and neither one carries a sentence you did not write. That matters most for the second kind, because its wording is whatever a library chose: an exception text is where a DSN with a password, a failing row or a file path ends up.
Over HTTP this is UnhandledErrorMiddleware plus the Exception handler; over gRPC it is
UnhandledErrorInterceptor. Both are installed unconditionally, and neither one is what
map_service_errors=False or default_exception_handlers=False turns off — those switch off the
mapping of the errors you did declare, and an undeclared exception then reaches the client as a
masked internal error rather than as its own message.
The gRPC default without it
grpc.aio answers an exception it was not told about with UNKNOWN and repr() of it —
status code and message both chosen by the transport, out of material nobody reviewed.
Own the wire format¶
If your product has its own error envelope, implement one renderer and every default handler starts speaking it:
from servicewright import ErrorInfo, RenderedError
class MyEnvelopeRenderer:
def render(self, info: ErrorInfo) -> RenderedError:
return RenderedError(
status_code=info.http_status,
body={"error": {"code": info.code, "message": self._localize(info)}},
media_type="application/json",
)
http = FastApiEntrypoint(error_renderer=MyEnvelopeRenderer())
ErrorInfo gives you everything you need to localize: kind, code, detail, params,
public, status_override, headers and the computed http_status.
The default renderer can also be pointed at a documentation base URI:
from servicewright import ProblemDetailsRenderer
FastApiEntrypoint(error_renderer=ProblemDetailsRenderer(type_base="https://errors.example.com"))
# → "type": "https://errors.example.com/order_not_found"
Rendering never fails¶
Rendering an error is the client's last chance to learn what went wrong, so it may not be the thing that breaks:
paramsthat are not JSON primitives — aUUID, adatetime, aDecimal, a set, a self-referencing dict — are coerced, not dropped and not raised on;- non-finite floats (
nan,inf) become strings, becausejson.dumpsrejects them; - a status outside the IANA registry (499, 520) still gets a sensible title.
The two helpers doing that are public, if your renderer wants them:
The default HTTP handlers¶
FastApiEntrypoint installs five, all rendering through the configured renderer:
| Situation | Result |
|---|---|
| Request validation failed | 422, code="validation_error", sanitized field errors |
HTTPException with a 4xx status |
that status, detail and headers preserved |
HTTPException with a 5xx status |
masked to a generic internal error |
ServiceError |
its kind's status; masked when public=False |
| Deadline exceeded | 504, code="deadline_exceeded" |
| Anything unhandled | masked 500, logged with the request id — and the response carries that id too |
The gRPC equivalents¶
| Situation | Result |
|---|---|
ServiceError |
its kind's status; masked when public=False |
| Anything unhandled | INTERNAL, x-error-code: internal_error, logged with the correlation ids |
A handler that called context.abort() itself |
left alone — the status was already chosen |
The caller went away (CancelledError) |
left alone — not an error to report |
The two interceptors doing that sit at different depths of the chain, and the adapter page explains why.
Add handlers for your own exception types, or switch the defaults off entirely:
FastApiEntrypoint(
exception_handlers={MyLegacyError: my_handler},
default_exception_handlers=False,
)
Runtime errors¶
Failures of the runtime itself have their own family, all deriving from ServiceWrightError:
| Exception | Raised when |
|---|---|
WarmupError |
a warmer failed and raise_on_failure was set |
WarmupTimeoutError |
warmup did not finish in its 60-second budget |
DrainTimeoutError |
an entrypoint did not drain within its grace window |
CleanupTimeoutError |
a post-drain step blew its budget |
RedisWarmupError, PostgresWarmupError, KafkaProducerWarmupError |
the matching warmer failed |
These are TimeoutError subclasses where that makes sense, so ordinary except TimeoutError
still catches them.
Next¶
- FastAPI adapter — where the handlers are installed.
- gRPC adapter — interceptor ordering and why it matters.