Skip to content

Commit aee882f

Browse files
Berik AshimovBerik Ashimov
authored andcommitted
docs: tier 2 gRPC spec — thin mount_grpc over grpc.aio with observability interceptor
1 parent 3273ed8 commit aee882f

1 file changed

Lines changed: 207 additions & 0 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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

Comments
 (0)