svc-infra 0.1.706__py3-none-any.whl → 1.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.

Potentially problematic release.


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

Files changed (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
  import traceback
3
- from typing import Any, Dict, Optional
3
+ from typing import Any
4
4
 
5
5
  import httpx
6
6
  from fastapi import Request
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
17
17
  PROBLEM_MT = "application/problem+json"
18
18
 
19
19
 
20
- def _trace_id_from_request(request: Request) -> Optional[str]:
20
+ def _trace_id_from_request(request: Request) -> str | None:
21
21
  # Try common headers first; fall back to None
22
22
  for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
23
23
  v = request.headers.get(h)
@@ -49,7 +49,7 @@ def problem_response(
49
49
  trace_id: str | None = None,
50
50
  headers: dict[str, str] | None = None,
51
51
  ) -> Response:
52
- body: Dict[str, Any] = {
52
+ body: dict[str, Any] = {
53
53
  "type": type_uri,
54
54
  "title": title,
55
55
  "status": status,
@@ -64,9 +64,7 @@ def problem_response(
64
64
  body["errors"] = errors
65
65
  if trace_id:
66
66
  body["trace_id"] = trace_id
67
- return JSONResponse(
68
- status_code=status, content=body, media_type=PROBLEM_MT, headers=headers
69
- )
67
+ return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT, headers=headers)
70
68
 
71
69
 
72
70
  def register_error_handlers(app):
@@ -78,11 +76,7 @@ def register_error_handlers(app):
78
76
  return problem_response(
79
77
  status=504,
80
78
  title="Gateway Timeout",
81
- detail=(
82
- "Upstream request timed out."
83
- if IS_PROD
84
- else (str(exc) or "httpx timeout")
85
- ),
79
+ detail=("Upstream request timed out." if IS_PROD else (str(exc) or "httpx timeout")),
86
80
  code="GATEWAY_TIMEOUT",
87
81
  instance=str(request.url),
88
82
  trace_id=trace_id,
@@ -140,9 +134,10 @@ def register_error_handlers(app):
140
134
  # Preserve headers set on the exception (e.g., Retry-After for rate limits)
141
135
  hdrs: dict[str, str] | None = None
142
136
  try:
143
- if getattr(exc, "headers", None):
137
+ exc_headers = getattr(exc, "headers", None)
138
+ if exc_headers is not None:
144
139
  # FastAPI/Starlette exceptions store headers as a dict[str, str]
145
- hdrs = dict(getattr(exc, "headers"))
140
+ hdrs = dict(exc_headers)
146
141
  except Exception:
147
142
  hdrs = None
148
143
  return problem_response(
@@ -156,9 +151,7 @@ def register_error_handlers(app):
156
151
  )
157
152
 
158
153
  @app.exception_handler(StarletteHTTPException)
159
- async def handle_starlette_http_exception(
160
- request: Request, exc: StarletteHTTPException
161
- ):
154
+ async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
162
155
  trace_id = _trace_id_from_request(request)
163
156
  title = {
164
157
  401: "Unauthorized",
@@ -173,8 +166,9 @@ def register_error_handlers(app):
173
166
  )
174
167
  hdrs: dict[str, str] | None = None
175
168
  try:
176
- if getattr(exc, "headers", None):
177
- hdrs = dict(getattr(exc, "headers"))
169
+ exc_headers = getattr(exc, "headers", None)
170
+ if exc_headers is not None:
171
+ hdrs = dict(exc_headers)
178
172
  except Exception:
179
173
  hdrs = None
180
174
  return problem_response(
@@ -4,7 +4,6 @@ import asyncio
4
4
  import logging
5
5
  import os
6
6
  from contextlib import asynccontextmanager
7
- from typing import Optional
8
7
 
9
8
  from fastapi import FastAPI
10
9
  from starlette.types import ASGIApp, Receive, Scope, Send
@@ -47,9 +46,7 @@ class InflightTrackerMiddleware:
47
46
  try:
48
47
  await self.app(scope, receive, send)
49
48
  finally:
50
- state._inflight_requests = max(
51
- 0, getattr(state, "_inflight_requests", 1) - 1
52
- )
49
+ state._inflight_requests = max(0, getattr(state, "_inflight_requests", 1) - 1)
53
50
 
54
51
 
55
52
  async def _wait_for_drain(app: FastAPI, grace: float) -> None:
@@ -70,9 +67,7 @@ async def _wait_for_drain(app: FastAPI, grace: float) -> None:
70
67
  )
71
68
 
72
69
 
73
- def install_graceful_shutdown(
74
- app: FastAPI, *, grace_seconds: Optional[float] = None
75
- ) -> None:
70
+ def install_graceful_shutdown(app: FastAPI, *, grace_seconds: float | None = None) -> None:
76
71
  """Install inflight tracking and lifespan hooks to wait for requests to drain.
77
72
 
78
73
  - Adds InflightTrackerMiddleware
@@ -80,17 +75,13 @@ def install_graceful_shutdown(
80
75
  """
81
76
  app.add_middleware(InflightTrackerMiddleware)
82
77
 
83
- g = (
84
- float(grace_seconds)
85
- if grace_seconds is not None
86
- else _get_grace_period_seconds()
87
- )
78
+ g = float(grace_seconds) if grace_seconds is not None else _get_grace_period_seconds()
88
79
 
89
80
  # Preserve any existing lifespan and wrap it so our drain runs on shutdown.
90
81
  previous_lifespan = getattr(app.router, "lifespan_context", None)
91
82
 
92
83
  @asynccontextmanager
93
- async def _lifespan(a: FastAPI): # noqa: ANN202
84
+ async def _lifespan(a: FastAPI):
94
85
  # Startup: initialize inflight counter
95
86
  a.state._inflight_requests = 0
96
87
  if previous_lifespan is not None:
@@ -2,7 +2,7 @@ import base64
2
2
  import hashlib
3
3
  import json
4
4
  import time
5
- from typing import Annotated, Optional
5
+ from typing import Annotated
6
6
 
7
7
  from fastapi import Header, HTTPException, Request
8
8
  from starlette.types import ASGIApp, Receive, Scope, Send
@@ -17,15 +17,18 @@ class IdempotencyMiddleware:
17
17
  Caches responses for requests with Idempotency-Key header to ensure
18
18
  duplicate requests return the same response. Use skip_paths for endpoints
19
19
  where idempotency caching is not appropriate (e.g., streaming responses).
20
+
21
+ Matching uses prefix matching: "/v1/chat" matches "/v1/chat", "/v1/chat/stream",
22
+ but not "/api/v1/chat" or "/v1/chatter".
20
23
  """
21
24
 
22
25
  def __init__(
23
26
  self,
24
27
  app: ASGIApp,
25
28
  ttl_seconds: int = 24 * 3600,
26
- store: Optional[IdempotencyStore] = None,
29
+ store: IdempotencyStore | None = None,
27
30
  header_name: str = "Idempotency-Key",
28
- skip_paths: Optional[list[str]] = None,
31
+ skip_paths: list[str] | None = None,
29
32
  ):
30
33
  self.app = app
31
34
  self.ttl = ttl_seconds
@@ -45,8 +48,8 @@ class IdempotencyMiddleware:
45
48
  path = scope.get("path", "")
46
49
  method = scope.get("method", "GET")
47
50
 
48
- # Skip specified paths
49
- if any(skip in path for skip in self.skip_paths):
51
+ # Skip specified paths using prefix matching
52
+ if any(path.startswith(skip) for skip in self.skip_paths):
50
53
  await self.app(scope, receive, send)
51
54
  return
52
55
 
@@ -115,11 +118,7 @@ class IdempotencyMiddleware:
115
118
  },
116
119
  )
117
120
  return
118
- if (
119
- existing
120
- and existing.status is not None
121
- and existing.body_b64 is not None
122
- ):
121
+ if existing and existing.status is not None and existing.body_b64 is not None:
123
122
  await self._send_cached_response(send, existing)
124
123
  return
125
124
 
@@ -182,9 +181,7 @@ class IdempotencyMiddleware:
182
181
  await send({"type": "http.response.body", "body": body, "more_body": False})
183
182
 
184
183
  async def _send_cached_response(self, send, existing) -> None:
185
- headers = [
186
- (k.encode(), v.encode()) for k, v in (existing.headers or {}).items()
187
- ]
184
+ headers = [(k.encode(), v.encode()) for k, v in (existing.headers or {}).items()]
188
185
  if existing.media_type:
189
186
  headers.append((b"content-type", existing.media_type.encode()))
190
187
  await send(
@@ -208,6 +205,4 @@ async def require_idempotency_key(
208
205
  request: Request,
209
206
  ) -> None:
210
207
  if not idempotency_key.strip():
211
- raise HTTPException(
212
- status_code=400, detail="Idempotency-Key must not be empty."
213
- )
208
+ raise HTTPException(status_code=400, detail="Idempotency-Key must not be empty.")
@@ -4,7 +4,7 @@ import base64
4
4
  import json
5
5
  import time
6
6
  from dataclasses import dataclass
7
- from typing import Dict, Optional, Protocol
7
+ from typing import Protocol
8
8
 
9
9
 
10
10
  @dataclass
@@ -12,14 +12,14 @@ class IdempotencyEntry:
12
12
  req_hash: str
13
13
  exp: float
14
14
  # Optional response fields when available
15
- status: Optional[int] = None
16
- body_b64: Optional[str] = None
17
- headers: Optional[Dict[str, str]] = None
18
- media_type: Optional[str] = None
15
+ status: int | None = None
16
+ body_b64: str | None = None
17
+ headers: dict[str, str] | None = None
18
+ media_type: str | None = None
19
19
 
20
20
 
21
21
  class IdempotencyStore(Protocol):
22
- def get(self, key: str) -> Optional[IdempotencyEntry]:
22
+ def get(self, key: str) -> IdempotencyEntry | None:
23
23
  pass
24
24
 
25
25
  def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
@@ -32,8 +32,8 @@ class IdempotencyStore(Protocol):
32
32
  *,
33
33
  status: int,
34
34
  body: bytes,
35
- headers: Dict[str, str],
36
- media_type: Optional[str],
35
+ headers: dict[str, str],
36
+ media_type: str | None,
37
37
  ) -> None:
38
38
  pass
39
39
 
@@ -45,7 +45,7 @@ class InMemoryIdempotencyStore:
45
45
  def __init__(self):
46
46
  self._store: dict[str, IdempotencyEntry] = {}
47
47
 
48
- def get(self, key: str) -> Optional[IdempotencyEntry]:
48
+ def get(self, key: str) -> IdempotencyEntry | None:
49
49
  entry = self._store.get(key)
50
50
  if not entry:
51
51
  return None
@@ -69,8 +69,8 @@ class InMemoryIdempotencyStore:
69
69
  *,
70
70
  status: int,
71
71
  body: bytes,
72
- headers: Dict[str, str],
73
- media_type: Optional[str],
72
+ headers: dict[str, str],
73
+ media_type: str | None,
74
74
  ) -> None:
75
75
  entry = self._store.get(key)
76
76
  if not entry:
@@ -102,7 +102,7 @@ class RedisIdempotencyStore:
102
102
  def _k(self, key: str) -> str:
103
103
  return f"{self.prefix}:{key}"
104
104
 
105
- def get(self, key: str) -> Optional[IdempotencyEntry]:
105
+ def get(self, key: str) -> IdempotencyEntry | None:
106
106
  raw = self.r.get(self._k(key))
107
107
  if not raw:
108
108
  return None
@@ -156,8 +156,8 @@ class RedisIdempotencyStore:
156
156
  *,
157
157
  status: int,
158
158
  body: bytes,
159
- headers: Dict[str, str],
160
- media_type: Optional[str],
159
+ headers: dict[str, str],
160
+ media_type: str | None,
161
161
  ) -> None:
162
162
  entry = self.get(key)
163
163
  if not entry:
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Annotated, Any, Callable, Optional
3
+ from collections.abc import Callable
4
+ from typing import Annotated, Any
4
5
 
5
6
  from fastapi import Header, HTTPException
6
7
 
7
8
 
8
9
  async def require_if_match(
9
- version: Annotated[Optional[str], Header(alias="If-Match")] = None,
10
+ version: Annotated[str | None, Header(alias="If-Match")] = None,
10
11
  ) -> str:
11
12
  """Require If-Match header for optimistic locking on mutating operations.
12
13
 
@@ -31,12 +32,8 @@ def check_version_or_409(get_current_version: Callable[[], Any], provided: str)
31
32
  try:
32
33
  p = int(provided)
33
34
  except Exception:
34
- raise HTTPException(
35
- status_code=400, detail="Invalid If-Match value; expected integer."
36
- )
35
+ raise HTTPException(status_code=400, detail="Invalid If-Match value; expected integer.")
37
36
  else:
38
37
  p = provided
39
38
  if p != current:
40
- raise HTTPException(
41
- status_code=409, detail="Version mismatch (optimistic locking)."
42
- )
39
+ raise HTTPException(status_code=409, detail="Version mismatch (optimistic locking).")
@@ -23,6 +23,9 @@ class SimpleRateLimitMiddleware:
23
23
 
24
24
  Applies per-key rate limits with configurable windows. Use skip_paths for
25
25
  endpoints that should bypass rate limiting (e.g., health checks, webhooks).
26
+
27
+ Matching uses prefix matching: "/v1/chat" matches "/v1/chat", "/v1/chat/stream",
28
+ but not "/api/v1/chat" or "/v1/chatter".
26
29
  """
27
30
 
28
31
  def __init__(
@@ -60,8 +63,8 @@ class SimpleRateLimitMiddleware:
60
63
 
61
64
  path = scope.get("path", "")
62
65
 
63
- # Skip specified paths
64
- if any(skip in path for skip in self.skip_paths):
66
+ # Skip specified paths using prefix matching
67
+ if any(path.startswith(skip) for skip in self.skip_paths):
65
68
  await self.app(scope, receive, send)
66
69
  return
67
70
 
@@ -70,8 +73,7 @@ class SimpleRateLimitMiddleware:
70
73
 
71
74
  # Default key function
72
75
  key_fn = self.key_fn or (
73
- lambda r: r.headers.get("X-API-Key")
74
- or (r.client.host if r.client else "unknown")
76
+ lambda r: r.headers.get("X-API-Key") or (r.client.host if r.client else "unknown")
75
77
  )
76
78
 
77
79
  # Resolve tenant when possible
@@ -85,9 +87,7 @@ class SimpleRateLimitMiddleware:
85
87
  # Fallback header behavior - ONLY if explicitly allowed
86
88
  # Never trust untrusted headers by default to prevent rate limit evasion
87
89
  if not tenant_id and self._allow_untrusted_tenant_header:
88
- tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get(
89
- "X-Tenant-ID"
90
- )
90
+ tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get("X-Tenant-ID")
91
91
 
92
92
  key = key_fn(request)
93
93
  if self.scope_by_tenant and tenant_id:
@@ -103,7 +103,7 @@ class SimpleRateLimitMiddleware:
103
103
  eff_limit = self.limit
104
104
 
105
105
  now = int(time.time())
106
- count, store_limit, reset = self.store.incr(str(key), self.window)
106
+ count, _store_limit, reset = self.store.incr(str(key), self.window)
107
107
  limit = eff_limit
108
108
  remaining = max(0, limit - count)
109
109
 
@@ -4,7 +4,8 @@ import logging
4
4
  import os
5
5
  import time
6
6
  import warnings
7
- from typing import Callable, Optional, Protocol, Tuple
7
+ from collections.abc import Callable
8
+ from typing import Protocol
8
9
 
9
10
  logger = logging.getLogger(__name__)
10
11
 
@@ -29,7 +30,7 @@ def _check_inmemory_production_warning(class_name: str) -> None:
29
30
 
30
31
 
31
32
  class RateLimitStore(Protocol):
32
- def incr(self, key: str, window: int) -> Tuple[int, int, int]:
33
+ def incr(self, key: str, window: int) -> tuple[int, int, int]:
33
34
  """Increment and return (count, limit, resetEpoch).
34
35
 
35
36
  Implementations should manage per-window buckets. The 'limit' is stored configuration.
@@ -44,7 +45,7 @@ class InMemoryRateLimitStore:
44
45
  # Track per-key rolling windows: key -> (count, window_start_epoch)
45
46
  self._state: dict[str, tuple[int, float]] = {}
46
47
 
47
- def incr(self, key: str, window: int) -> Tuple[int, int, int]:
48
+ def incr(self, key: str, window: int) -> tuple[int, int, int]:
48
49
  now = time.time()
49
50
  count, window_start = self._state.get(key, (0, now))
50
51
  # If outside the rolling window, reset
@@ -74,7 +75,7 @@ class RedisRateLimitStore:
74
75
  *,
75
76
  limit: int = 120,
76
77
  prefix: str = "ratelimit",
77
- clock: Optional[Callable[[], float]] = None,
78
+ clock: Callable[[], float] | None = None,
78
79
  ):
79
80
  self.redis = redis_client
80
81
  self.limit = limit
@@ -87,16 +88,14 @@ class RedisRateLimitStore:
87
88
  redis_key = f"{self.prefix}:{key}:{win}"
88
89
  return redis_key, win, now
89
90
 
90
- def incr(self, key: str, window: int) -> Tuple[int, int, int]:
91
+ def incr(self, key: str, window: int) -> tuple[int, int, int]:
91
92
  rkey, win, now = self._window_key(key, window)
92
93
  # Increment; if this is the first time we've seen this window key, set expiry to window end
93
94
  pipe = self.redis.pipeline()
94
95
  pipe.incr(rkey)
95
96
  pipe.ttl(rkey)
96
97
  count, ttl = pipe.execute()
97
- if (
98
- ttl == -1
99
- ): # key exists without expire or just created; set expire to end of window
98
+ if ttl == -1: # key exists without expire or just created; set expire to end of window
100
99
  expire_sec = (win + window) - now
101
100
  if expire_sec <= 0:
102
101
  expire_sec = window
@@ -4,9 +4,7 @@ from uuid import uuid4
4
4
  from starlette.datastructures import Headers, MutableHeaders
5
5
  from starlette.types import ASGIApp, Message, Receive, Scope, Send
6
6
 
7
- request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar(
8
- "request_id", default=""
9
- )
7
+ request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="")
10
8
 
11
9
 
12
10
  class RequestIdMiddleware:
@@ -37,6 +37,9 @@ class HandlerTimeoutMiddleware:
37
37
 
38
38
  Use skip_paths for endpoints that may run longer than the timeout
39
39
  (e.g., streaming responses, long-polling, file uploads).
40
+
41
+ Matching uses prefix matching: "/v1/chat" matches "/v1/chat", "/v1/chat/stream",
42
+ but not "/api/v1/chat" or "/v1/chatter".
40
43
  """
41
44
 
42
45
  def __init__(
@@ -58,8 +61,8 @@ class HandlerTimeoutMiddleware:
58
61
 
59
62
  path = scope.get("path", "")
60
63
 
61
- # Skip specified paths (e.g., long-running endpoints)
62
- if any(skip in path for skip in self.skip_paths):
64
+ # Skip specified paths using prefix matching (e.g., long-running endpoints)
65
+ if any(path.startswith(skip) for skip in self.skip_paths):
63
66
  await self.app(scope, receive, send)
64
67
  return
65
68
 
@@ -77,7 +80,7 @@ class HandlerTimeoutMiddleware:
77
80
  self.app(scope, receive, send_wrapper), # type: ignore[arg-type] # ASGI send signature
78
81
  timeout=self.timeout_seconds,
79
82
  )
80
- except asyncio.TimeoutError:
83
+ except TimeoutError:
81
84
  # Only send 504 if response hasn't started yet
82
85
  if not response_started:
83
86
  response = problem_response(
@@ -98,9 +101,7 @@ class BodyReadTimeoutMiddleware:
98
101
  def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
99
102
  self.app = app
100
103
  self.timeout_seconds = (
101
- timeout_seconds
102
- if timeout_seconds is not None
103
- else REQUEST_BODY_TIMEOUT_SECONDS
104
+ timeout_seconds if timeout_seconds is not None else REQUEST_BODY_TIMEOUT_SECONDS
104
105
  )
105
106
 
106
107
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
@@ -116,9 +117,7 @@ class BodyReadTimeoutMiddleware:
116
117
 
117
118
  try:
118
119
  while True:
119
- message = await asyncio.wait_for(
120
- receive(), timeout=self.timeout_seconds
121
- )
120
+ message = await asyncio.wait_for(receive(), timeout=self.timeout_seconds)
122
121
 
123
122
  mtype = message.get("type")
124
123
  if mtype == "http.request":
@@ -136,7 +135,7 @@ class BodyReadTimeoutMiddleware:
136
135
  # will see an empty body. No timeout response needed here.
137
136
  break
138
137
  # Ignore other message types and continue
139
- except asyncio.TimeoutError:
138
+ except TimeoutError:
140
139
  # Timed out while waiting for the next body chunk → return 408
141
140
  request = Request(scope, receive=receive)
142
141
  trace_id = None