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"

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"})

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, and benzene-status trailer are unaffected.

Exports

to_grpc, from_grpc, BENZENE_STATUS_TRAILER, GRPC_DETAILS_TRAILER, add_benzene_handler, BenzeneGrpcHandler, GrpcMessageSender, method_for, topic_for.

See also