Skip to content

UnhandledErrorInterceptor re-raises grpc.RpcError, so a failed outbound call reaches the caller as UNKNOWN with its details #86

Closed

Description

@AlexeyShalaev

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

  1. AlexeyShalaev commented on Oct 17, 2026

    @AlexeyShalaev
    MemberAuthor

    Fixed in 2b456d3, released in 0.13.2.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions