|
| 1 | +# Tier 2 — gRPC (thin mount) — design spec |
| 2 | + |
| 3 | +**Status:** Approved — ready for implementation |
| 4 | +**Date:** 2026-04-19 |
| 5 | +**Scope:** Ship `app.mount_grpc(servicer, port=..., ...)` — a thin integration around `grpc.aio` (official grpcio). Users bring their own `*_pb2.py` / `*_pb2_grpc.py` (generated by `protoc` / `buf`). Framework ships: server lifecycle tied to ASGI lifespan, built-in observability interceptor, reflection toggle, TLS passthrough. Zero runtime deps on the default path — `grpcio` imported lazily. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Goal |
| 10 | + |
| 11 | +```python |
| 12 | +from hawkapi import HawkAPI |
| 13 | +from myproto import greeter_pb2, greeter_pb2_grpc |
| 14 | + |
| 15 | +app = HawkAPI() |
| 16 | + |
| 17 | +class Greeter(greeter_pb2_grpc.GreeterServicer): |
| 18 | + async def SayHello(self, request, context): |
| 19 | + # The HawkAPI app is attached to every ServicerContext. |
| 20 | + app = context.hawkapi_app |
| 21 | + name = await app.flags.string("greeter.prefix", default="Hello") |
| 22 | + return greeter_pb2.HelloReply(message=f"{name}, {request.name}!") |
| 23 | + |
| 24 | +app.mount_grpc( |
| 25 | + Greeter(), |
| 26 | + add_to_server=greeter_pb2_grpc.add_GreeterServicer_to_server, |
| 27 | + port=50051, |
| 28 | +) |
| 29 | +``` |
| 30 | + |
| 31 | +The gRPC server starts when the ASGI app starts (`startup` lifespan event) and stops on `shutdown`. Works out-of-the-box under uvicorn/granian — one process serves both HTTP (ASGI) and gRPC (HTTP/2) on different ports. |
| 32 | + |
| 33 | +## Semantics |
| 34 | + |
| 35 | +### `mount_grpc` signature |
| 36 | + |
| 37 | +```python |
| 38 | +def mount_grpc( |
| 39 | + self, |
| 40 | + servicer: object, |
| 41 | + *, |
| 42 | + add_to_server: Callable[[object, grpc.aio.Server], None], |
| 43 | + port: int = 50051, |
| 44 | + host: str = "[::]", |
| 45 | + interceptors: Sequence[grpc.aio.ServerInterceptor] = (), |
| 46 | + observability: bool = True, |
| 47 | + reflection: bool = False, |
| 48 | + ssl_credentials: grpc.ServerCredentials | None = None, |
| 49 | + autostart: bool = True, |
| 50 | + max_workers: int | None = None, |
| 51 | + options: Sequence[tuple[str, Any]] = (), |
| 52 | +) -> GrpcMount: ... |
| 53 | +``` |
| 54 | + |
| 55 | +- `servicer` — an instance of a user-written `*Servicer` subclass. |
| 56 | +- `add_to_server` — the generated `add_FooServicer_to_server` function (passed explicitly; we don't introspect). |
| 57 | +- `interceptors` — user-provided `grpc.aio.ServerInterceptor` list; appended **after** the built-in observability interceptor (if enabled). |
| 58 | +- `observability=True` — installs `HawkAPIObservabilityInterceptor` (logs method/status/duration, exports to the same Prometheus registry as HTTP middleware). |
| 59 | +- `reflection=False` by default to avoid schema leak in prod; set `True` for local dev / staging. |
| 60 | +- `ssl_credentials` — passed through to `server.add_secure_port`; when `None`, `add_insecure_port` is used. |
| 61 | +- `autostart=True` — server starts on ASGI `startup`; set `False` for tests/custom wiring. |
| 62 | +- Returns `GrpcMount` handle with `.server`, `.port`, `.start()`, `.stop(grace: float = 5.0)` for manual control. |
| 63 | + |
| 64 | +Multiple `mount_grpc` calls are supported — each call can target a different port, or the same port with a different servicer (handlers are merged on the same grpc.aio.Server instance when ports match). |
| 65 | + |
| 66 | +### ASGI lifespan integration |
| 67 | + |
| 68 | +HawkAPI already fires `startup` / `shutdown` callbacks. `mount_grpc` registers: |
| 69 | + |
| 70 | +- `startup` → `await grpc_mount._start()` — builds `grpc.aio.server(...)`, calls `add_to_server(servicer, server)`, registers interceptors, binds port, calls `await server.start()`. |
| 71 | +- `shutdown` → `await grpc_mount._stop(grace=5.0)` — `await server.stop(grace)`. |
| 72 | + |
| 73 | +Errors during `_start` propagate to the ASGI runner (uvicorn shows them and exits). No background task leaks. |
| 74 | + |
| 75 | +### Context injection |
| 76 | + |
| 77 | +Built-in interceptor attaches two attributes to every `ServicerContext`: |
| 78 | + |
| 79 | +```python |
| 80 | +context.hawkapi_app # reference to the HawkAPI app |
| 81 | +context.hawkapi_request_id # a uuid4() string, surfaced in logs |
| 82 | +``` |
| 83 | + |
| 84 | +Users who want typed context helpers can build them on top — we stay out of DI territory in v1. `Depends()` resolution is explicitly deferred (see Out of scope). |
| 85 | + |
| 86 | +### Observability interceptor |
| 87 | + |
| 88 | +`HawkAPIObservabilityInterceptor(app)` instruments every unary + streaming call: |
| 89 | + |
| 90 | +- Logs structured entry `{event: "grpc.request", method, peer, request_id}` and exit `{event: "grpc.response", method, code, duration_ms}` via the existing `StructuredLoggingMiddleware`-compatible logger (uses `hawkapi.observability.get_logger()`). |
| 91 | +- Exports Prometheus counters/histograms on the **same** `CollectorRegistry` as `PrometheusMiddleware`: |
| 92 | + - `hawkapi_grpc_requests_total{method, code}` |
| 93 | + - `hawkapi_grpc_request_duration_seconds{method}` (histogram) |
| 94 | +- Reuses `ObservabilityConfig` for sampling / exporter choice. |
| 95 | + |
| 96 | +Emission is best-effort; exceptions inside the interceptor are caught and logged, never break the call. |
| 97 | + |
| 98 | +### Reflection |
| 99 | + |
| 100 | +When `reflection=True`, we call `grpc_reflection.v1alpha.reflection.enable_server_reflection(service_names, server)`. `grpc-reflection` is an optional extra — if the module isn't importable, raise `ConfigurationError` with a clear install hint (`pip install hawkapi[grpc]` — which pulls `grpcio` + `grpcio-reflection`). |
| 101 | + |
| 102 | +### TLS |
| 103 | + |
| 104 | +`ssl_credentials` is a pure passthrough to `server.add_secure_port(address, ssl_credentials)`. HawkAPI doesn't load certs itself — the user constructs `grpc.ssl_server_credentials(...)` and hands it in. Documented with a short example in the guide. |
| 105 | + |
| 106 | +### HawkAPI ctor integration |
| 107 | + |
| 108 | +No constructor changes. `mount_grpc` is a method; internal state lives on `app._grpc_mounts: list[GrpcMount]`. Startup/shutdown hooks are installed the first time `mount_grpc` is called. |
| 109 | + |
| 110 | +## Module layout |
| 111 | + |
| 112 | +``` |
| 113 | +src/hawkapi/grpc/ |
| 114 | + __init__.py # re-exports: mount_grpc types, GrpcMount, HawkAPIObservabilityInterceptor |
| 115 | + _mount.py # GrpcMount class (server lifecycle, start/stop) |
| 116 | + _interceptor.py # HawkAPIObservabilityInterceptor (context injection + metrics/logging) |
| 117 | + _reflection.py # reflection enablement with clear error if dep missing |
| 118 | +``` |
| 119 | + |
| 120 | +Each file < 200 lines. All `grpc`, `grpc.aio`, `grpc_reflection`, protobuf imports are lazy (inside function bodies / inside `_mount.py` guarded by a single `_require_grpc()` helper). |
| 121 | + |
| 122 | +## Dependencies |
| 123 | + |
| 124 | +- **Runtime:** none by default. `grpcio>=1.60` and `grpcio-reflection>=1.60` as optional extras under `pyproject.toml` `[project.optional-dependencies].grpc`. |
| 125 | +- **Dev/test:** add `grpcio`, `grpcio-reflection`, `protobuf` to the test dependency group so CI runs the real-server tests. |
| 126 | + |
| 127 | +## Tests — `tests/unit/test_grpc.py` |
| 128 | + |
| 129 | +Target ~18 tests. Use a tiny hand-written servicer + `grpc.aio.insecure_channel` client (no `protoc` needed — skip codegen by defining servicer against a hand-rolled `_pb2_grpc.py` fixture that registers a method by name through `grpc.method_handlers_generic_handler`). All tests guarded by `pytest.importorskip("grpc")`. |
| 130 | + |
| 131 | +- Unary call → returns expected payload. |
| 132 | +- `context.hawkapi_app` is the right app instance. |
| 133 | +- `context.hawkapi_request_id` is a valid uuid4 string. |
| 134 | +- Observability interceptor increments Prometheus counter. |
| 135 | +- Observability interceptor logs entry + exit. |
| 136 | +- User-provided interceptor runs (appended after built-in). |
| 137 | +- User interceptor runs in declared order (for 2 user interceptors). |
| 138 | +- Error in handler → gRPC status `INTERNAL`, counter labelled with the code. |
| 139 | +- `autostart=False` → server not listening until `await mount.start()`. |
| 140 | +- Manual `.start()` / `.stop()` happy path. |
| 141 | +- Lifespan startup binds port; shutdown releases it (assert via second bind attempt). |
| 142 | +- `ssl_credentials=...` → `add_secure_port` path exercised (use a self-signed pair from a fixture). |
| 143 | +- `reflection=True` with module present → `ServerReflection/ServerReflectionInfo` responds. |
| 144 | +- `reflection=True` without module → `ConfigurationError` with install hint. |
| 145 | +- Multiple `mount_grpc` calls with different ports → both servers start. |
| 146 | +- Multiple `mount_grpc` calls with the same port → servicers merged on one server. |
| 147 | +- Streaming call (server-streaming) works end-to-end. |
| 148 | +- Integration: HTTP request handled by HawkAPI and gRPC call served concurrently in the same process. |
| 149 | + |
| 150 | +Tests that need `grpcio-reflection` use a second `pytest.importorskip`. |
| 151 | + |
| 152 | +## Docs — `docs/guide/grpc.md` |
| 153 | + |
| 154 | +Covers: |
| 155 | +- Quickstart with `protoc`-generated stubs. |
| 156 | +- `add_to_server` wiring + why we pass it explicitly (no introspection magic). |
| 157 | +- ASGI lifespan integration and running under uvicorn/granian. |
| 158 | +- Accessing `app` from servicer methods. |
| 159 | +- TLS example (`grpc.ssl_server_credentials`). |
| 160 | +- Reflection toggle (+ grpcurl/Postman hint). |
| 161 | +- Observability — what metrics/logs ship and where they land. |
| 162 | +- Roadmap: Connect/gRPC-Web adapter, `Depends()` resolution, streaming interceptors. |
| 163 | + |
| 164 | +## Mkdocs nav + CHANGELOG |
| 165 | + |
| 166 | +- `mkdocs.yml`: `- gRPC: guide/grpc.md` after GraphQL, before Bulkhead. |
| 167 | +- `CHANGELOG.md`: one `[Unreleased] ### Added` bullet. |
| 168 | +- `pyproject.toml`: add `[project.optional-dependencies] grpc = ["grpcio>=1.60", "grpcio-reflection>=1.60"]`. |
| 169 | + |
| 170 | +## Out of scope |
| 171 | + |
| 172 | +- **`Depends()` resolution in servicer methods** — v2. Users use `context.hawkapi_app` for now. |
| 173 | +- **Connect / gRPC-Web over ASGI** — v2. Separate mount, shares interceptors. |
| 174 | +- **gRPC-Web gateway** — v2. Translates gRPC-Web → native gRPC. |
| 175 | +- **Auto-generating servicer from annotations** — v3+, needs heavy design. |
| 176 | +- **Built-in auth interceptors (JWT, OAuth2)** — users compose with existing `hawkapi.security` helpers + custom interceptor in v1. |
| 177 | +- **Client-side gRPC helpers** — out of scope; users use `grpc.aio.insecure_channel` directly. |
| 178 | +- **Load balancing / service mesh primitives** — deployment concern, not framework. |
| 179 | + |
| 180 | +## Success criteria |
| 181 | + |
| 182 | +1. `app.mount_grpc(servicer, add_to_server=..., port=50051)` starts server on ASGI startup and stops on shutdown. |
| 183 | +2. `context.hawkapi_app` is the HawkAPI instance inside every handler. |
| 184 | +3. Observability interceptor emits Prometheus metrics on the shared registry. |
| 185 | +4. `reflection=True` enables `grpcurl list` / `grpcurl describe`. |
| 186 | +5. `ssl_credentials=` passes through to `add_secure_port`. |
| 187 | +6. `autostart=False` → manual `.start()` / `.stop()` work. |
| 188 | +7. Zero runtime deps on the default path; clear error when extras missing. |
| 189 | +8. Full suite + ruff + mkdocs strict clean. |
| 190 | + |
| 191 | +## Files touched |
| 192 | + |
| 193 | +- `src/hawkapi/grpc/__init__.py` — new |
| 194 | +- `src/hawkapi/grpc/_mount.py` — new |
| 195 | +- `src/hawkapi/grpc/_interceptor.py` — new |
| 196 | +- `src/hawkapi/grpc/_reflection.py` — new |
| 197 | +- `src/hawkapi/app.py` — `mount_grpc` method + lifespan hooks |
| 198 | +- `src/hawkapi/__init__.py` — lazy re-exports (`GrpcMount`, `HawkAPIObservabilityInterceptor`) |
| 199 | +- `tests/unit/test_grpc.py` — new |
| 200 | +- `docs/guide/grpc.md` — new |
| 201 | +- `mkdocs.yml` — nav entry |
| 202 | +- `CHANGELOG.md` — bullet |
| 203 | +- `pyproject.toml` — optional-dependencies entry |
| 204 | + |
| 205 | +## Rollback |
| 206 | + |
| 207 | +New module + new method + additive deps (optional extra). No existing paths change. Revert = delete `grpc/` package, revert one method on HawkAPI, revert four doc/meta diffs. |
0 commit comments