Repository navigation
Description
Versions: servicewright 0.13.1, grpc-server-kit 0.2.0, grpcio 1.84.0, Python 3.13.
#50 made an unexpected exception reach a gRPC caller as INTERNAL / internal_error instead of UNKNOWN with its message. One kind of exception still escapes that: an error from an outbound gRPC call. UnhandledErrorInterceptor re-raises (grpc.aio.AbortError, grpc.RpcError) (adapters/grpc/errors.py:118), and grpc.aio.AioRpcError is a grpc.RpcError, so grpc.aio answers with UNKNOWN and the repr of the downstream error, including the downstream service's details. GrpcEntrypoint installs this interceptor, so every gRPC entrypoint behaves this way.
Reproduction
import asyncio
import logging
import grpc
from grpc_server_kit import GrpcApp, GrpcServerConfig
from servicewright.adapters.grpc import UnhandledErrorInterceptor
def generic(service, method, handler):
return lambda server: server.add_generic_rpc_handlers((
grpc.method_handlers_generic_handler(service, {method: grpc.unary_unary_rpc_method_handler(handler)}),
))
async def main():
async def ledger(request, context):
await context.abort(grpc.StatusCode.INTERNAL, "ledger dsn=postgres://ledger:s3cret@10.0.3.7/ledger")
downstream = GrpcApp(GrpcServerConfig(host="127.0.0.1", port=0))
downstream.register(generic("billing.Ledger", "Get", ledger))
async with downstream:
target = f"127.0.0.1:{downstream.bound_port}"
async def invoice(request, context):
async with grpc.aio.insecure_channel(target) as channel:
await channel.unary_unary("/billing.Ledger/Get")(b"", timeout=2)
return b"ok"
app = GrpcApp(GrpcServerConfig(host="127.0.0.1", port=0), interceptors=[UnhandledErrorInterceptor()])
app.register(generic("orders.Invoices", "GetInvoice", invoice))
async with app:
async with grpc.aio.insecure_channel(f"127.0.0.1:{app.bound_port}") as channel:
try:
await channel.unary_unary("/orders.Invoices/GetInvoice")(b"", timeout=3)
except grpc.aio.AioRpcError as error:
print("status: ", error.code().name)
print("details:", error.details())
logging.basicConfig(level=logging.CRITICAL)
asyncio.run(main())Actual
status: UNKNOWN
details: Unexpected <class 'grpc.aio._call.AioRpcError'>: <AioRpcError of RPC that terminated with:
status = StatusCode.INTERNAL
details = "ledger dsn=postgres://ledger:s3cret@10.0.3.7/ledger"
debug_error_string = "INTERNAL:ledger dsn=postgres://ledger:s3cret@10.0.3.7/ledger"
>
Expected
What #50 promises for any exception nobody planned for: INTERNAL, detail internal_error, x-error-code: internal_error, and the real exception in the log with its traceback. grpc.aio.AbortError should keep passing through, because it is the RPC's own deliberate status; a client-side AioRpcError is not.
Possible fix
Narrow the clause to except grpc.aio.AbortError: raise and extend the #50 test with a handler that awaits a failing downstream RPC. The same pass-through exists in grpc-server-kit's AsyncExceptionHandlerInterceptor: bedrock-python/grpc-server-kit#48.
Activity
fix(grpc): mask a failed outbound RPC instead of re-raising it (#86)
AlexeyShalaev commentedon Oct 17, 2026
Fixed in 2b456d3, released in 0.13.2.