svc-infra 0.1.600__py3-none-any.whl → 0.1.664__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (140) hide show
  1. svc_infra/api/fastapi/admin/__init__.py +3 -0
  2. svc_infra/api/fastapi/admin/add.py +231 -0
  3. svc_infra/api/fastapi/apf_payments/setup.py +0 -2
  4. svc_infra/api/fastapi/auth/add.py +0 -4
  5. svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
  6. svc_infra/api/fastapi/billing/router.py +64 -0
  7. svc_infra/api/fastapi/billing/setup.py +19 -0
  8. svc_infra/api/fastapi/cache/add.py +9 -5
  9. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  10. svc_infra/api/fastapi/db/sql/add.py +40 -18
  11. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  12. svc_infra/api/fastapi/db/sql/session.py +16 -0
  13. svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
  14. svc_infra/api/fastapi/docs/add.py +160 -0
  15. svc_infra/api/fastapi/docs/landing.py +1 -1
  16. svc_infra/api/fastapi/docs/scoped.py +41 -6
  17. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  18. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  19. svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
  20. svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
  21. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  22. svc_infra/api/fastapi/openapi/mutators.py +114 -0
  23. svc_infra/api/fastapi/ops/add.py +73 -0
  24. svc_infra/api/fastapi/pagination.py +3 -1
  25. svc_infra/api/fastapi/routers/ping.py +1 -0
  26. svc_infra/api/fastapi/setup.py +21 -13
  27. svc_infra/api/fastapi/tenancy/add.py +19 -0
  28. svc_infra/api/fastapi/tenancy/context.py +112 -0
  29. svc_infra/api/fastapi/versioned.py +101 -0
  30. svc_infra/app/README.md +5 -5
  31. svc_infra/billing/__init__.py +23 -0
  32. svc_infra/billing/async_service.py +147 -0
  33. svc_infra/billing/jobs.py +230 -0
  34. svc_infra/billing/models.py +131 -0
  35. svc_infra/billing/quotas.py +101 -0
  36. svc_infra/billing/schemas.py +33 -0
  37. svc_infra/billing/service.py +115 -0
  38. svc_infra/bundled_docs/README.md +5 -0
  39. svc_infra/bundled_docs/__init__.py +1 -0
  40. svc_infra/bundled_docs/getting-started.md +6 -0
  41. svc_infra/cache/__init__.py +4 -0
  42. svc_infra/cache/add.py +158 -0
  43. svc_infra/cache/backend.py +5 -2
  44. svc_infra/cache/decorators.py +19 -1
  45. svc_infra/cache/keys.py +24 -4
  46. svc_infra/cli/__init__.py +28 -8
  47. svc_infra/cli/cmds/__init__.py +8 -0
  48. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  49. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  50. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  51. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  52. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  53. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  54. svc_infra/cli/cmds/dx/__init__.py +12 -0
  55. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  56. svc_infra/cli/cmds/help.py +4 -0
  57. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  58. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  59. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  60. svc_infra/data/add.py +61 -0
  61. svc_infra/data/backup.py +53 -0
  62. svc_infra/data/erasure.py +45 -0
  63. svc_infra/data/fixtures.py +40 -0
  64. svc_infra/data/retention.py +55 -0
  65. svc_infra/db/nosql/mongo/README.md +13 -13
  66. svc_infra/db/sql/repository.py +51 -11
  67. svc_infra/db/sql/resource.py +5 -0
  68. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  69. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  70. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  71. svc_infra/db/sql/tenant.py +79 -0
  72. svc_infra/db/sql/utils.py +18 -4
  73. svc_infra/docs/acceptance-matrix.md +88 -0
  74. svc_infra/docs/acceptance.md +44 -0
  75. svc_infra/docs/admin.md +425 -0
  76. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  77. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  78. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  79. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  80. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  81. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  82. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  83. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  84. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  85. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  86. svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
  87. svc_infra/docs/api.md +186 -0
  88. svc_infra/docs/auth.md +11 -0
  89. svc_infra/docs/billing.md +190 -0
  90. svc_infra/docs/cache.md +76 -0
  91. svc_infra/docs/cli.md +74 -0
  92. svc_infra/docs/contributing.md +34 -0
  93. svc_infra/docs/data-lifecycle.md +52 -0
  94. svc_infra/docs/database.md +14 -0
  95. svc_infra/docs/docs-and-sdks.md +62 -0
  96. svc_infra/docs/environment.md +114 -0
  97. svc_infra/docs/getting-started.md +63 -0
  98. svc_infra/docs/idempotency.md +111 -0
  99. svc_infra/docs/jobs.md +67 -0
  100. svc_infra/docs/observability.md +16 -0
  101. svc_infra/docs/ops.md +37 -0
  102. svc_infra/docs/rate-limiting.md +125 -0
  103. svc_infra/docs/repo-review.md +48 -0
  104. svc_infra/docs/security.md +176 -0
  105. svc_infra/docs/storage.md +982 -0
  106. svc_infra/docs/tenancy.md +35 -0
  107. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  108. svc_infra/docs/versioned-integrations.md +146 -0
  109. svc_infra/docs/webhooks.md +112 -0
  110. svc_infra/dx/add.py +63 -0
  111. svc_infra/dx/changelog.py +74 -0
  112. svc_infra/dx/checks.py +67 -0
  113. svc_infra/http/__init__.py +13 -0
  114. svc_infra/http/client.py +72 -0
  115. svc_infra/jobs/builtins/webhook_delivery.py +14 -2
  116. svc_infra/jobs/queue.py +9 -1
  117. svc_infra/jobs/runner.py +75 -0
  118. svc_infra/jobs/worker.py +17 -1
  119. svc_infra/mcp/svc_infra_mcp.py +85 -28
  120. svc_infra/obs/add.py +54 -7
  121. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  122. svc_infra/security/headers.py +15 -2
  123. svc_infra/security/hibp.py +6 -2
  124. svc_infra/security/models.py +27 -7
  125. svc_infra/security/oauth_models.py +59 -0
  126. svc_infra/security/permissions.py +1 -0
  127. svc_infra/storage/__init__.py +93 -0
  128. svc_infra/storage/add.py +250 -0
  129. svc_infra/storage/backends/__init__.py +11 -0
  130. svc_infra/storage/backends/local.py +331 -0
  131. svc_infra/storage/backends/memory.py +214 -0
  132. svc_infra/storage/backends/s3.py +329 -0
  133. svc_infra/storage/base.py +239 -0
  134. svc_infra/storage/easy.py +182 -0
  135. svc_infra/storage/settings.py +192 -0
  136. svc_infra/webhooks/service.py +10 -2
  137. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
  138. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
  139. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
  140. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
@@ -2,6 +2,7 @@ import logging
2
2
  import traceback
3
3
  from typing import Any, Dict, Optional
4
4
 
5
+ import httpx
5
6
  from fastapi import Request
6
7
  from fastapi.exceptions import HTTPException, RequestValidationError
7
8
  from fastapi.responses import JSONResponse, Response
@@ -46,6 +47,7 @@ def problem_response(
46
47
  code: str | None = None,
47
48
  errors: list[dict] | None = None,
48
49
  trace_id: str | None = None,
50
+ headers: dict[str, str] | None = None,
49
51
  ) -> Response:
50
52
  body: Dict[str, Any] = {
51
53
  "type": type_uri,
@@ -62,10 +64,24 @@ def problem_response(
62
64
  body["errors"] = errors
63
65
  if trace_id:
64
66
  body["trace_id"] = trace_id
65
- return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT)
67
+ return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT, headers=headers)
66
68
 
67
69
 
68
70
  def register_error_handlers(app):
71
+ @app.exception_handler(httpx.TimeoutException)
72
+ async def handle_httpx_timeout(request: Request, exc: httpx.TimeoutException):
73
+ trace_id = _trace_id_from_request(request)
74
+ # Map outbound HTTP client timeouts to 504 Gateway Timeout
75
+ # Keep details generic in prod
76
+ return problem_response(
77
+ status=504,
78
+ title="Gateway Timeout",
79
+ detail=("Upstream request timed out." if IS_PROD else (str(exc) or "httpx timeout")),
80
+ code="GATEWAY_TIMEOUT",
81
+ instance=str(request.url),
82
+ trace_id=trace_id,
83
+ )
84
+
69
85
  @app.exception_handler(FastApiException)
70
86
  async def handle_app_exception(request: Request, exc: FastApiException):
71
87
  trace_id = _trace_id_from_request(request)
@@ -104,14 +120,25 @@ def register_error_handlers(app):
104
120
  @app.exception_handler(HTTPException)
105
121
  async def handle_http_exception(request: Request, exc: HTTPException):
106
122
  trace_id = _trace_id_from_request(request)
107
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
108
- exc.status_code, "Error"
109
- )
123
+ title = {
124
+ 401: "Unauthorized",
125
+ 403: "Forbidden",
126
+ 404: "Not Found",
127
+ 429: "Too Many Requests",
128
+ }.get(exc.status_code, "Error")
110
129
  detail = (
111
130
  exc.detail
112
131
  if not IS_PROD or exc.status_code < 500
113
132
  else "Something went wrong. Please contact support."
114
133
  )
134
+ # Preserve headers set on the exception (e.g., Retry-After for rate limits)
135
+ hdrs: dict[str, str] | None = None
136
+ try:
137
+ if getattr(exc, "headers", None):
138
+ # FastAPI/Starlette exceptions store headers as a dict[str, str]
139
+ hdrs = dict(getattr(exc, "headers")) # type: ignore[arg-type]
140
+ except Exception:
141
+ hdrs = None
115
142
  return problem_response(
116
143
  status=exc.status_code,
117
144
  title=title,
@@ -119,19 +146,29 @@ def register_error_handlers(app):
119
146
  code=title.replace(" ", "_").upper(),
120
147
  instance=str(request.url),
121
148
  trace_id=trace_id,
149
+ headers=hdrs,
122
150
  )
123
151
 
124
152
  @app.exception_handler(StarletteHTTPException)
125
153
  async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
126
154
  trace_id = _trace_id_from_request(request)
127
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
128
- exc.status_code, "Error"
129
- )
155
+ title = {
156
+ 401: "Unauthorized",
157
+ 403: "Forbidden",
158
+ 404: "Not Found",
159
+ 429: "Too Many Requests",
160
+ }.get(exc.status_code, "Error")
130
161
  detail = (
131
162
  exc.detail
132
163
  if not IS_PROD or exc.status_code < 500
133
164
  else "Something went wrong. Please contact support."
134
165
  )
166
+ hdrs: dict[str, str] | None = None
167
+ try:
168
+ if getattr(exc, "headers", None):
169
+ hdrs = dict(getattr(exc, "headers")) # type: ignore[arg-type]
170
+ except Exception:
171
+ hdrs = None
135
172
  return problem_response(
136
173
  status=exc.status_code,
137
174
  title=title,
@@ -139,6 +176,7 @@ def register_error_handlers(app):
139
176
  code=title.replace(" ", "_").upper(),
140
177
  instance=str(request.url),
141
178
  trace_id=trace_id,
179
+ headers=hdrs,
142
180
  )
143
181
 
144
182
  @app.exception_handler(IntegrityError)
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+ from typing import Optional
8
+
9
+ from fastapi import FastAPI
10
+ from starlette.types import ASGIApp, Receive, Scope, Send
11
+
12
+ from svc_infra.app.env import pick
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _get_grace_period_seconds() -> float:
18
+ default = pick(prod=20.0, nonprod=5.0)
19
+ raw = os.getenv("SHUTDOWN_GRACE_PERIOD_SECONDS")
20
+ if raw is None or raw == "":
21
+ return float(default)
22
+ try:
23
+ return float(raw)
24
+ except ValueError:
25
+ return float(default)
26
+
27
+
28
+ class InflightTrackerMiddleware:
29
+ """Tracks number of in-flight requests to support graceful shutdown drains."""
30
+
31
+ def __init__(self, app: ASGIApp):
32
+ self.app = app
33
+
34
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
35
+ if scope.get("type") != "http":
36
+ await self.app(scope, receive, send)
37
+ return
38
+ state = scope.get("app").state # type: ignore[attr-defined]
39
+ state._inflight_requests = getattr(state, "_inflight_requests", 0) + 1
40
+ try:
41
+ await self.app(scope, receive, send)
42
+ finally:
43
+ state._inflight_requests = max(0, getattr(state, "_inflight_requests", 1) - 1)
44
+
45
+
46
+ async def _wait_for_drain(app: FastAPI, grace: float) -> None:
47
+ interval = 0.1
48
+ waited = 0.0
49
+ while waited < grace:
50
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
51
+ if inflight <= 0:
52
+ return
53
+ await asyncio.sleep(interval)
54
+ waited += interval
55
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
56
+ if inflight > 0:
57
+ logger.warning(
58
+ "Graceful shutdown timeout: %s in-flight request(s) after %.2fs", inflight, waited
59
+ )
60
+
61
+
62
+ def install_graceful_shutdown(app: FastAPI, *, grace_seconds: Optional[float] = None) -> None:
63
+ """Install inflight tracking and lifespan hooks to wait for requests to drain.
64
+
65
+ - Adds InflightTrackerMiddleware
66
+ - Registers a lifespan handler that initializes state and waits up to grace_seconds on shutdown
67
+ """
68
+ app.add_middleware(InflightTrackerMiddleware)
69
+
70
+ g = float(grace_seconds) if grace_seconds is not None else _get_grace_period_seconds()
71
+
72
+ # Preserve any existing lifespan and wrap it so our drain runs on shutdown.
73
+ previous_lifespan = getattr(app.router, "lifespan_context", None)
74
+
75
+ @asynccontextmanager
76
+ async def _lifespan(a: FastAPI): # noqa: ANN202
77
+ # Startup: initialize inflight counter
78
+ a.state._inflight_requests = 0
79
+ if previous_lifespan is not None:
80
+ async with previous_lifespan(a):
81
+ yield
82
+ else:
83
+ yield
84
+ # Shutdown: wait for in-flight requests to drain (up to grace period)
85
+ await _wait_for_drain(a, g)
86
+
87
+ app.router.lifespan_context = _lifespan
@@ -7,6 +7,12 @@ from svc_infra.obs.metrics import emit_rate_limited
7
7
 
8
8
  from .ratelimit_store import InMemoryRateLimitStore, RateLimitStore
9
9
 
10
+ try:
11
+ # Optional import: tenancy may not be enabled in all apps
12
+ from svc_infra.api.fastapi.tenancy.context import resolve_tenant_id as _resolve_tenant_id
13
+ except Exception: # pragma: no cover - fallback for minimal builds
14
+ _resolve_tenant_id = None # type: ignore
15
+
10
16
 
11
17
  class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
12
18
  def __init__(
@@ -15,18 +21,70 @@ class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
15
21
  limit: int = 120,
16
22
  window: int = 60,
17
23
  key_fn=None,
24
+ *,
25
+ # When provided, dynamically computes a limit for the current request (e.g. per-tenant quotas)
26
+ # Signature: (request: Request, tenant_id: Optional[str]) -> int | None
27
+ limit_resolver=None,
28
+ # If True, automatically scopes the bucket key by tenant id when available
29
+ scope_by_tenant: bool = False,
30
+ # When True, allows unresolved tenant IDs to fall back to an "X-Tenant-Id" header value.
31
+ # Disabled by default to avoid trusting arbitrary client-provided headers which could
32
+ # otherwise be used to evade per-tenant limits when authentication fails.
33
+ allow_untrusted_tenant_header: bool = False,
18
34
  store: RateLimitStore | None = None,
19
35
  ):
20
36
  super().__init__(app)
21
37
  self.limit, self.window = limit, window
22
38
  self.key_fn = key_fn or (lambda r: r.headers.get("X-API-Key") or r.client.host)
39
+ self._limit_resolver = limit_resolver
40
+ self.scope_by_tenant = scope_by_tenant
41
+ self._allow_untrusted_tenant_header = allow_untrusted_tenant_header
23
42
  self.store = store or InMemoryRateLimitStore(limit=limit)
24
43
 
25
44
  async def dispatch(self, request, call_next):
45
+ # Resolve tenant when possible
46
+ tenant_id = None
47
+ if self.scope_by_tenant or self._limit_resolver:
48
+ try:
49
+ if _resolve_tenant_id is not None:
50
+ tenant_id = await _resolve_tenant_id(request)
51
+ except Exception:
52
+ tenant_id = None
53
+ # Fallback header behavior:
54
+ # - If tenancy context is unavailable (minimal builds), accept header by default so
55
+ # unit/integration tests can exercise per-tenant scoping without full auth state.
56
+ # - If tenancy is available, only trust the header when explicitly allowed.
57
+ if not tenant_id:
58
+ if _resolve_tenant_id is None:
59
+ tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get(
60
+ "X-Tenant-ID"
61
+ )
62
+ elif self._allow_untrusted_tenant_header:
63
+ tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get(
64
+ "X-Tenant-ID"
65
+ )
66
+
26
67
  key = self.key_fn(request)
68
+ if self.scope_by_tenant and tenant_id:
69
+ key = f"{key}:tenant:{tenant_id}"
70
+
71
+ # Allow dynamic limit overrides
72
+ eff_limit = self.limit
73
+ if self._limit_resolver:
74
+ try:
75
+ v = self._limit_resolver(request, tenant_id)
76
+ eff_limit = int(v) if v is not None else self.limit
77
+ except Exception:
78
+ eff_limit = self.limit
79
+
27
80
  now = int(time.time())
28
81
  # Increment counter in store
29
- count, limit, reset = self.store.incr(str(key), self.window)
82
+ # Update store limit if it differs; stores capture configured limit internally
83
+ # For in-memory store, we can temporarily adjust per-request by swapping a new store instance
84
+ # but to keep API simple, we reuse store and clamp by eff_limit below.
85
+ count, store_limit, reset = self.store.incr(str(key), self.window)
86
+ # Enforce the effective limit selected for this request
87
+ limit = eff_limit
30
88
  remaining = max(0, limit - count)
31
89
 
32
90
  if remaining < 0: # defensive clamp
@@ -16,14 +16,20 @@ class RateLimitStore(Protocol):
16
16
  class InMemoryRateLimitStore:
17
17
  def __init__(self, limit: int = 120):
18
18
  self.limit = limit
19
- self._buckets: dict[tuple[str, int], int] = {}
19
+ # Track per-key rolling windows: key -> (count, window_start_epoch)
20
+ self._state: dict[str, tuple[int, float]] = {}
20
21
 
21
22
  def incr(self, key: str, window: int) -> Tuple[int, int, int]:
22
- now = int(time.time())
23
- win = now - (now % window)
24
- count = self._buckets.get((key, win), 0) + 1
25
- self._buckets[(key, win)] = count
26
- reset = win + window
23
+ now = time.time()
24
+ count, window_start = self._state.get(key, (0, now))
25
+ # If outside the rolling window, reset
26
+ if now >= window_start + window:
27
+ count = 1
28
+ window_start = now
29
+ else:
30
+ count += 1
31
+ self._state[key] = (count, window_start)
32
+ reset = int(window_start + window)
27
33
  return count, self.limit, reset
28
34
 
29
35
 
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ from fastapi import Request
7
+ from starlette.types import ASGIApp, Receive, Scope, Send
8
+
9
+ from svc_infra.api.fastapi.middleware.errors.handlers import problem_response
10
+ from svc_infra.app.env import pick
11
+
12
+
13
+ def _env_int(name: str, default: int) -> int:
14
+ v = os.getenv(name)
15
+ if v is None:
16
+ return default
17
+ try:
18
+ return int(v)
19
+ except Exception:
20
+ return default
21
+
22
+
23
+ REQUEST_BODY_TIMEOUT_SECONDS: int = pick(
24
+ prod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 15),
25
+ nonprod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 30),
26
+ )
27
+ REQUEST_TIMEOUT_SECONDS: int = pick(
28
+ prod=_env_int("REQUEST_TIMEOUT_SECONDS", 30),
29
+ nonprod=_env_int("REQUEST_TIMEOUT_SECONDS", 15),
30
+ )
31
+
32
+
33
+ class HandlerTimeoutMiddleware:
34
+ """
35
+ Caps total handler execution time. If exceeded, returns 504 Problem+JSON.
36
+ """
37
+
38
+ def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
39
+ self.app = app
40
+ self.timeout_seconds = (
41
+ timeout_seconds if timeout_seconds is not None else REQUEST_TIMEOUT_SECONDS
42
+ )
43
+
44
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
45
+ if scope.get("type") != "http":
46
+ await self.app(scope, receive, send)
47
+ return
48
+
49
+ async def _call_next() -> None:
50
+ await self.app(scope, receive, send)
51
+
52
+ try:
53
+ await asyncio.wait_for(_call_next(), timeout=self.timeout_seconds)
54
+ except asyncio.TimeoutError:
55
+ # Build a minimal Request to extract headers and URL for trace info
56
+ request = Request(scope, receive=receive)
57
+ trace_id = None
58
+ for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
59
+ v = request.headers.get(h)
60
+ if v:
61
+ trace_id = v
62
+ break
63
+ resp = problem_response(
64
+ status=504,
65
+ title="Gateway Timeout",
66
+ detail="The request took too long to complete.",
67
+ code="GATEWAY_TIMEOUT",
68
+ instance=str(request.url),
69
+ trace_id=trace_id,
70
+ )
71
+ await resp(scope, receive, send)
72
+
73
+
74
+ class BodyReadTimeoutMiddleware:
75
+ """
76
+ Enforces a timeout while reading the request body to mitigate slowloris.
77
+ If body read does not make progress within the timeout, returns 408 Problem+JSON.
78
+ """
79
+
80
+ def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
81
+ self.app = app
82
+ self.timeout_seconds = (
83
+ timeout_seconds if timeout_seconds is not None else REQUEST_BODY_TIMEOUT_SECONDS
84
+ )
85
+
86
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
87
+ if scope.get("type") != "http":
88
+ await self.app(scope, receive, send)
89
+ return
90
+
91
+ # Strategy: greedily drain the incoming request body here while enforcing
92
+ # per-receive timeout, then replay it to the downstream app from a buffer.
93
+ # This ensures we can detect slowloris-style uploads even if the app only
94
+ # reads the body later (after the server has finished buffering).
95
+ buffered = bytearray()
96
+
97
+ try:
98
+ while True:
99
+ message = await asyncio.wait_for(receive(), timeout=self.timeout_seconds)
100
+
101
+ mtype = message.get("type")
102
+ if mtype == "http.request":
103
+ chunk = message.get("body", b"") or b""
104
+ if chunk:
105
+ buffered.extend(chunk)
106
+ # Stop when server indicates no more body
107
+ if not message.get("more_body", False):
108
+ break
109
+ # else: continue reading remaining chunks with timeout
110
+ continue
111
+
112
+ if mtype == "http.disconnect": # client disconnected mid-upload
113
+ # Treat as end of body for the purposes of replay; downstream
114
+ # will see an empty body. No timeout response needed here.
115
+ break
116
+ # Ignore other message types and continue
117
+ except asyncio.TimeoutError:
118
+ # Timed out while waiting for the next body chunk → return 408
119
+ request = Request(scope, receive=receive)
120
+ trace_id = None
121
+ for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
122
+ v = request.headers.get(h)
123
+ if v:
124
+ trace_id = v
125
+ break
126
+ resp = problem_response(
127
+ status=408,
128
+ title="Request Timeout",
129
+ detail="Timed out while reading request body.",
130
+ code="REQUEST_TIMEOUT",
131
+ instance=str(request.url),
132
+ trace_id=trace_id,
133
+ )
134
+ await resp(scope, receive, send)
135
+ return
136
+
137
+ # Replay the drained body to the app as a single http.request message.
138
+ sent = False
139
+
140
+ async def _replay_receive() -> dict:
141
+ nonlocal sent
142
+ if not sent:
143
+ sent = True
144
+ return {"type": "http.request", "body": bytes(buffered), "more_body": False}
145
+ # Subsequent calls return an empty terminal body event
146
+ return {"type": "http.request", "body": b"", "more_body": False}
147
+
148
+ await self.app(scope, _replay_receive, send)
@@ -1102,6 +1102,117 @@ def ensure_success_examples_mutator():
1102
1102
  return m
1103
1103
 
1104
1104
 
1105
+ # --- NEW: attach minimal x-codeSamples for common operations ---
1106
+ def attach_code_samples_mutator():
1107
+ """Attach minimal curl/httpie x-codeSamples for each operation if missing.
1108
+
1109
+ We avoid templating parameters; samples illustrate method and path only.
1110
+ """
1111
+
1112
+ def m(schema: dict) -> dict:
1113
+ schema = dict(schema)
1114
+ servers = schema.get("servers") or [{"url": ""}]
1115
+ base = servers[0].get("url") or ""
1116
+
1117
+ for path, method, op in _iter_ops(schema):
1118
+ # Don't override existing samples
1119
+ if isinstance(op.get("x-codeSamples"), list) and op["x-codeSamples"]:
1120
+ continue
1121
+ url = f"{base}{path}"
1122
+ method_up = method.upper()
1123
+ samples = [
1124
+ {
1125
+ "lang": "bash",
1126
+ "label": "curl",
1127
+ "source": f"curl -X {method_up} '{url}'",
1128
+ },
1129
+ {
1130
+ "lang": "bash",
1131
+ "label": "httpie",
1132
+ "source": f"http {method_up} '{url}'",
1133
+ },
1134
+ ]
1135
+ op["x-codeSamples"] = samples
1136
+ return schema
1137
+
1138
+ return m
1139
+
1140
+
1141
+ # --- NEW: ensure Problem+JSON examples exist for standard error responses ---
1142
+ def ensure_problem_examples_mutator():
1143
+ """Add example objects for 4xx/5xx responses using Problem schema if absent."""
1144
+
1145
+ try:
1146
+ # Internal helper with sensible defaults
1147
+ from .conventions import _problem_example # type: ignore
1148
+ except Exception: # pragma: no cover - fallback
1149
+
1150
+ def _problem_example(**kw): # type: ignore
1151
+ base = {
1152
+ "type": "about:blank",
1153
+ "title": "Error",
1154
+ "status": 500,
1155
+ "detail": "An error occurred.",
1156
+ "instance": "/request/trace",
1157
+ "code": "INTERNAL_ERROR",
1158
+ }
1159
+ base.update(kw)
1160
+ return base
1161
+
1162
+ def m(schema: dict) -> dict:
1163
+ schema = dict(schema)
1164
+ for _, _, op in _iter_ops(schema):
1165
+ resps = op.get("responses") or {}
1166
+ for code, resp in resps.items():
1167
+ if not isinstance(resp, dict):
1168
+ continue
1169
+ try:
1170
+ ic = int(code)
1171
+ except Exception:
1172
+ continue
1173
+ if ic < 400:
1174
+ continue
1175
+ # Do not add content if response is a $ref; avoid creating siblings
1176
+ if "$ref" in resp:
1177
+ continue
1178
+ content = resp.setdefault("content", {})
1179
+ # prefer problem+json but also set application/json if present
1180
+ for mt in ("application/problem+json", "application/json"):
1181
+ mt_obj = content.get(mt)
1182
+ if mt_obj is None:
1183
+ # Create a basic media type referencing Problem schema when appropriate
1184
+ if mt == "application/problem+json":
1185
+ mt_obj = {"schema": {"$ref": "#/components/schemas/Problem"}}
1186
+ content[mt] = mt_obj
1187
+ else:
1188
+ continue
1189
+ if not isinstance(mt_obj, dict):
1190
+ continue
1191
+ if "example" in mt_obj or "examples" in mt_obj:
1192
+ continue
1193
+ mt_obj["example"] = _problem_example(status=ic)
1194
+ return schema
1195
+
1196
+ return m
1197
+
1198
+
1199
+ # --- NEW: attach default tags from first path segment when missing ---
1200
+ def attach_default_tags_mutator():
1201
+ """If an operation has no tags, tag it by its first path segment."""
1202
+
1203
+ def m(schema: dict) -> dict:
1204
+ schema = dict(schema)
1205
+ for path, _method, op in _iter_ops(schema):
1206
+ tags = op.get("tags")
1207
+ if tags:
1208
+ continue
1209
+ seg = path.strip("/").split("/", 1)[0] or "root"
1210
+ op["tags"] = [seg]
1211
+ return schema
1212
+
1213
+ return m
1214
+
1215
+
1105
1216
  def dedupe_tags_mutator():
1106
1217
  def m(schema: dict) -> dict:
1107
1218
  schema = dict(schema)
@@ -1429,6 +1540,9 @@ def setup_mutators(
1429
1540
  ensure_media_type_schemas_mutator(),
1430
1541
  ensure_examples_for_json_mutator(),
1431
1542
  ensure_success_examples_mutator(),
1543
+ attach_default_tags_mutator(),
1544
+ attach_code_samples_mutator(),
1545
+ ensure_problem_examples_mutator(),
1432
1546
  ensure_media_examples_mutator(),
1433
1547
  scrub_invalid_object_examples_mutator(),
1434
1548
  normalize_no_content_204_mutator(),
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Callable
5
+
6
+ from fastapi import FastAPI, HTTPException, Request
7
+ from starlette.responses import JSONResponse
8
+
9
+
10
+ def add_probes(
11
+ app: FastAPI,
12
+ *,
13
+ prefix: str = "/_ops",
14
+ include_in_schema: bool = False,
15
+ ) -> None:
16
+ """Mount basic liveness/readiness/startup probes under prefix."""
17
+ from svc_infra.api.fastapi.dual.public import public_router
18
+
19
+ router = public_router(prefix=prefix, tags=["ops"], include_in_schema=include_in_schema)
20
+
21
+ @router.get("/live")
22
+ async def live() -> JSONResponse: # noqa: D401, ANN201
23
+ return JSONResponse({"status": "ok"})
24
+
25
+ @router.get("/ready")
26
+ async def ready() -> JSONResponse: # noqa: D401, ANN201
27
+ # In the future, add checks (DB ping, cache ping) via DI hooks.
28
+ return JSONResponse({"status": "ok"})
29
+
30
+ @router.get("/startup")
31
+ async def startup_probe() -> JSONResponse: # noqa: D401, ANN201
32
+ return JSONResponse({"status": "ok"})
33
+
34
+ app.include_router(router)
35
+
36
+
37
+ def add_maintenance_mode(
38
+ app: FastAPI,
39
+ *,
40
+ env_var: str = "MAINTENANCE_MODE",
41
+ exempt_prefixes: tuple[str, ...] | None = None,
42
+ ) -> None:
43
+ """Enable a simple maintenance gate controlled by an env var.
44
+
45
+ When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
46
+ """
47
+
48
+ @app.middleware("http")
49
+ async def _maintenance_gate(request: Request, call_next): # noqa: ANN001, ANN202
50
+ flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
51
+ if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
52
+ path = request.scope.get("path", "")
53
+ if exempt_prefixes and any(path.startswith(p) for p in exempt_prefixes):
54
+ return await call_next(request)
55
+ return JSONResponse({"detail": "maintenance"}, status_code=503)
56
+ return await call_next(request)
57
+
58
+
59
+ def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
60
+ """Return a dependency that can trip rejective errors based on external metrics.
61
+
62
+ This is a placeholder; callers can swap with a provider that tracks failures and opens the
63
+ breaker. Here, we read an env var to simulate an open breaker.
64
+ """
65
+
66
+ async def _dep(_: Request) -> None: # noqa: D401, ANN202
67
+ if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
68
+ raise HTTPException(status_code=503, detail="circuit open")
69
+
70
+ return _dep
71
+
72
+
73
+ __all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]
@@ -89,7 +89,9 @@ class PaginationContext(Generic[T]):
89
89
 
90
90
  @property
91
91
  def limit(self) -> int:
92
- if self.allow_cursor and self.cursor_params and self.cursor_params.cursor is not None:
92
+ # For cursor-based pagination, always honor the requested limit, even on the first page
93
+ # (cursor may be None for the first page).
94
+ if self.allow_cursor and self.cursor_params:
93
95
  return self.cursor_params.limit
94
96
  if self.allow_page and self.page_params:
95
97
  return self.limit_override or self.page_params.page_size
@@ -14,6 +14,7 @@ router = public_router(tags=["Health Check"])
14
14
  PING_PATH,
15
15
  status_code=status.HTTP_200_OK,
16
16
  description="Operation to check if the service is up and running",
17
+ operation_id="health_ping_get",
17
18
  )
18
19
  def ping():
19
20
  logging.info("Health check: /ping endpoint accessed. Service is responsive.")