gRPC

EDRI can expose the same @api-decorated Event dataclasses that already drive REST/WebSocket/HTML over gRPC — one unary rpc per event, plus one shared bidi-streaming rpc. The .proto schema is generated at runtime from those dataclasses; there is no hand-written .proto file to keep in sync and no committed generated _pb2.py stub files.


Enabling it

from edri import EDRI

edri = EDRI()
edri.add_component(...)
edri.start_grpc()
edri.run()

start_grpc() runs its own process (GRPCListener), analogous to the ASGI Listener start_api() starts — alongside it, or on its own. Both share the same Broker, started automatically if it isn't running yet. See EDRI.

Host/port default to EDRI_GRPC_HOST (localhost) / EDRI_GRPC_PORT (50051) — see Environment Variables — or pass host/port to start_grpc() explicitly.


Excluding events

Every event is GRPC-eligible by default, the same as REST/WS/HTML. Opt one out with exclude={ApiType.GRPC}:

from edri.config.constant import ApiType

@api(method=HTTPMethod.GET, exclude={ApiType.GRPC})
class InternalOnlyEvent(Event):
    ...

File-only events (upload/download) are excluded automatically — file transfer isn't supported over gRPC yet (see Limitations below).


Type mapping

proto3's scalar set doesn't cover every type an Event field commonly uses. edri.api.grpc.type_mapping maps:

Python / Event field type

proto3 representation

str, int, float, bool

matching proto3 scalar (string / int64 / double / bool)

datetime

google.protobuf.Timestamp

UUID

string

Enum subclass

string (the member name)

nested dataclass

nested message

Optional[X]

proto3 optional X

list[X]

repeated X

dict[str, Any]

google.protobuf.Struct

A field type this table can't represent (a bare Any, a non-dataclass custom object, Optional[list[X]], a multi-type union, an Injection-validated field) makes the whole event unavailable over gRPC — logged as a warning at schema-generation time, not a startup crash, the same "best effort" posture HTML already applies to template-less events.

This affects two of the framework's own built-in events: EdriHealth and EdriReady (see Health checking) both respond with a list[Record], and Record carries a pipe: Connection and a definition: ManagerBase field — neither representable here — so both events are excluded from gRPC and absent from the schema and reflection. EdriLive and Topology (see Router) have no such field and remain gRPC-eligible; reach EdriHealth over REST/WS and EdriReady over REST instead.


Unary calls

One unary rpc per GRPC-eligible event, named after the event's class:

@response
class GreetResponse(Response):
    message: str

@api(method=HTTPMethod.GET, exclude={ApiType.HTML})
class GreetEvent(Event):
    name: str
    response: GreetResponse

exposes rpc GreetEvent (GreetEventRequest) returns (GreetEventResponse) on the EdriService service. GreetEventRequest carries every field the caller supplies (name); GreetEventResponse always carries status first (a string, mirroring Response.as_dict()'s own field order), followed by the event's own response fields (message).


Bidi streaming

One shared bidi-streaming rpc, Connect, mirrors the WebSocket connection: register once, exchange as many events as you like over the lifetime of the stream, correlated by Event._id.

rpc Connect (stream ClientMessage) returns (stream ServerMessage);

ClientMessage/ServerMessage are oneof envelopes over every GRPC-eligible event's own request/response message, keyed by the event's snake_case name (greet_event, ...). Send a message by setting the matching oneof field; read a reply the same way, via WhichOneof("event").

A client may half-close its write side (finish sending) while replies to already-sent requests are still in flight — those replies are still delivered, up to EDRI_API_RESPONSE_TIMEOUT after the last request, before the stream ends.

A Connect stream is a persistent connection like a WebSocket, so it also receives events with handling=EventHandlingType.SUBSCRIBED/ALL (see Event): send EdriRegister/EdriUnregister over the stream (param_set maps to google.protobuf.Struct, per the type mapping above) the same way a WS client would, to subscribe to a shareable event's pushes. A gRPC unary call cannot receive these — its pipe is only read for the duration of that one call.


Reflection and codegen

The standard gRPC reflection service is always enabled, wired against the exact schema being served — point grpcurl, evans, or your language's codegen directly at the running server:

grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe edri.grpc.v1.EdriService

For a static .proto file instead (for example, to commit generated stubs for a non-Python client), render the same schema to text:

python -m edri.api.grpc.export edri.proto   # write to a file
python -m edri.api.grpc.export              # or to stdout

Each event's wire field number is derived from its own class name (not its position among whatever other events happen to be registered), so a stub compiled from one export stays wire-compatible as events are added or removed elsewhere in the codebase.


Errors

A ResponseDirective your business logic returns maps to a grpc.StatusCode, the same way REST maps directives to HTTP status codes and WS maps them to failure responses:

Directive

grpc.StatusCode

InternalServerErrorResponseDirective

INTERNAL

UnauthorizedResponseDirective

UNAUTHENTICATED

NotFoundResponseDirective

NOT_FOUND

ConflictResponseDirective

ALREADY_EXISTS

BadRequestResponseDirective

INVALID_ARGUMENT

UnprocessableContentResponseDirective

FAILED_PRECONDITION

ServiceUnavailableResponseDirective

UNAVAILABLE

TooManyRequestsResponseDirective

RESOURCE_EXHAUSTED

NotImplementedResponseDirective

UNIMPLEMENTED

GoneResponseDirective

NOT_FOUND

HeaderResponseDirective maps into trailing metadata instead of a status code. Directives with no gRPC equivalent (CookieResponseDirective, the redirect family, RangeNotSatisfiableResponseDirective, UnsupportedMediaTypeResponseDirective) fall through to the same "unhandled directive" logging REST/WS already have.

A unary call that gets no reply in time fails DEADLINE_EXCEEDED — bounded by the client's own gRPC deadline if it set one, capped by EDRI_API_RESPONSE_TIMEOUT (see Environment Variables) otherwise.


Limitations

  • No file upload/download over gRPC yet — file-only events are excluded automatically.

  • No TLS/mTLS or auth interceptors built in — terminate TLS and authenticate the same way you already do in front of the process; a future iteration can add gRPC interceptors the way REST/WS have middleware today.

  • GRPCListener runs as a single process — no built-in multi-worker scaling, matching the framework's general single-process-per-listener posture (see Deployment).