gRPC¶
A gRPC server entrypoint built on
grpc-server-kit, with the standard health service,
per-RPC DI scopes, correlation from metadata and ServiceError mapping already wired.
from servicewright import Service
from servicewright.adapters.grpc import GrpcConfig, GrpcEntrypoint
def register_servicers(server, ctx) -> None:
orders_pb2_grpc.add_OrdersServicer_to_server(OrdersServicer(), server)
grpc_ep = GrpcEntrypoint(
config=GrpcConfig(port=50051),
servicers=register_servicers,
)
service = Service(spec, entrypoints=[grpc_ep])
servicers is called during bind with the raw grpc.aio.Server and the ServiceContext, so it
can resolve singletons from ctx.app_scope. It may be async.
Per-RPC dependency scope¶
UnitScopeInterceptor is added first — the outermost interceptor — so every downstream
interceptor and your servicer run inside a live unit scope:
from servicewright.adapters.grpc import current_unit_scope
class OrdersServicer(orders_pb2_grpc.OrdersServicer):
async def GetOrder(self, request, context):
scope = current_unit_scope()
use_case = await scope.get(GetOrder)
order = await use_case.execute(request.order_id)
return orders_pb2.Order(id=order.id)
The scope stays open for the whole RPC, including the full response stream of a streaming call.
Correlation from metadata¶
The interceptor extracts and binds these into the context store:
| Context key | From metadata |
|---|---|
request_id |
x-request-id (generated when absent) |
user_id |
x-user-id |
tenant_id |
x-tenant-id |
trace_id |
x-trace-id |
idempotency_key |
x-idempotency-key |
client_ip |
x-forwarded-for, then x-real-ip |
user_agent |
user-agent / x-user-agent |
grpc_method |
the RPC's method name |
Helpers for reading them straight off a servicer context:
from servicewright.adapters.grpc import (
get_client_context,
get_client_ip,
get_idempotency_key,
get_user_agent,
)
Error mapping¶
A ServiceError raised anywhere in your servicer is converted into the matching abort:
raise OrderNotFoundError("no such order")
# → the RPC aborts with NOT_FOUND
# → the machine code travels in the "x-error-code" trailing metadata
Non-public errors are masked exactly like over HTTP: the client sees a generic INTERNAL and the
real code only reaches your logs. The full mapping table is in
Errors.
Turn it off with map_service_errors=False.
Anything that is not a ServiceError¶
UnhandledErrorInterceptor is the last resort, and it is always installed:
raise RuntimeError("dividing by the number of retries, which is zero")
# → the RPC aborts with INTERNAL
# → details "internal_error", trailing metadata "x-error-code: internal_error"
# → the exception and its traceback go to the log, with the RPC's correlation ids
Byte for byte what a public=False ServiceError produces, which is the point: the two are
indistinguishable to a caller. Without it grpc.aio answers UNKNOWN with repr() of the
exception, so the one error class whose wording nobody reviewed is the one that reaches the wire
verbatim.
Three things are left alone, because each already carries a decision:
grpc.aio.AbortError and grpc.RpcError (a status a handler chose itself) are re-raised, and
asyncio.CancelledError never reaches it — a caller that walked away is not a failure to report.
map_service_errors=False does not remove it. That flag turns off the mapping of the errors you
declared; with it off a ServiceError reaches the client as a masked INTERNAL like any other
unhandled exception, never as its own message.
Interceptor ordering¶
grpc.aio hands control in list order, so the first entry is the outermost wrapper. The
entrypoint assembles the chain like this:
UnitScopeInterceptor ← outermost: everything below sees a live scope
UnhandledErrorInterceptor ← the net: inside the scope, so the log is correlated
metrics interceptor ← records the status the client actually receives
your static interceptors
your interceptors_factory results
ServiceErrorInterceptor ← innermost: closest to the servicer
your servicer
Why ServiceErrorInterceptor is innermost
A ServiceError has to become a mapped abort before any interceptor of yours sees it. A
generic exception handler — grpc-server-kit's AsyncExceptionHandlerInterceptor, for
instance, which maps unknown exceptions to INTERNAL — would otherwise swallow every domain
error and turn a deliberate NOT_FOUND into a 500-equivalent.
It still sits inside the metrics interceptor, so aborts are recorded with their real status.
Why UnhandledErrorInterceptor is not
It is the mirror of the HTTP stack's UnhandledErrorMiddleware, and it sits at the same
depth: inside the layer that binds the correlation ids, so the masked abort is logged with
the request id, and outside everything else, so it only ever sees what nobody below it
claimed. An interceptor of yours that maps an exception type of its own still gets it first —
including grpc-server-kit's AsyncExceptionHandlerInterceptor, if that is the mapping you
want. Compose exactly as before.
GrpcEntrypoint(
config=config,
servicers=register_servicers,
interceptors=[AuthInterceptor()],
interceptors_factory=lambda ctx: [RateLimitInterceptor(ctx.app_scope)],
)
Health, reflection and channelz¶
The standard grpc.health.v1.Health service is always registered, driven by the same
HealthRegistry as the HTTP probes:
Because the gRPC health API is a push API, the entrypoint polls readiness every
health_refresh_interval seconds while serving, so a dependency failing mid-life flips the status
to NOT_SERVING within roughly one interval. On drain, every tracked name is pinned to
NOT_SERVING so load balancers stop routing.
By default only the overall (empty-string) name is reported — the one a plain gRPC readiness probe
checks. List your own services to answer Check for them too:
Reflection and channelz are off by default
Both serve on the same port as production traffic and are unauthenticated unless one of your interceptors authenticates them.
- Reflection lets tools like
grpcurlresolve symbols without a local.proto. Handy in dev and staging. - Channelz exposes
GetServers/GetServerSockets/GetSocket/GetTopChannels, revealing connected peers' addresses and per-socket call and byte counters.
Metrics¶
Records through whatever metrics backend the app configured — a null object when metrics are off, so the flag is always safe:
| Metric | Labels |
|---|---|
grpc_requests_total |
service, method, status, grpc_code |
grpc_request_duration_seconds |
service, method |
Prefix them with metrics_prefix="myapp".
GrpcConfig¶
The main knobs:
| Field | Default | Meaning |
|---|---|---|
host |
"0.0.0.0" |
Bind host |
port |
50051 |
Bind port (0 picks a free one) |
grace_period |
30.0 |
The entrypoint's own drain budget |
enable_reflection |
False |
Serve server reflection |
enable_channelz |
False |
Serve channelz |
reflection_service_names |
None |
Extra names to advertise via reflection |
health_service_names |
() |
Concrete services to report health for |
health_refresh_interval |
5.0 |
Readiness poll interval; 0 disables polling |
max_concurrent_rpcs |
None |
Server-side concurrency cap |
Plus the full server-tuning surface — keepalive (keepalive_time_ms, keepalive_timeout_ms,
keepalive_permit_without_calls), HTTP/2 ping policy, message and metadata size limits, flow
control windows, connection age limits, compression — and TLS (ssl_enabled, ssl_cert_file,
ssl_key_file, ssl_ca_file, ssl_client_auth). See the
API reference for every field.
GrpcConfig satisfies grpc-server-kit's settings protocol, so it can be handed straight to the
kit's primitives.
From settings¶
grpc-server-kit ships the environment-facing side of the same field set,
grpc_server_kit.settings.BaseGrpcServerSettings — a pydantic model, so grpc-server-kit[settings],
which servicewright[settings] covers already. GrpcConfig.from_settings reads every field off
it, so the mapping lives here rather than in each service:
from grpc_server_kit.settings import BaseGrpcServerSettings
from servicewright.adapters.grpc import GrpcConfig
from servicewright.adapters.settings import BaseServiceSettings
class Settings(BaseServiceSettings):
grpc: BaseGrpcServerSettings = BaseGrpcServerSettings()
settings = Settings() # GRPC__PORT=50051 GRPC__KEEPALIVE_TIME_MS=30000 GRPC__SSL_ENABLED=true ...
config = GrpcConfig.from_settings(settings.grpc, health_service_names=("my.pkg.Orders",))
settings is anything satisfying the kit's GrpcServerSettingsProtocol — the pydantic model, the
stdlib grpc_server_kit.GrpcServerConfig, a model of your own — and every value comes from it,
defaults included: the kit binds [::] with a 5 s grace_period, 8 KiB of metadata and
keepalive_permit_without_calls=False where GrpcConfig() has 0.0.0.0, 30 s, 16 KiB and True.
A test in servicewright asserts that every GrpcConfig field is either read from the settings
object or in the table below, so a knob added to GrpcConfig cannot leave the mapping silently
incomplete.
Three fields have no source on a settings object and are keyword arguments instead:
| Keyword | Default | Why it is not read from settings |
|---|---|---|
health_service_names |
() |
Your own servicers' names: code, not environment |
reflection_service_names |
None |
Same |
health_refresh_interval |
5.0 |
The kit's health.cache_ttl looks alike but means the opposite at 0: no caching there, no polling here |
The kit's metrics_enabled has no GrpcConfig field either; that switch is
GrpcEntrypoint(enable_metrics=settings.grpc.metrics_enabled).
Drain semantics¶
The effective drain budget is min(host_grace, config.grace_period):
- the Host's
drain_grace_secondsbounds the shutdown; grace_periodcan only shorten it.
Raising grace_period above the Host's allowance has no effect — raise
AppSpec.drain_grace_seconds instead.
Lifecycle notes¶
bind()creates the server, registers your servicers and the health servicer, and binds the port. A clash aborts startup.serve()starts the server, starts the health poller, and waits for the stop event — still accepting.drain(grace)stops the poller, pins health toNOT_SERVINGand lets in-flight RPCs finish.stop()is the hard stop.
grpc-server-kit's own run_async_grpc_server helper — which installs its own signal handlers and
blocks — is deliberately not used. The Host owns signals.