Skip to content

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 an ErrorInfo into 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:

raise ServiceError("quota exceeded", kind=ErrorKind.TOO_MANY_REQUESTS, code="quota_exceeded")

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

raise ServiceError(
    "row 412 violates fk_orders_user",
    kind=ErrorKind.CONFLICT,
    public=False,
)

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:

class InfrastructureError(ServiceError):
    kind = ErrorKind.INTERNAL
    public = False

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:

  • params that are not JSON primitives — a UUID, a datetime, a Decimal, a set, a self-referencing dict — are coerced, not dropped and not raised on;
  • non-finite floats (nan, inf) become strings, because json.dumps rejects 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:

from servicewright.core.errors import status_title, to_json_safe

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