benzene.grpc
The gRPC edge of the Benzene wire contract: the Benzene ↔ gRPC status mapping (wire-contracts.md
§4.2), the benzene-status trailer rule, and the server/client transport over grpcio.
Distribution: benzene-grpc (depends on benzene-core; the transport adds the [transport]
extra).
pip install benzene-grpc # the status mapping (no grpcio)
pip install 'benzene-grpc[transport]' # + the gRPC server/client transport
Status mapping
from benzene.grpc import to_grpc, from_grpc, BENZENE_STATUS_TRAILER
to_grpc("not-found") # -> "NotFound"
to_grpc("created") # -> "OK" (every success status collapses to OK)
to_grpc("some-extension") # -> "Internal" (unknown/missing failure)
from_grpc("InvalidArgument") # -> "bad-request"
from_grpc("SomeFutureCode") # -> "unexpected-error"
- Forward (
to_grpc, server side): a Benzene status → a gRPCStatusCodename. All success statuses map toOK; each failure status maps per §4.2; an unknown/missing status maps toInternal. - Reverse (
from_grpc, client side): a gRPCStatusCodename → a Benzene status; an unrecognised code falls back tounexpected-error.
gRPC codes are their canonical names as strings ("OK", "InvalidArgument", …), so the mapping
needs no grpcio dependency — a transport binding translates them to grpc.StatusCode members at its
edge.
The benzene-status trailer
Because several Benzene statuses collapse to one gRPC code (all success → OK), a gRPC server MUST
attach a benzene-status trailer carrying the raw status verbatim. A client, seeing the trailer, uses
it in preference to re-deriving from the code:
from_grpc("OK", trailer="created") # -> "created" (the trailer wins verbatim)
BENZENE_STATUS_TRAILER is the trailer key ("benzene-status"). Pinned by
grpc-status-mapping.json.
Structured errors: the grpc-status-details-bin trailer
There is no JSON problem document over gRPC — a non-OK call has no body — so wire-contracts §4.2 maps
the problem's information onto gRPC's own error model instead. benzene-status carries
benzeneStatus; the structured errors cross as a google.rpc.BadRequest packed into a
google.rpc.Status on the grpc-status-details-bin trailer, one FieldViolation per error:
BenzeneError |
google.rpc.BadRequest.FieldViolation |
|---|---|
message |
description |
field |
field, as a dotted path: the JSON Pointer's segments unescaped and joined with ., a digits-only segment written [n] (/items/0/sku → items[0].sku); empty when the error has no field. Read back, the dotted path becomes a pointer again |
code |
reason (§4.2, spec 1.1); empty when the error has no code. Read back, a non-empty reason becomes code again |
The google.rpc.Status itself carries the numeric gRPC code and the same detail string the call sets,
which is what lets a non-Benzene peer read it with the stock grpc_status.rpc_status.from_call.
result = await sender.send_message("orders:submit", {})
result.status # -> "validation-error"
[(e.message, e.field, e.code) for e in result.errors] # -> [("sku is required", "/sku", "missing"), ...]
The server attaches the trailer whenever the outcome is non-OK; the BadRequest goes in whenever the
result carries errors, not only for validation-error. A client reading a peer that sends no details
keeps the message-only behaviour it always had. GRPC_DETAILS_TRAILER is the trailer key.
The google.rpc messages come from grpcio-status, which the [transport] extra installs. Without
it the trailer is simply not written or read — the status mapping and benzene-status are unaffected.
The transport ([transport] extra)
A Benzene gRPC service is a generic gRPC handler: the method name is the topic (the grpc topic
source is "method"), so one handler serves every topic. Each unary call carries the message body as its
bytes and the Benzene headers as request metadata.
from concurrent.futures import ThreadPoolExecutor
import grpc
from benzene.core import BenzeneMessageApplication
from benzene.grpc import add_benzene_handler, GrpcMessageSender
# Server: serve every topic as a unary method
server = grpc.server(ThreadPoolExecutor(max_workers=8))
add_benzene_handler(server, BenzeneMessageApplication(registry))
server.add_insecure_port("[::]:50051"); server.start()
# Client: a MessageSender over a channel
sender = GrpcMessageSender(grpc.insecure_channel("localhost:50051"))
result = await sender.send_message("orders:place", {"sku": "A"}, headers={"x-correlation-id": "c1"})
add_benzene_handler(server, application)registers aBenzeneGrpcHandleron agrpc.Server. The response carries the mappedStatusCodeand — on success and failure alike — abenzene-statustrailer with the raw status, so a status likecreated(which maps to gRPCOK) survives the round trip exactly. A failure also carries the structured errors ongrpc-status-details-bin— onegoogle.rpc.BadRequest.FieldViolationper error:messageasdescription,fieldas the dotted path,codeasreason.GrpcMessageSender(channel, *, timeout=None)is aMessageSender: it calls/benzene.Benzene/<topic>, forwards the headers as metadata, and maps the outcome back (the trailer wins verbatim, else the code is mapped), reading a failure'sBadRequestdetails back intoresult.errors(message,fieldandcode). The blocking gRPC call runs on a worker thread, so it never blocks the event loop.timeoutis the call's gRPC deadline; a call that gets no reply returns a Result, never raises (transport-bindings §1): the deadline elapsing istimeout, an unreachable peer or a closed channelservice-unavailable.method_for(topic)/topic_for(method)are the method-path convention if you need them directly.
Testing
create_test_host(StartUp).with_services(...).build_grpc() drives the real BenzeneGrpcHandler in
memory — the same one-specialization-step harness as the cloud hosts, no socket:
host = create_test_host(OrdersStartUp).with_services(overrides).build_grpc()
reply = host.send_grpc("orders:place", body={"sku": "A"}, headers={"x-correlation-id": "c1"})
assert reply.status == "created" # the benzene-status trailer, verbatim; reply.code has the StatusCode
send_grpc builds the native ingress (method = topic, headers = metadata, bytes body) and returns a
GrpcResponse(status, payload, code, details). See benzene.testing.
Spec note (documented bend). The method-path scheme
/benzene.Benzene/<topic>is this port's convention, not a wire contract. The gRPC binding catalog in transport-bindings §2 is informative and describes .NET's explicit (route → topic) registrations; the Python port instead serves every topic through one generic handler, which is the idiomatic gRPC-Python shape. Two Benzene Python services interoperate over gRPC out of the box; talking gRPC to a binding that uses a different method path (e.g. a .NET service's/package.Service/Method) means agreeing the path on both sides. This is a binding (tier-D) detail — the envelope, headers, status vocabulary, andbenzene-statustrailer are unaffected.
Exports
to_grpc, from_grpc, BENZENE_STATUS_TRAILER, GRPC_DETAILS_TRAILER, add_benzene_handler,
BenzeneGrpcHandler, GrpcMessageSender, method_for, topic_for.
See also
benzene.http— the analogous Benzene ↔ HTTP status mapping and the ASGI binding.benzene.results— the status vocabulary.