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.
Measured on 0.10.0 with one service running a
FastApiEntrypointand aGrpcEntrypointover the same use case,map_service_errorsleft at its default. Every row is the same call made twice, once per transport:Five of the seven rows are exactly what the design promises. The
unexpectedrow is not, and it is the row where an exception nobody planned for reaches the caller.The case is a plain
RuntimeErrorfrom the use case — not aServiceError. Over HTTP, the default exception handlers turn it into a500withcode=internal_errorand no detail, and the message goes to the log where it belongs. Over gRPC it reaches the caller in full: the status isUNKNOWNrather thanINTERNAL, and the details carryreprof 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:
ServiceErrorInterceptormapsServiceErrorand leaves everything else togrpc.aio, whose default for an unhandled exception isUNKNOWNwith"Unexpected {type}: {message}"in the details. The HTTP side does not have that gap because its default handlers have anExceptioncatch-all.What I think it needs: the gRPC error mapping should treat a non-
ServiceErrorexception the way the HTTP side does — abort withINTERNAL, a generic detail, the sameinternal_errorcode in the trailing metadata, and the real exception logged with its traceback at ERROR.CancelledErrorandAbortErrorkeep 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_EXISTSforErrorKind.CONFLICTreads oddly for a state conflict that is not a duplicate key (FAILED_PRECONDITIONandABORTEDare the usual gRPC spellings), andGRPC_STATUS_BY_KINDis 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.public=Falsemasks "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.pyanddriver.pyin https://github.com/bedrock-python/bedrock-python.github.io/tree/docs/production-python-series/docs/blog/lab/2026-09-07-transport-independent-errors.