Skip to content

An unexpected exception reaches a gRPC caller with its message; the HTTP side masks the same error #50

Description

@AlexeyShalaev

Measured on 0.10.0 with one service running a FastApiEntrypoint and a GrpcEntrypoint over the same use case, map_service_errors left at its default. Every row is the same call made twice, once per transport:

    case         HTTP                                                          gRPC
    missing      404 code=order_not_found  detail="no order with id 42"        NOT_FOUND: no order with id 42  [x-error-code=order_not_found]
    paid         409 code=order_already_paid                                   ALREADY_EXISTS: order 42 was paid at 09:12  [x-error-code=order_already_paid]
    forbidden    403 code=not_your_order                                       PERMISSION_DENIED: ...  [x-error-code=not_your_order]
    provider     503 code=payment_provider_down                                UNAVAILABLE: ...  [x-error-code=payment_provider_down]
    ledger       500 code=internal_error, no detail                            INTERNAL: internal_error  [x-error-code=internal_error]
    unexpected   500 code=internal_error, no detail                            UNKNOWN: Unexpected <class 'RuntimeError'>: dividing by the number of retries, which is zero
    ok           200                                                           OK

Five of the seven rows are exactly what the design promises. The unexpected row is not, and it is the row where an exception nobody planned for reaches the caller.

The case is a plain RuntimeError from the use case — not a ServiceError. Over HTTP, the default exception handlers turn it into a 500 with code=internal_error and no detail, and the message goes to the log where it belongs. Over gRPC it reaches the caller in full: the status is UNKNOWN rather than INTERNAL, and the details carry repr of the exception. In this lab the message is harmless; in the service this lab is modelled on, the same path had a DSN with a password in the exception text, and it would have gone over the wire to whoever made the call.

The cause is the scope of the mapping: ServiceErrorInterceptor maps ServiceError and leaves everything else to grpc.aio, whose default for an unhandled exception is UNKNOWN with "Unexpected {type}: {message}" in the details. The HTTP side does not have that gap because its default handlers have an Exception catch-all.

What I think it needs: the gRPC error mapping should treat a non-ServiceError exception the way the HTTP side does — abort with INTERNAL, a generic detail, the same internal_error code in the trailing metadata, and the real exception logged with its traceback at ERROR. CancelledError and AbortError keep their current behaviour, since one is the caller leaving and the other is a deliberate status a handler already chose. The two transports agreeing on this is the whole promise of the shared error model, and today the promise holds for errors you declared and breaks for the ones you did not.

Worth checking in the same pass:

  • ALREADY_EXISTS for ErrorKind.CONFLICT reads oddly for a state conflict that is not a duplicate key (FAILED_PRECONDITION and ABORTED are the usual gRPC spellings), and GRPC_STATUS_BY_KIND is the table where that decision lives. I would not change it without a reason, but the mapping table deserves a line in the docs saying why each kind maps where it does.
  • The docs say public=False masks "at the transport". That is true for both transports today. It is worth a test that asserts it for both, next to the new one, because that is the property this issue's row broke for unexpected exceptions.

Lab: service.py and driver.py in https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-07-transport-independent-errors.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions