nativegate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. nativegate/__init__.py +1 -0
  2. nativegate/__main__.py +4 -0
  3. nativegate/buildinfo.py +344 -0
  4. nativegate/cli.py +2007 -0
  5. nativegate/config.py +991 -0
  6. nativegate/declared_invariants.py +565 -0
  7. nativegate/discovery.py +167 -0
  8. nativegate/driverbuild.py +626 -0
  9. nativegate/drivers/__init__.py +5 -0
  10. nativegate/drivers/cpp.py +616 -0
  11. nativegate/drivers/fortran.py +507 -0
  12. nativegate/generators/__init__.py +0 -0
  13. nativegate/generators/cmake_gen.py +101 -0
  14. nativegate/generators/docker_gen.py +614 -0
  15. nativegate/generators/error_gen.py +104 -0
  16. nativegate/generators/f2py_gen.py +91 -0
  17. nativegate/generators/gateway_gen.py +110 -0
  18. nativegate/generators/golden_gen.py +50 -0
  19. nativegate/generators/k8s_gen.py +212 -0
  20. nativegate/generators/mcp_gen.py +281 -0
  21. nativegate/generators/middleware_gen.py +717 -0
  22. nativegate/generators/pybind_gen.py +406 -0
  23. nativegate/generators/pyproject_gen.py +61 -0
  24. nativegate/generators/python_pkg_gen.py +1164 -0
  25. nativegate/generators/test_gen.py +160 -0
  26. nativegate/golden.py +747 -0
  27. nativegate/invariants.py +532 -0
  28. nativegate/ir.py +789 -0
  29. nativegate/lattice.py +350 -0
  30. nativegate/locking.py +216 -0
  31. nativegate/oracle.py +904 -0
  32. nativegate/parsers/__init__.py +0 -0
  33. nativegate/parsers/cpp.py +105 -0
  34. nativegate/parsers/cpp_ast.py +1652 -0
  35. nativegate/parsers/cpp_regex.py +812 -0
  36. nativegate/parsers/fixed_form.py +868 -0
  37. nativegate/parsers/fortran.py +157 -0
  38. nativegate/parsers/fortran_fparser.py +1116 -0
  39. nativegate/parsers/fortran_regex.py +686 -0
  40. nativegate/preprocess.py +335 -0
  41. nativegate/structural_invariants.py +762 -0
  42. nativegate/suggest.py +208 -0
  43. nativegate/templates/__init__.py +20 -0
  44. nativegate/templates/golden_test_template.py +248 -0
  45. nativegate/wire.py +438 -0
  46. nativegate-0.1.0.dist-info/METADATA +547 -0
  47. nativegate-0.1.0.dist-info/RECORD +50 -0
  48. nativegate-0.1.0.dist-info/WHEEL +5 -0
  49. nativegate-0.1.0.dist-info/entry_points.txt +3 -0
  50. nativegate-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,717 @@
1
+ """The generated service's middleware: request identity, access logs, auth, limits.
2
+
3
+ Emitted as one `middleware.py` per service with a single entry point,
4
+ `install_middleware(app, service_name)`, called by both the standalone
5
+ `service.py` and the composed gateway app. Same reason `error_gen` is shared:
6
+ middleware attaches to an *app*, and a service router mounted into a gateway
7
+ runs under the gateway's app, so installing it in one place only would miss
8
+ the deployment more likely to face the internet.
9
+
10
+ TWO RULES THIS FILE IS BUILT AROUND
11
+
12
+ **Secrets come from the environment, never from config or the image.** API
13
+ keys are read from `NATIVEGATE_API_KEYS` at startup. Nothing is baked into
14
+ `nativegate.yaml` (which is committed) or into a layer (which is pullable by
15
+ anyone who can reach the registry).
16
+
17
+ **Auth fails closed.** If `api.auth: api_key` is configured and no keys are
18
+ present in the environment, the service **refuses to start** rather than
19
+ serving unauthenticated. A misconfiguration that silently disables
20
+ authentication is worse than one that pages someone at deploy time — the
21
+ first is discovered by an attacker, the second by the person deploying.
22
+
23
+ WHAT IS DELIBERATELY HONEST ABOUT THE RATE LIMITER
24
+
25
+ It is per-process. Generated images run several gunicorn workers, so a limit
26
+ of N is really N×`WEB_CONCURRENCY` across the service, and it resets when a
27
+ worker is recycled. That is useful as a crude backstop against one client
28
+ melting one worker; it is **not** a quota system, and real global limiting
29
+ belongs at the ingress, where there is one place to count. Saying so is the
30
+ point — an in-process counter presented as a global limit is the kind of
31
+ control that is trusted until the day it matters.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from .mcp_gen import MCP_MOUNT_PATH
37
+
38
+ MIDDLEWARE_FILENAME = "middleware.py"
39
+
40
+ # Names of the env vars the generated middleware reads. Kept here so the CLI
41
+ # and the docs can name them without re-deriving the strings.
42
+ ENV_API_KEYS = "NATIVEGATE_API_KEYS"
43
+ ENV_RATE_LIMIT = "NATIVEGATE_RATE_LIMIT_PER_MINUTE"
44
+ ENV_MAX_BODY = "NATIVEGATE_MAX_REQUEST_BYTES"
45
+
46
+ # Provenance stamped onto every access-log line. Set by whatever runs the
47
+ # container (the console's deployer), not by the image, so they are read per
48
+ # call rather than frozen at import.
49
+ ENV_PROJECT = "NATIVEGATE_PROJECT"
50
+ ENV_BUILD_ID = "NATIVEGATE_BUILD_ID"
51
+ ENV_IMAGE_DIGEST = "NATIVEGATE_IMAGE_DIGEST"
52
+
53
+ AUTH_MODES = ("none", "api_key")
54
+
55
+
56
+ def generate_middleware_py(service_name: str, auth: str = "none") -> str:
57
+ """The service's `middleware.py`.
58
+
59
+ `auth` is the *configured* mode, baked in at generate time on purpose: a
60
+ service generated to require authentication should not be able to have it
61
+ turned off by an environment variable at runtime.
62
+ """
63
+ if auth not in AUTH_MODES:
64
+ raise ValueError(
65
+ f"Unknown auth mode {auth!r} for service {service_name!r}; "
66
+ f"expected one of {', '.join(AUTH_MODES)}."
67
+ )
68
+
69
+ return f'''# Generated by nativegate. Do not edit by hand — re-run `ngate generate`.
70
+ """Request identity, access logging, authentication and limits for {service_name}.
71
+
72
+ Installed by both `service.py` and the gateway app via `install_middleware`.
73
+ See the nativegate docs on production readiness for what each layer does and,
74
+ just as importantly, what it does not.
75
+ """
76
+ from __future__ import annotations
77
+
78
+ import hmac
79
+ import json
80
+ import logging
81
+ import os
82
+ import signal
83
+ import sys
84
+ import time
85
+ import uuid
86
+ from collections import deque
87
+ from datetime import datetime, timezone
88
+ from threading import Lock
89
+
90
+ from fastapi import HTTPException, Request
91
+ from fastapi.responses import JSONResponse
92
+
93
+ __all__ = [
94
+ "install_middleware",
95
+ "readiness",
96
+ "start_draining",
97
+ "AuthNotConfigured",
98
+ "current_request_id",
99
+ "log_call",
100
+ ]
101
+
102
+ _LOG = logging.getLogger("nativegate.{service_name}")
103
+ _ACCESS_LOG = logging.getLogger("nativegate.{service_name}.access")
104
+
105
+ # Characters of a call's input/output payload kept per access-log line.
106
+ # Bounded because these lines go to stdout, get parsed by the console's
107
+ # tailer and stored in SQLite — an uploaded array or a large struct dump
108
+ # would otherwise make one call balloon the log stream and the evidence
109
+ # pack it feeds.
110
+ _PAYLOAD_LIMIT = 2000
111
+
112
+
113
+ def _truncate_payload(text: str | None) -> str | None:
114
+ if not text:
115
+ return None
116
+ if len(text) > _PAYLOAD_LIMIT:
117
+ return text[:_PAYLOAD_LIMIT] + f"... ({{len(text)}} chars total)"
118
+ return text
119
+
120
+
121
+ # Bytes of an HTTP body carried forward from the capture into the log line.
122
+ # Larger than _PAYLOAD_LIMIT so a body that will be truncated anyway is still
123
+ # recognisably the right body.
124
+ #
125
+ # Honest about what this does NOT bound: a captured body is whole in memory
126
+ # before it gets here, because reading a request body and draining a response
127
+ # iterator are both all-or-nothing. What keeps that from being unbounded is
128
+ # the content-type gate (binary payloads — the large ones for a native
129
+ # service — are never read at all) and, for requests, the separate
130
+ # `_limit_body` layer. This limit only stops a large *JSON* body from being
131
+ # copied on into a log line no reader can use past _PAYLOAD_LIMIT anyway.
132
+ _CAPTURE_LIMIT = 8192
133
+
134
+
135
+ def _is_capturable(content_type: str, content_encoding: str = "") -> bool:
136
+ """Whether a body with these headers is worth recording.
137
+
138
+ JSON only. Everything else a generated service returns is binary (packed
139
+ arrays, files): its bytes are noise in a log line, and skipping it is
140
+ what keeps the large payloads out of memory. Notably this also excludes
141
+ `text/event-stream`, which must not be buffered at all — see the
142
+ streaming note in `_access_log`.
143
+
144
+ The media type is parsed rather than substring-matched. `"json" in
145
+ content_type` would admit `multipart/form-data;
146
+ boundary=----WebKitFormBoundaryjson1a2b` — a boundary is client-chosen,
147
+ so a binary upload could put itself in the log as mojibake purely by
148
+ naming itself that, which is precisely what this gate exists to stop.
149
+ `+json` is honoured so structured error bodies
150
+ (`application/problem+json`) are captured like any other JSON.
151
+
152
+ `content_encoding` rejects a compressed body (a GZipMiddleware installed
153
+ inside this one, a handler setting it by hand). Its bytes are still JSON
154
+ by content type, but decoding them as text yields mojibake — a corrupted
155
+ audit record is worse than an absent one, because nothing about it looks
156
+ wrong.
157
+ """
158
+ encoding = content_encoding.strip().lower()
159
+ if encoding and encoding != "identity":
160
+ return False
161
+ media_type = content_type.split(";", 1)[0].strip().lower()
162
+ return media_type == "application/json" or media_type.endswith("+json")
163
+
164
+
165
+ def _capture_body(raw: bytes) -> str | None:
166
+ """The loggable string form of a body already known to be capturable.
167
+
168
+ `errors="replace"` rather than a try/except: _CAPTURE_LIMIT cuts at a
169
+ byte offset, which lands mid-character often enough on non-ASCII input
170
+ that treating it as a decode failure would silently drop whole payloads
171
+ for the tail of one character.
172
+ """
173
+ if not raw:
174
+ return None
175
+ return raw[:_CAPTURE_LIMIT].decode("utf-8", errors="replace")
176
+
177
+
178
+ def _provenance() -> dict:
179
+ """Which project, which build, which image produced this line.
180
+
181
+ `build_id` is emitted as an int because a consumer joining log rows against
182
+ build records wants a number, not the string form it arrived as; an
183
+ unparseable value is nulled rather than passed through half-typed.
184
+ """
185
+ raw_build_id = os.environ.get("{ENV_BUILD_ID}") or ""
186
+ try:
187
+ build_id = int(raw_build_id)
188
+ except ValueError:
189
+ build_id = None
190
+ return {{
191
+ "project": os.environ.get("{ENV_PROJECT}") or None,
192
+ "build_id": build_id,
193
+ "image_digest": os.environ.get("{ENV_IMAGE_DIGEST}") or None,
194
+ }}
195
+
196
+
197
+ class _JsonLineFormatter(logging.Formatter):
198
+ """One JSON object per line on stdout — the schema the console tails.
199
+
200
+ Not the stdlib `extra=` fields verbatim: the console needs a fixed set of
201
+ keys present on every line (nulled, not omitted, when a field does not
202
+ apply to that call), so it can parse REST and MCP rows with one schema
203
+ rather than two.
204
+
205
+ The provenance fields (`project`, `build_id`, `image_digest`) are resolved
206
+ here rather than at the call sites so REST and MCP rows cannot drift, and
207
+ from the environment on every line rather than at import: a container's env
208
+ is set at `docker run`, after this module is already loaded.
209
+ """
210
+
211
+ def format(self, record: logging.LogRecord) -> str:
212
+ return json.dumps(
213
+ {{
214
+ "ts": datetime.now(timezone.utc).isoformat(),
215
+ "service": getattr(record, "service", "{service_name}"),
216
+ "kind": getattr(record, "kind", "rest"),
217
+ "method": getattr(record, "method", None),
218
+ "path": getattr(record, "path", None),
219
+ "tool": getattr(record, "tool", None),
220
+ "status": getattr(record, "status", None),
221
+ "duration_ms": getattr(record, "duration_ms", None),
222
+ "request_id": getattr(record, "request_id", "-"),
223
+ "input": getattr(record, "input", None),
224
+ "output": getattr(record, "output", None),
225
+ **_provenance(),
226
+ }}
227
+ )
228
+
229
+
230
+ class _StdoutHandler(logging.StreamHandler):
231
+ """Resolves `sys.stdout` on every emit rather than once at construction.
232
+
233
+ A handler built with `logging.StreamHandler(sys.stdout)` freezes whatever
234
+ stream object `sys.stdout` was at import time — wrong the moment anything
235
+ (a test's capsys, a reloader) swaps `sys.stdout` out afterwards.
236
+ """
237
+
238
+ def __init__(self) -> None:
239
+ super().__init__(sys.stdout)
240
+
241
+ @property
242
+ def stream(self):
243
+ return sys.stdout
244
+
245
+ @stream.setter
246
+ def stream(self, value) -> None:
247
+ pass
248
+
249
+
250
+ def _install_json_access_log() -> None:
251
+ # Idempotent: install_middleware and the MCP server's module-level import
252
+ # can both run in the same process, and a second handler would duplicate
253
+ # every line.
254
+ if any(isinstance(h, _StdoutHandler) for h in _ACCESS_LOG.handlers):
255
+ return
256
+ handler = _StdoutHandler()
257
+ handler.setFormatter(_JsonLineFormatter())
258
+ _ACCESS_LOG.addHandler(handler)
259
+ _ACCESS_LOG.setLevel(logging.INFO)
260
+ _ACCESS_LOG.propagate = False
261
+
262
+
263
+ def log_call(
264
+ *,
265
+ kind: str,
266
+ request_id: str,
267
+ method: str | None = None,
268
+ path: str | None = None,
269
+ tool: str | None = None,
270
+ status: int | None = None,
271
+ duration_ms: float,
272
+ service_name: str = "{service_name}",
273
+ input_data: str | None = None,
274
+ output_data: str | None = None,
275
+ ) -> None:
276
+ """Emit one access-log line. Shared by the REST middleware and the MCP
277
+ server so both `kind`s go through the same formatter and schema.
278
+
279
+ `input_data`/`output_data` are already-serialised JSON strings (the
280
+ caller owns encoding, since a request body and an MCP tool result are
281
+ shaped nothing alike) and are truncated here so one oversized payload
282
+ cannot inflate this line past what the console's tailer and SQLite
283
+ column are meant to hold.
284
+ """
285
+ _install_json_access_log()
286
+ _ACCESS_LOG.info(
287
+ "%s %s",
288
+ kind,
289
+ tool if tool is not None else path,
290
+ extra={{
291
+ "service": service_name,
292
+ "kind": kind,
293
+ "method": method,
294
+ "path": path,
295
+ "tool": tool,
296
+ "status": status,
297
+ "duration_ms": round(duration_ms, 3),
298
+ "request_id": request_id,
299
+ "input": _truncate_payload(input_data),
300
+ "output": _truncate_payload(output_data),
301
+ }},
302
+ )
303
+
304
+ MCP_MOUNT_PATH = "{MCP_MOUNT_PATH}"
305
+
306
+
307
+ def _is_mcp_transport_path(path: str) -> bool:
308
+ """Whether `path` addresses the mounted MCP app.
309
+
310
+ A boundary match, not `startswith("{MCP_MOUNT_PATH}")`: a sibling route such as
311
+ `/mcpconfig` or `/mcp-status` shares the prefix but is ordinary REST
312
+ traffic, and labelling it `mcp_http` would put a row in the audit trail
313
+ describing a layer the request never went through.
314
+ """
315
+ return path == MCP_MOUNT_PATH or path.startswith(MCP_MOUNT_PATH + "/")
316
+
317
+
318
+ # Baked in at generate time from `api.auth` in nativegate.yaml. Deliberately not
319
+ # an environment variable: a service generated to require authentication must
320
+ # not be silently downgraded by the environment it happens to start in.
321
+ AUTH_MODE = "{auth}"
322
+
323
+ # Paths that never require authentication. An orchestrator probing readiness
324
+ # has no credentials, and a liveness probe that 401s is a service that looks
325
+ # dead and gets restarted forever.
326
+ _UNAUTHENTICATED_PATHS = frozenset({{"/healthz", "/readyz"}})
327
+
328
+
329
+ class AuthNotConfigured(RuntimeError):
330
+ """Raised at startup when auth is required but no keys are present."""
331
+
332
+
333
+ def _load_api_keys() -> list[str]:
334
+ raw = os.environ.get("{ENV_API_KEYS}", "")
335
+ return [k.strip() for k in raw.split(",") if k.strip()]
336
+
337
+
338
+ def _int_env(name: str, default: int) -> int:
339
+ try:
340
+ return int(os.environ.get(name, "") or default)
341
+ except ValueError:
342
+ _LOG.warning("%s is not an integer; falling back to %s", name, default)
343
+ return default
344
+
345
+
346
+ class _RateLimiter:
347
+ """Per-process sliding window. See the module docstring for the caveat.
348
+
349
+ A deque of timestamps per client rather than a token bucket: the window is
350
+ exact, and for the request rates a native-compute service sees the memory
351
+ is irrelevant. Old entries are evicted on access, so an idle client's
352
+ deque costs nothing after its window passes.
353
+ """
354
+
355
+ def __init__(self, per_minute: int) -> None:
356
+ self.per_minute = per_minute
357
+ self._hits: dict[str, deque[float]] = {{}}
358
+ self._lock = Lock()
359
+
360
+ def allow(self, client: str, now: float) -> bool:
361
+ if self.per_minute <= 0:
362
+ return True
363
+ cutoff = now - 60.0
364
+ with self._lock:
365
+ hits = self._hits.setdefault(client, deque())
366
+ while hits and hits[0] < cutoff:
367
+ hits.popleft()
368
+ if not hits:
369
+ # Keeps the dict from growing without bound as clients churn.
370
+ self._hits.pop(client, None)
371
+ hits = self._hits.setdefault(client, deque())
372
+ if len(hits) >= self.per_minute:
373
+ return False
374
+ hits.append(now)
375
+ return True
376
+
377
+
378
+ def current_request_id(request: Request) -> str:
379
+ """The id for this request, for handlers that want to log against it."""
380
+ return getattr(request.state, "request_id", "-")
381
+
382
+
383
+ def install_middleware(app, service_name: str = "{service_name}") -> None:
384
+ """Attach every middleware layer to `app`, outermost first.
385
+
386
+ Order matters and is not arbitrary:
387
+
388
+ 1. request id — so everything below it, including rejections, can be
389
+ correlated. A 401 with no id is an unanswerable support ticket.
390
+ 2. access log — directly inside the request id, so it sees an id on every
391
+ line, and OUTSIDE the layers that reject. An access log nested inside
392
+ auth records only the calls that got through: a 401, a 413 and a 429
393
+ would each leave no row at all, which is exactly the traffic an audit
394
+ of a governed service most needs to see. The cost is that the recorded
395
+ duration includes the layers below rather than the handler alone —
396
+ cheap, and honest, since that is what the caller waited for.
397
+ 3. body size — cheapest rejection, and it runs before anything reads the
398
+ body into memory.
399
+ 4. auth — before the rate limiter, so an unauthenticated flood is rejected
400
+ without consuming the caller's quota, and so the limiter can key on the
401
+ authenticated identity rather than a spoofable address.
402
+ 5. rate limit — keyed by API key when there is one, client address if not.
403
+
404
+ Starlette runs `add_middleware` in reverse registration order, so these
405
+ are registered bottom-up.
406
+ """
407
+ api_keys = _load_api_keys()
408
+ if AUTH_MODE == "api_key" and not api_keys:
409
+ raise AuthNotConfigured(
410
+ f"{{service_name}} is configured with `api.auth: api_key` but "
411
+ "{ENV_API_KEYS} is empty. Refusing to start rather than serve "
412
+ "unauthenticated. Set it to a comma-separated list of keys, or "
413
+ "regenerate the service with `api.auth: none` if it is genuinely "
414
+ "meant to be open."
415
+ )
416
+ if AUTH_MODE == "none":
417
+ # Not a warning on every request — once, at startup, where an operator
418
+ # reading boot logs will actually see it.
419
+ _LOG.warning(
420
+ "%s is serving WITHOUT authentication (api.auth: none). Anything "
421
+ "that can reach the port can call the native code.", service_name
422
+ )
423
+
424
+ limiter = _RateLimiter(_int_env("{ENV_RATE_LIMIT}", 0))
425
+ max_body = _int_env("{ENV_MAX_BODY}", 1_048_576)
426
+
427
+ @app.middleware("http")
428
+ async def _rate_limit(request: Request, call_next):
429
+ if limiter.per_minute > 0 and request.url.path not in _UNAUTHENTICATED_PATHS:
430
+ client = getattr(request.state, "auth_key", None) or (
431
+ request.client.host if request.client else "unknown"
432
+ )
433
+ if not limiter.allow(client, time.monotonic()):
434
+ _LOG.warning(
435
+ "rate limit exceeded for %s on %s (request %s)",
436
+ client, request.url.path, current_request_id(request),
437
+ )
438
+ return JSONResponse(
439
+ status_code=429,
440
+ content={{
441
+ "error": "rate limit exceeded",
442
+ "limit_per_minute": limiter.per_minute,
443
+ "request_id": current_request_id(request),
444
+ }},
445
+ headers={{"Retry-After": "60"}},
446
+ )
447
+ return await call_next(request)
448
+
449
+ @app.middleware("http")
450
+ async def _authenticate(request: Request, call_next):
451
+ if AUTH_MODE == "api_key" and request.url.path not in _UNAUTHENTICATED_PATHS:
452
+ presented = request.headers.get("x-api-key") or ""
453
+ bearer = request.headers.get("authorization", "")
454
+ if not presented and bearer.lower().startswith("bearer "):
455
+ presented = bearer[7:].strip()
456
+ # compare_digest against every key: a short-circuit `in` leaks key
457
+ # length and position through timing. The loop is deliberately not
458
+ # broken early for the same reason.
459
+ matched = False
460
+ for key in api_keys:
461
+ if hmac.compare_digest(presented, key):
462
+ matched = True
463
+ if not matched:
464
+ _LOG.warning(
465
+ "rejected unauthenticated %s %s (request %s)",
466
+ request.method, request.url.path, current_request_id(request),
467
+ )
468
+ return JSONResponse(
469
+ status_code=401,
470
+ content={{
471
+ "error": "unauthorized",
472
+ "request_id": current_request_id(request),
473
+ }},
474
+ headers={{"WWW-Authenticate": "Bearer"}},
475
+ )
476
+ request.state.auth_key = presented
477
+ return await call_next(request)
478
+
479
+ @app.middleware("http")
480
+ async def _limit_body(request: Request, call_next):
481
+ declared = request.headers.get("content-length")
482
+ if declared is not None:
483
+ try:
484
+ if int(declared) > max_body:
485
+ return JSONResponse(
486
+ status_code=413,
487
+ content={{
488
+ "error": "request body too large",
489
+ "max_request_bytes": max_body,
490
+ "request_id": current_request_id(request),
491
+ }},
492
+ )
493
+ except ValueError:
494
+ return JSONResponse(
495
+ status_code=400,
496
+ content={{"error": "malformed Content-Length"}},
497
+ )
498
+ return await call_next(request)
499
+
500
+ @app.middleware("http")
501
+ async def _access_log(request: Request, call_next):
502
+ started = time.perf_counter()
503
+ is_mcp = _is_mcp_transport_path(request.url.path)
504
+
505
+ # Payloads are recorded for REST only. The MCP mount speaks a
506
+ # streaming transport, and the tool call underneath it already logs
507
+ # its own decoded arguments and result (mcp_server.py) — capturing
508
+ # here as well would both risk buffering a live stream and store the
509
+ # same payload twice under two `kind`s.
510
+ input_data = None
511
+ if not is_mcp and _is_capturable(
512
+ request.headers.get("content-type", ""),
513
+ request.headers.get("content-encoding", ""),
514
+ ):
515
+ # Reading the body here does NOT starve the endpoint, and this
516
+ # layer must not try to help it along. BaseHTTPMiddleware hands
517
+ # dispatch a `_CachedRequest`, whose `wrapped_receive` replays a
518
+ # body that `.body()` already cached — downstream sees the whole
519
+ # request without anything from us.
520
+ #
521
+ # Re-injecting a replay onto `request._receive` (the obvious
522
+ # move, and wrong) additionally poisons the disconnect path: once
523
+ # the body is consumed, `wrapped_receive` awaits that same
524
+ # channel expecting only `http.disconnect`, and a replay that
525
+ # keeps answering `http.request` makes it raise "Unexpected
526
+ # message received". That crashed every body-carrying request
527
+ # whose handler returned a streaming response.
528
+ try:
529
+ raw = await request.body()
530
+ except Exception: # noqa: BLE001 - logging must never break the call
531
+ raw = b""
532
+ input_data = _capture_body(raw)
533
+
534
+ response = await call_next(request)
535
+
536
+ # Draining `body_iterator` is why this is gated on content type
537
+ # rather than done unconditionally: for a genuinely streaming
538
+ # response the loop below would not end until the stream did, turning
539
+ # an incremental response into a buffered one. JSON responses are
540
+ # already fully materialised by the time they reach here, so
541
+ # collecting and replaying their chunks changes nothing the client
542
+ # can observe.
543
+ output_data = None
544
+ body_iterator = getattr(response, "body_iterator", None)
545
+ if (
546
+ not is_mcp
547
+ and body_iterator is not None
548
+ # A Content-Length is the difference between a response that is
549
+ # already whole and one still being produced. Starlette sets it
550
+ # on a materialised body (JSONResponse and friends) and cannot
551
+ # set it on a StreamingResponse, whose length is unknown until
552
+ # the generator ends. Without this, a handler streaming
553
+ # newline-delimited JSON would be drained to completion before
554
+ # the client saw its first byte — and never released at all if
555
+ # the generator does not terminate.
556
+ #
557
+ # The header is taken at its word. A handler that returns a
558
+ # StreamingResponse and sets Content-Length on it by hand would
559
+ # still be drained — but that response is already malformed on
560
+ # its own terms (it declares a length Starlette will not
561
+ # enforce), and guessing around a handler that lies about its
562
+ # own body is not something this layer can do better than the
563
+ # framework.
564
+ and response.headers.get("content-length") is not None
565
+ and _is_capturable(
566
+ response.headers.get("content-type", ""),
567
+ response.headers.get("content-encoding", ""),
568
+ )
569
+ ):
570
+ # Chunks accumulate outside the try and the replacement iterator
571
+ # is installed unconditionally, so no path can leave the original
572
+ # (now partly drained) iterator on the response.
573
+ #
574
+ # Belt and braces, not a live bug: an exception here can only
575
+ # come from the downstream app, which BaseHTTPMiddleware runs in
576
+ # a task group that re-raises it on unwind regardless of what
577
+ # this clause does — measured, and the client sees the same error
578
+ # either way. It is written this way so the guarantee survives a
579
+ # future where that is no longer true, and so re-raising after
580
+ # the collected chunks keeps the failure the response's own
581
+ # rather than this layer's.
582
+ chunks: list = []
583
+ failure: BaseException | None = None
584
+ try:
585
+ async for chunk in body_iterator:
586
+ chunks.append(chunk)
587
+ except Exception as exc: # noqa: BLE001 - re-raised below, not swallowed
588
+ failure = exc
589
+
590
+ async def _replay_response(_chunks: list = chunks, _failure=failure):
591
+ for chunk in _chunks:
592
+ yield chunk
593
+ if _failure is not None:
594
+ raise _failure
595
+
596
+ response.body_iterator = _replay_response()
597
+ if failure is None:
598
+ # Not every chunk is a body chunk. `body_stream()` yields a
599
+ # raw ASGI message dict for `http.response.pathsend` (a
600
+ # FileResponse on a server advertising that extension) —
601
+ # Starlette's own `stream_response` has an `isinstance(chunk,
602
+ # dict)` branch for it, and `bytes(a_dict)` would raise
603
+ # TypeError here, failing a download that had already
604
+ # succeeded. Non-bytes chunks are passed through to the
605
+ # client by the replay above and simply left out of the log.
606
+ #
607
+ # str is handled for the same defensive reason, though it is
608
+ # currently unreachable: the downstream response encodes at
609
+ # the ASGI send boundary before this layer sees it (measured).
610
+ output_data = _capture_body(
611
+ b"".join(
612
+ c.encode("utf-8") if isinstance(c, str) else c
613
+ for c in chunks
614
+ if isinstance(c, (bytes, bytearray, memoryview, str))
615
+ )
616
+ )
617
+
618
+ duration_ms = (time.perf_counter() - started) * 1000.0
619
+ # MCP transport requests are logged under their own kind rather than
620
+ # skipped. mcp_server.py's FastMCP hook only fires for actual tool
621
+ # dispatch, so skipping the whole mount left `initialize`, `tools/list`
622
+ # and malformed JSON-RPC bodies with no record anywhere — an audit hole
623
+ # in a governed service. A tools/call now yields two rows, one per
624
+ # layer, which is honest because they are two distinct events; the
625
+ # `kind` keeps them unambiguously separable, and they are deliberately
626
+ # NOT correlated by a contextvar (the tool call re-enters through an
627
+ # in-process ASGI transport and may run in a different task).
628
+ log_call(
629
+ kind="mcp_http" if _is_mcp_transport_path(request.url.path) else "rest",
630
+ request_id=current_request_id(request),
631
+ method=request.method,
632
+ path=request.url.path,
633
+ status=response.status_code,
634
+ duration_ms=duration_ms,
635
+ service_name=service_name,
636
+ input_data=input_data,
637
+ output_data=output_data,
638
+ )
639
+ response.headers["X-Response-Time-Ms"] = f"{{duration_ms:.1f}}"
640
+ return response
641
+
642
+ @app.middleware("http")
643
+ async def _request_id(request: Request, call_next):
644
+ # Honour an inbound id so a trace survives the hop from a gateway or
645
+ # service mesh, but bound its length and character set: it is attacker
646
+ # -controlled and it ends up in log lines.
647
+ inbound = request.headers.get("x-request-id", "")
648
+ if inbound and len(inbound) <= 64 and inbound.isascii() and inbound.isprintable():
649
+ request_id = inbound
650
+ else:
651
+ request_id = uuid.uuid4().hex[:12]
652
+ request.state.request_id = request_id
653
+ response = await call_next(request)
654
+ response.headers["X-Request-ID"] = request_id
655
+ return response
656
+
657
+
658
+ _DRAINING = False
659
+
660
+
661
+ def start_draining() -> None:
662
+ """Stop reporting ready. Called on SIGTERM; safe to call directly."""
663
+ global _DRAINING
664
+ _DRAINING = True
665
+ _LOG.info("draining: /readyz will report 503 while in-flight work finishes")
666
+
667
+
668
+ def readiness(app, service_name: str = "{service_name}"):
669
+ """Register `/readyz`, distinct from `/healthz` liveness.
670
+
671
+ WHAT READINESS IS ACTUALLY FOR HERE
672
+
673
+ The obvious reading — "has the app finished starting?" — is worth nothing
674
+ in this service. `service.py` imports `router.py`, which imports the native
675
+ extension at module scope, so a worker that failed to load it never becomes
676
+ able to answer anything at all. Any request that gets a response at all is
677
+ proof the extension imported. A readiness probe asserting that would be a
678
+ slower liveness probe.
679
+
680
+ What readiness genuinely buys is **draining**. On a rolling deploy or a
681
+ scale-down, the orchestrator sends SIGTERM and gunicorn stops accepting new
682
+ work while finishing what is in flight. Without a readiness signal the load
683
+ balancer keeps routing to that worker for as long as its own health check
684
+ interval, and those requests are refused. Reporting 503 the moment SIGTERM
685
+ lands takes the process out of rotation *before* it stops accepting, which
686
+ is the difference between a clean deploy and a burst of 502s.
687
+
688
+ Liveness must NOT do this: a draining process is deliberately not ready but
689
+ perfectly alive, and a liveness probe that failed here would get it killed
690
+ mid-request instead of drained.
691
+ """
692
+
693
+ # Chained, not replaced. gunicorn and uvicorn install their own SIGTERM
694
+ # handlers to run graceful shutdown; overwriting one would turn a graceful
695
+ # drain into an abrupt exit, which is the opposite of the goal.
696
+ try:
697
+ previous = signal.getsignal(signal.SIGTERM)
698
+
699
+ def _drain_then_delegate(signum, frame):
700
+ start_draining()
701
+ if callable(previous):
702
+ previous(signum, frame)
703
+
704
+ signal.signal(signal.SIGTERM, _drain_then_delegate)
705
+ except (ValueError, OSError):
706
+ # Not the main thread (some test runners, some embeddings). Draining is
707
+ # an optimisation, not a correctness requirement — carry on without it.
708
+ _LOG.debug("could not install a SIGTERM drain handler; /readyz is static")
709
+
710
+ @app.get("/readyz", tags=["health"])
711
+ def readyz():
712
+ if _DRAINING:
713
+ raise HTTPException(status_code=503, detail="draining")
714
+ return {{"status": "ready", "service": service_name}}
715
+
716
+ return readyz
717
+ '''