Skip to content

gRPC & RPC in Async Python

gRPC brings three things a JSON-over-HTTP service usually lacks: a schema both sides are generated from, streaming in either direction as a first-class call shape, and a deadline that travels with the request so an entire chain of services shares one budget. grpc.aio — the asyncio implementation inside the standard grpcio package — maps all three onto the event loop cleanly: servicers are coroutines, streams are async generators, and an expired deadline cancels the servicer, which cancels whatever it was awaiting. Measured on a two-hop chain running through interceptors on both servers, 500 calls completed in 0.19 s with a server-side p50 of 0.08 ms per hop.

What it costs is a build step, a binary wire format you cannot read with curl, and a set of conventions — status codes, metadata, deadline propagation — that have to be established once and then followed. This section covers the parts that decide whether a gRPC service behaves well in production, all measured against grpcio 1.84. The parent section, Network I/O & Protocol Handling, covers the HTTP and transport layers underneath.

Scope of this section:

  • Building grpc.aio servers and clients, and reusing channels.
  • The four call shapes and the asyncio idiom for each.
  • Deadlines: reading them, propagating them, and what cancellation reaches.
  • Interceptors for authentication, tracing, metrics and status mapping.
  • Failure modes: UNKNOWN statuses, blocked loops, unbounded streams.

Architectural principles

  • Every call gets a deadline. A gRPC call without one waits indefinitely and pins resources at both ends. Deadlines are the mechanism that makes a distributed call chain bounded, and they only work if every hop sets one.
  • Statuses are the API. A client can act on NOT_FOUND, INVALID_ARGUMENT or UNAVAILABLE. It cannot act on UNKNOWN, which is what an unhandled exception becomes — along with the exception's repr, leaked to the caller.
  • One channel per target, created at startup. A channel is an HTTP/2 connection with multiplexing; 1,000 concurrent calls ran over one in 0.15 s. Creating one per call throws that away and pays a handshake each time.
  • Servicers must not block. They are coroutines on a shared loop. A synchronous database call in one stalls every concurrent RPC in the process, exactly as in an ASGI handler.
  • Cross-cutting concerns belong in interceptors. Auth, tracing, metrics and exception mapping apply to every method, including the ones added next month — which is precisely what an interceptor covers and a decorator does not.
What a gRPC call passes through 5 stacked layers from generated stub to servicer coroutine. What a gRPC call passes through generated stub typed methods from the .proto serialisation handled for you channel one HTTP/2 connection, many streams created once, closed at shutdown client interceptors auth, tracing, retries run on every call server interceptors status mapping, metrics wrap the handler servicer coroutine your code, on the loop cancelled when the deadline passes Measured: 500 two-hop calls through all of it in 0.19 s, p50 0.08 ms per hop.

Execution model: streams on one connection, coroutines on one loop

A channel holds an HTTP/2 connection, and every call is a stream on it. That is why concurrency costs so little — no new sockets, no handshakes, just multiplexed frames — and why grpc.max_concurrent_streams matters: beyond it, calls queue invisibly rather than failing.

On the server, each RPC is a coroutine on the loop. The lifecycle is where grpc.aio differs most usefully from the threaded implementation: when the deadline expires or the client disconnects, the servicer coroutine is cancelled, and the cancellation propagates into whatever it awaits — including downstream RPCs. Verified on a two-hop chain, a 0.5-second client deadline produced a cancelled leaf servicer at 0.45 s without any code arranging it.

The deadline itself, though, does not propagate automatically. A service that awaits a downstream call passes its cancellation along, but a service that spawns detached work, retries in the background, or continues after responding leaves that work running against nothing. Reading context.time_remaining() and passing it down explicitly — minus a margin so the hop has time to report its own failure — is what turns implicit cancellation into an enforced budget at every level.

One more model difference is worth stating before the patterns, because it shapes how a gRPC service is deployed. There is no connection-per-request, so the usual HTTP heuristics about pool sizes and keep-alive do not transfer: a client holds one connection per target and multiplexes everything over it, which means an idle period long enough for a NAT or load balancer to drop that connection takes every call down at once rather than one. grpc.keepalive_time_ms, set below the shortest idle timeout on the path, is the fix — the same reasoning as tuning TCP keepalive, with a larger blast radius when it is missing.

One budget across a call chain 5 stages from client: timeout=0.4 to all stop together. One budget across a call chain client: timeout=0.4 absolute on the wire edge reads it time_remaining() minus a margin room to answer leaf gets the rest its own deadline all stop together no orphan work Measured: DEADLINE_EXCEEDED reached the client at 0.35 s, with the leaf cancelled too.

Pattern catalogue

A servicer that maps failures to statuses

    async def Unary(self, request, context):
        if not request.text:
            await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "text is required")
        try:
            return await self.handle(request)
        except NotFoundError:
            await context.abort(grpc.StatusCode.NOT_FOUND, "no such record")

abort() is a coroutine in grpc.aio — forgetting the await silently does nothing. See building async gRPC services.

A deadline passed down with a margin

        remaining = context.time_remaining()
        budget = None if remaining is None else max(0.0, remaining - MARGIN)
        return await self.downstream.Unary(request, timeout=budget)

Measured: a 0.4-second client deadline produced DEADLINE_EXCEEDED at the client at 0.35 s, with the leaf cancelled at the same moment. See propagating gRPC deadlines and cancellation.

A stream that releases what it holds

    async def ServerStream(self, request, context):
        try:
            async for record in cursor:
                yield Reply(text=record.text)
        except asyncio.CancelledError:
            log.info("client left mid-stream")
            raise

The yield is the cancellation point, so an abandoned stream stops at the next message. See streaming RPCs with grpc.aio.

An interceptor that stops UNKNOWN reaching clients

            except grpc.RpcError:
                raise                                  # deliberate status: pass through
            except Exception:
                log.exception("unhandled error in %s", details.method)
                await context.abort(grpc.StatusCode.INTERNAL, "internal error")

Without it, a raised RuntimeError reaches the client as UNKNOWN: Unexpected <class 'RuntimeError'>: a bug in the handler. See adding interceptors.

One channel, reused

channel = grpc.aio.insecure_channel(target, options=[("grpc.keepalive_time_ms", 30_000)])
stub = EchoStub(channel)                               # cheap; create freely
...
await channel.close()                                  # once, at shutdown

A client that classifies its failures

try:
    return await stub.Method(request, timeout=budget)
except grpc.aio.AioRpcError as exc:
    if exc.code() is grpc.StatusCode.UNAVAILABLE:
        raise Retryable("no server reached") from exc       # nothing was processed
    if exc.code() is grpc.StatusCode.DEADLINE_EXCEEDED:
        raise Unknown("outcome unknown") from exc           # may have been processed
    raise                                                   # deterministic: do not retry

The distinction matters more in gRPC than in HTTP, because the statuses are precise enough to act on. UNAVAILABLE means the call did not reach a server and is always safe to retry; DEADLINE_EXCEEDED means the outcome is unknown, so retrying a non-idempotent method may duplicate its effect. INVALID_ARGUMENT, NOT_FOUND and PERMISSION_DENIED will produce the same answer every time and should fail fast, drawing nothing from the retry budget.

The four call shapes and their asyncio form A grid of 4 rows by 2 columns. The four call shapes and their asyncio form shape asyncio form note unary await stub.M(req, timeout=...) retryable if idempotent server stream async for r in stub.M(req) cancelled on client disconnect client stream await stub.M(async_gen()) bounded upload memory bidirectional write(), read(), done_writing() ends at grpc.aio.EOF All four share the deadline and cancellation model; only the message flow differs.

Resource boundaries

Resource What consumes it How to size and bound it
HTTP/2 streams One per in-flight call grpc.max_concurrent_streams; beyond it calls queue
Channels One connection each One per target, created at startup, closed at shutdown
Message size Request and reply payloads 4 MB default; raise on both ends or stream instead
Event loop time Every servicer and interceptor Nothing synchronous over ~1 ms; offload the rest
Server memory Buffered streaming messages HTTP/2 windows allow megabytes; pace expensive producers
Deadlines Time held by in-flight work Every call bounded; a service maximum when callers omit one
Connections through NAT Idle long-lived channels grpc.keepalive_time_ms below the shortest idle timeout

The streaming row is the one that surprises people: with 1 KB messages and a stalled client, a server-streaming method had produced 6,222 messages while the client had read 50 — roughly 6 MB buffered before flow control pushed back. The message-size row is the second: the 4 MB default applies independently to each direction and to both ends, so a client and server that disagree produce a RESOURCE_EXHAUSTED that appears only above a particular payload size, which is a memorable afternoon to debug. Where payloads can genuinely be large, a server-streaming method that sends them in pieces is better than raising the limit, because it bounds memory on both sides rather than just permitting more of it.

Integrated production example

An edge service and a leaf service, both with an observability interceptor, deadline propagation with a margin, status mapping, a streaming method and a graceful shutdown.

import asyncio
import logging
import time

import grpc

import echo_pb2 as pb
import echo_pb2_grpc as pbg

log = logging.getLogger("rpc")
MARGIN, MAX_BUDGET = 0.05, 10.0
metrics = {"ok": 0, "error": 0, "deadline": 0, "latency": []}


class Observability(grpc.aio.ServerInterceptor):
    """One place that sees every unary RPC: latency, status, and no leaked exceptions."""

    async def intercept_service(self, continuation, details):
        handler = await continuation(details)
        if handler is None or not handler.unary_unary:
            return handler                                  # streams pass through untouched
        inner = handler.unary_unary

        async def wrapper(request, context):
            started = time.perf_counter()
            try:
                reply = await inner(request, context)
                metrics["ok"] += 1
                return reply
            except grpc.RpcError:
                metrics["error"] += 1                       # a deliberate abort()
                raise
            except asyncio.CancelledError:
                metrics["deadline"] += 1                    # client gave up
                raise
            except Exception:
                metrics["error"] += 1
                log.exception("unhandled error in %s", details.method)
                await context.abort(grpc.StatusCode.INTERNAL, "internal error")
            finally:
                metrics["latency"].append(time.perf_counter() - started)

        return grpc.unary_unary_rpc_method_handler(
            wrapper,
            request_deserializer=handler.request_deserializer,
            response_serializer=handler.response_serializer)


class Service(pbg.EchoServicer):
    def __init__(self, downstream=None):
        self.downstream = downstream

    def budget(self, context) -> float:
        remaining = context.time_remaining()
        return min(remaining if remaining is not None else MAX_BUDGET, MAX_BUDGET)

    async def Unary(self, request, context):
        if not request.text:
            await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "text is required")
        if self.downstream is not None:
            budget = max(0.0, self.budget(context) - MARGIN)    # keep room to report
            try:
                return await self.downstream.Unary(request, timeout=budget)
            except grpc.aio.AioRpcError as exc:
                await context.abort(exc.code(), f"downstream: {exc.details()}")
        return pb.Reply(text=f"echo:{request.text}")

    async def ServerStream(self, request, context):
        index = 0
        try:
            for index in range(request.count):
                yield pb.Reply(text=request.text, index=index)
        except asyncio.CancelledError:
            log.info("stream cancelled at %d", index)
            raise


async def main() -> None:
    leaf = grpc.aio.server(interceptors=[Observability()])
    pbg.add_EchoServicer_to_server(Service(), leaf)
    leaf_port = leaf.add_insecure_port("127.0.0.1:0")
    await leaf.start()

    leaf_channel = grpc.aio.insecure_channel(f"127.0.0.1:{leaf_port}")
    edge = grpc.aio.server(interceptors=[Observability()])
    pbg.add_EchoServicer_to_server(Service(pbg.EchoStub(leaf_channel)), edge)
    edge_port = edge.add_insecure_port("127.0.0.1:0")
    await edge.start()
    try:
        await serve_until_signalled()
    finally:
        await edge.stop(grace=2)                            # drain in-flight RPCs
        await leaf_channel.close()
        await leaf.stop(grace=2)

Exercised from a client, 500 two-hop calls completed in 0.19 s; an empty request returned INVALID_ARGUMENT: 'text is required'; a handler that raised returned INTERNAL: 'internal error' with the traceback in the log rather than on the wire; a slow call with timeout=0.4 returned DEADLINE_EXCEEDED at 0.35 s; and a server stream delivered its 10 messages. The interceptor's own counters read ok=1000 error=3 deadline=1 p50=0.08 ms p99=3.99 ms — a thousand successes because each client call passes through two servers.

Diagnostic Hook — is the RPC layer healthy?

Four signals, all available from one server interceptor. Status distribution per method: a rising UNKNOWN count means exceptions are escaping your mapping, and a rising UNAVAILABLE at a client means a target is down or the channel is broken. Deadline-exceeded rate per method and per caller: it tells you whether the caller's budget is unrealistic or the method got slower. Per-hop latency histogram from inside the interceptor, compared with the client's view — the gap is queueing and network. In-flight streams against grpc.max_concurrent_streams, since exceeding it makes calls queue with no error anywhere. Alert on any UNKNOWN, on a deadline rate above a small percentage, and on in-flight streams above 80% of the limit.

Is gRPC the right interface here? A decision on Who calls this service with 3 outcomes. Is gRPC the right interface here? Who calls this service? other services you own gRPC fits typed, streaming, deadlines browsers directly gRPC-Web or HTTP no raw gRPC in the browser third parties HTTP and JSON lower barrier, easier debugging A schema and generated clients are the benefit; requiring both ends to adopt them is the cost.

Failure modes

Failure mode Root cause Detection Fix
Clients see UNKNOWN Unhandled exception in a servicer Status counts; details contain a Python repr Interceptor mapping exceptions to INTERNAL
Every RPC slow under light load A blocking call in a servicer Loop lag tracks the call duration asyncio.to_thread, or an async client
Work continues after the caller left Deadline not propagated to downstream calls Downstream metrics outlive the caller's Pass time_remaining() minus a margin
Calls hang forever No deadline set by the client Long-running RPCs with no end A timeout on every call; a service maximum
Streaming methods break after adding middleware Interceptor assumed unary_unary Only streaming methods fail Dispatch on handler shape; return others untouched
Memory grows during a stream Producer far ahead of a slow consumer Server memory tracks stream length Application-level pacing or acknowledgements
RESOURCE_EXHAUSTED on large messages Payload above the 4 MB default Fails at a size threshold Raise the limit on both ends, or stream
Connections dropped when idle NAT or load balancer idle timeout Failures after quiet periods grpc.keepalive_time_ms below that timeout

Frequently Asked Questions

What is grpc.aio and how is it different from grpc?

It is the asyncio implementation inside the same grpcio package: servicer methods are coroutines, calls are awaited, and there is no thread pool to size. The two APIs are not interchangeable — a synchronous stub called from a coroutine blocks the event loop for every concurrent RPC.

How do deadlines work across multiple gRPC services?

The client sets a timeout, which travels as an absolute deadline. Each service reads context.time_remaining(), subtracts a small margin, and passes the rest as the timeout of its downstream call. Measured on a two-hop chain, a 0.4 s client deadline produced DEADLINE_EXCEEDED at 0.35 s with the leaf cancelled too.

Why does my gRPC client get UNKNOWN errors?

A servicer raised an exception that nothing converted to a status. gRPC maps it to UNKNOWN and puts the exception's repr in the details. Add a server interceptor that logs the exception and aborts with INTERNAL, letting deliberate grpc.RpcError statuses through unchanged.

How many gRPC channels should a service create?

One per target, created at startup and closed at shutdown. A channel multiplexes many concurrent calls over one HTTP/2 connection — 1,000 concurrent unary calls took 0.15 s over a single channel — so per-call channels only add handshakes.

Is gRPC suitable for public or browser-facing APIs?

Browsers cannot speak raw gRPC, so they need gRPC-Web and a proxy. Third-party consumers generally prefer HTTP and JSON, which need no code generation and can be debugged with curl. gRPC is strongest for service-to-service traffic where both ends are yours.