gRPC¶
BlackBull serves gRPC — all four RPC shapes: unary, server-streaming,
client-streaming, and bidirectional — over its own pure-Python HTTP/2 layer.
gRPC is just HTTP/2 with content-type: application/grpc and a length-prefixed
message body, where the call result is reported in grpc-status trailers — so a
gRPC service multiplexes onto the same port as your REST and WebSocket
traffic, in one process, with no C extensions and nothing to compile on ARM.
from blackbull import BlackBull
from blackbull.grpc import GrpcServiceRegistry, GrpcStatus, GrpcError
app = BlackBull()
grpc = GrpcServiceRegistry()
@grpc.method('/helloworld.Greeter/SayHello')
async def say_hello(request: bytes, context) -> bytes:
# `request` is the raw protobuf message body; deserialise it however you
# like (this example just echoes bytes back).
if not request:
context.abort(GrpcStatus.INVALID_ARGUMENT, 'empty request')
return b'Hello, ' + request
app.enable_grpc(grpc)
# REST on the same port — unaffected
@app.route(path='/healthz')
async def healthz():
return {'ok': True}
app.run(port=8443, certfile='cert.pem', keyfile='key.pem') # gRPC needs HTTP/2 (TLS+ALPN)
The handler contract¶
A gRPC handler is a coroutine:
async def handler(request: bytes, context: GrpcContext) -> bytes
request— the de-framed request message bytes (a single message for unary calls). Deserialise with your protobuf classes:MyRequest.FromString(request).- return value — the response message bytes:
my_response.SerializeToString(). context— per-call context (see below).
Protobuf is not a dependency. Handlers exchange raw bytes, so you choose
your own serialisation — grpc_tools.protoc-generated classes, the protobuf
package, or hand-rolled. The pip install 'blackbull[grpc]' extra reserves the
name but pulls in nothing; add protobuf yourself if you want generated
message classes.
Server-streaming¶
A server-streaming handler takes one request and returns many responses. Write
it as an async generator that yields each response message:
@grpc.method('/pkg.Svc/StreamItems')
async def stream_items(request: bytes, context):
for item in load_items(request):
yield item.SerializeToString()
The registry detects the async-generator form automatically — no flag needed.
(If a decorator hides the generator nature of your handler, force it with
grpc.method(path, streaming=True) or add_method(path, fn, streaming=True).)
Request lifecycle events identify the dispatcher as serve_grpc in handler.
Semantics that match the gRPC spec:
- The status rides the trailing field section after the last message. A handler that fails after emitting messages reports its status in the trailers; one that fails before the first message produces a clean Trailers-Only error, exactly like a unary failure.
context.set_trailing_metadata(...)/set_code(...)apply to the final trailers.- One
grpc-timeoutdeadline covers input, handler work and response completion for all four RPC forms. Expiry cancels the call and reportsDEADLINE_EXCEEDED; a handler's ownTimeoutErrorreportsINTERNAL. On deadline expiry, cleanup and expiry reporting each have a 100 ms grace period. If reporting stalls, an exception lets the host terminate the stream. Handlers must cooperate with cancellation; synchronous work cannot be preempted.
Client-streaming and bidirectional RPCs¶
A handler whose first parameter is named request_iter (also accepted:
request_iterator, requests, request_stream) receives the request as an
async iterator of message bytes instead of one buffered bytes value —
messages are delivered as they arrive on the wire. Pass
client_streaming=True to override the name-based detection:
@grpc.method('/pkg.Svc/Sum')
async def sum_lengths(request_iter, context) -> bytes: # client-streaming
total = 0
async for msg in request_iter:
total += len(msg)
return str(total).encode()
@grpc.method('/pkg.Svc/Echo')
async def echo(request_iter, context): # bidirectional
async for msg in request_iter:
yield msg
The response axis is detected automatically (a plain coroutine returns one message; an async generator streams), so the four gRPC shapes — unary, server-streaming, client-streaming, and bidirectional — all register through the same decorator.
The call context¶
GrpcContext carries request metadata and lets the handler shape the outcome:
| Member | Purpose |
|---|---|
context.metadata(name, default=b'') |
Read a request header (call metadata). |
context.abort(status, details='') |
End the call immediately with a non-OK GrpcStatus. |
context.set_code(status) / context.set_details(str) |
Set the status reported in the trailers (defaults to OK). |
context.set_trailing_metadata([(b'k', b'v')]) |
Add custom trailing metadata. |
Values passed to send_initial_metadata() or set_trailing_metadata() use
grpcio's application-facing representation. For a key ending in -bin, pass
the raw bytes; BlackBull converts them to canonical unpadded base64 before the
HTTP/2 field section is encoded. Other metadata values are emitted unchanged
and must obey the ordinary HTTP response-field rules. The
grpc-status-details-bin value produced by blackbull-protobuf is already in
canonical wire form and is recognised without encoding it twice.
Raising GrpcError(status, details) anywhere in the handler has the same effect
as context.abort. Any other unexpected exception becomes
GrpcStatus.INTERNAL — a handler bug never tears down the stream.
Registering services¶
grpc = GrpcServiceRegistry()
# One method at a time, decorator or call form:
@grpc.method('/pkg.Svc/Method')
async def method(request, context): ...
grpc.add_method('/pkg.Svc/Other', other_handler)
# Or a whole service at once:
grpc.add_service('helloworld.Greeter', {'SayHello': say_hello})
Paths are the standard gRPC /package.Service/Method form (a leading slash is
optional). Unknown methods return GrpcStatus.UNIMPLEMENTED.
Protobuf integration: blackbull-protobuf¶
Everything above exchanges raw bytes. The optional
blackbull-protobuf package
adds the protobuf side of the story while the framework core stays
dependency-free:
pip install 'blackbull[protobuf]'
Object-typed handlers — write servicers against your generated *_pb2
messages; the adapter does FromString/SerializeToString at the boundary
and maps each method's streaming shape automatically:
import helloworld_pb2
from blackbull_protobuf import add_servicer
class Greeter:
async def SayHello(self, request, context):
return helloworld_pb2.HelloReply(message=f'Hello, {request.name}!')
add_servicer(grpc, Greeter(), helloworld_pb2)
Server reflection — grpcurl, grpc_cli, and Postman can list, describe,
and invoke your services with no local .proto files:
from blackbull_protobuf import enable_reflection
enable_reflection(grpc)
$ grpcurl -insecure localhost:8443 list
helloworld.Greeter
grpc.health.v1.Health
grpc.reflection.v1alpha.ServerReflection
$ grpcurl -insecure -d '{"name": "world"}' localhost:8443 helloworld.Greeter/SayHello
Health checking — the standard grpc.health.v1.Health service
(Check + Watch) behind a settable status map, for Kubernetes and
load-balancer probes:
from blackbull_protobuf import enable_health, HealthService
health = enable_health(grpc)
health.set('helloworld.Greeter', HealthService.SERVING)
# during drain:
health.set('helloworld.Greeter', HealthService.NOT_SERVING) # Watch streams see it
Rich error details — attach structured google.rpc details
(BadRequest, ErrorInfo, RetryInfo, …) that ride the
grpc-status-details-bin trailer and decode on the client with grpcio's
rpc_status:
from google.rpc import error_details_pb2
from blackbull_protobuf import abort_with_details
bad = error_details_pb2.BadRequest()
bad.field_violations.add(field='name', description='must not be empty')
abort_with_details(context, GrpcStatus.INVALID_ARGUMENT,
'name is required', [bad])
The raw-bytes registry path is untouched — both styles coexist on one
registry, and blackbull.grpc works fully without the package installed.
Testing your servicers¶
import pytest
from blackbull.grpc import GrpcStatus
from blackbull.testing.grpc import GrpcTestServer
@pytest.mark.asyncio
async def test_say_hello():
async with GrpcTestServer(app) as grpc:
reply = await grpc.unary('/demo.Greeter/SayHello', b'world')
assert reply.status is GrpcStatus.OK
assert reply.message == b'hi world'
GrpcTestServer serves your app on an ephemeral h2c port and drives it over
a real socket, so the whole dispatch path runs — the gRPC analogue of
NativeTestServer. The length-prefixed framing is applied for
you: pass the message bytes your servicer expects, not an encoded frame.
GrpcReply carries what a servicer test asserts on:
| Field | |
|---|---|
status |
the grpc-status, as a GrpcStatus — synthesized for a malformed reply |
grpc_message |
the grpc-message, percent-decoded |
message |
the first response message, unframed |
messages |
every response message, for server-streaming calls |
response |
the raw ClientResponse, for anything the above does not surface |
violation |
None for a well-formed reply; otherwise what is wrong with it |
A malformed reply is never OK. status follows what a gRPC client must
report (PROTOCOL-HTTP2):
| Reply | status |
|---|---|
no grpc-status |
from the HTTP status (mapping): 200 → UNKNOWN, 503 → UNAVAILABLE, … |
a repeated, non-decimal or undefined grpc-status |
UNKNOWN |
grpc-status: 0, but a non-200 HTTP status or a non-gRPC content-type |
from the HTTP status |
grpc-status: 0, but broken framing, a compressed message, or a unary reply without exactly one message |
INTERNAL |
any other valid grpc-status |
as sent |
The helper accepts identity encoding only. Whether a method is unary comes from the app's gRPC registry.
An error asserts the same way, because a gRPC error is a response:
async def failing(request, context):
raise GrpcError(GrpcStatus.NOT_FOUND, 'no such greeting')
reply = await grpc.unary('/demo.Greeter/SayHello', b'x')
assert reply.status is GrpcStatus.NOT_FOUND
assert reply.grpc_message == 'no such greeting'
Why not the in-process test client¶
Every gRPC response reports its status in trailing headers — success and
error alike. An ASGI transport with no http.response.trailers support
never observes response completion, so TestClient (which
rides httpx.ASGITransport) cannot see a gRPC call finish at all. That is
not a limitation of your servicer; it is the framing real gRPC clients
require.
GrpcTestServer therefore drives a real socket with BlackBull's own
HTTP2Client, which handles trailers natively. This is the pattern the
framework's own gRPC tests have always used; it was internal until now.
BlackBull ships no gRPC client, and this is not one. It is a test
environment: GrpcTestServer.port is public, so grpcio or any other client
can drive the same server if you would rather assert against the one your
users will actually use.
Scope and limits¶
- All four RPC shapes are served: unary, server-streaming, client-streaming, and bidirectional (see above).
- A request reaches gRPC when its media type is
application/grpcorapplication/grpc+<subtype>, in any case and with any parameters. Any othercontent-type,application/grpc-webincluded, goes to your HTTP routes. BB_GRPC_MAX_MESSAGE_SIZE(default 4 MiB) bounds each decoded request and response message. Incoming encoded messages also have a 16 MiB safety cap. Declared oversize is rejected at the prefix. Unary and server-streaming accept one request message. Streaming caps apply per message; transport body and time limits still apply.- The message compression flag must be
0or1. Other values fail the RPC withINTERNALwithout waiting for the message body or decompressing it;decode_messagesraisesGrpcDecodeErrorfor them. - gzip message compression is supported (
grpc-encoding: gzip/identity); a compressed request in any other coding is rejected withUNIMPLEMENTED, and the response advertisesgrpc-accept-encoding: identity,gzipso the client can retry. A compressed request must be one complete gzip member within the size cap; a truncated or unreadable stream, or trailing bytes, fails the RPC (INTERNAL, orRESOURCE_EXHAUSTEDpast the cap) instead of reaching the handler. - HTTP/2 required. Run with TLS + ALPN (
h2) for real clients; the gRPC request must arrive over the HTTP/2 layer. - BlackBull is server-side. For a client, use the standard
grpcioclient against your service.
See blackbull.grpc for the codec (encode_message / decode_messages) and
status codes (GrpcStatus).
How it fits the architecture¶
gRPC is served through BlackBull's existing ASGI bridge rather than a dedicated
protocol Actor — each call still runs in its own isolated HTTP/2 StreamActor
task, but the gRPC logic lives at the application layer. For the full design
rationale (and why streaming RPCs may revisit it), see the
gRPC assessment
and Internals.