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.aioservers 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:
UNKNOWNstatuses, 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_ARGUMENTorUNAVAILABLE. It cannot act onUNKNOWN, 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.
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.
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.
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.
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.
Related¶
- Building async gRPC services with grpc.aio — servers, channels and status codes.
- Streaming RPCs with grpc.aio — the three streaming shapes.
- Propagating gRPC deadlines and cancellation — one budget across a chain.
- Adding interceptors to grpc.aio clients and servers — cross-cutting concerns.
- Network I/O & Protocol Handling — the parent section.