svc-infra 0.1.595__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 (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,81 +1,203 @@
1
+ import base64
1
2
  import hashlib
3
+ import json
2
4
  import time
3
5
  from typing import Annotated
4
6
 
5
7
  from fastapi import Header, HTTPException, Request
6
- from starlette.middleware.base import BaseHTTPMiddleware
7
- from starlette.responses import Response
8
+ from starlette.types import ASGIApp, Receive, Scope, Send
8
9
 
10
+ from .idempotency_store import IdempotencyStore, InMemoryIdempotencyStore
9
11
 
10
- class IdempotencyMiddleware(BaseHTTPMiddleware):
11
- def __init__(self, app, ttl_seconds: int = 24 * 3600, store=None):
12
- super().__init__(app)
12
+
13
+ class IdempotencyMiddleware:
14
+ """
15
+ Pure ASGI idempotency middleware.
16
+
17
+ Caches responses for requests with Idempotency-Key header to ensure
18
+ duplicate requests return the same response. Use skip_paths for endpoints
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".
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ app: ASGIApp,
28
+ ttl_seconds: int = 24 * 3600,
29
+ store: IdempotencyStore | None = None,
30
+ header_name: str = "Idempotency-Key",
31
+ skip_paths: list[str] | None = None,
32
+ ):
33
+ self.app = app
13
34
  self.ttl = ttl_seconds
14
- self.store = store or {} # replace with Redis
15
-
16
- def _cache_key(self, request, idkey: str):
17
- body = getattr(request, "_body", None)
18
- if body is None:
19
- body = b""
20
-
21
- async def _read():
22
- data = await request.body()
23
- request._body = data # stash for downstream
24
- return data
25
-
26
- # read once
27
- # note: starlette Request is awaitable; we read in dispatch below
28
-
29
- sig = hashlib.sha256(
30
- (
31
- request.method + "|" + request.url.path + "|" + idkey + "|" + (request._body or b"")
32
- ).encode()
33
- if isinstance(request._body, str)
34
- else (request.method + "|" + request.url.path + "|" + idkey).encode()
35
- + (request._body or b"")
36
- ).hexdigest()
35
+ self.store: IdempotencyStore = store or InMemoryIdempotencyStore()
36
+ self.header_name = header_name.lower()
37
+ self.skip_paths = skip_paths or []
38
+
39
+ def _cache_key(self, method: str, path: str, idkey: str) -> str:
40
+ sig = hashlib.sha256((method + "|" + path + "|" + idkey).encode()).hexdigest()
37
41
  return f"idmp:{sig}"
38
42
 
39
- async def dispatch(self, request, call_next):
40
- if request.method in {"POST", "PATCH", "DELETE"}:
41
- # read & buffer body once
42
- body = await request.body()
43
- request._body = body
44
- idkey = request.headers.get("Idempotency-Key")
45
- if idkey:
46
- k = self._cache_key(request, idkey)
47
- entry = self.store.get(k)
48
- now = time.time()
49
- if entry and entry["exp"] > now:
50
- cached = entry["resp"]
51
- return Response(
52
- content=cached["body"],
53
- status_code=cached["status"],
54
- headers=cached["headers"],
55
- media_type=cached.get("media_type"),
56
- )
57
- resp = await call_next(request)
58
- # cache only 2xx/201 responses
59
- if 200 <= resp.status_code < 300:
60
- body_bytes = b"".join([section async for section in resp.body_iterator])
61
- headers = dict(resp.headers)
62
- self.store[k] = {
63
- "resp": {
64
- "status": resp.status_code,
65
- "body": body_bytes,
66
- "headers": headers,
67
- "media_type": resp.media_type,
68
- },
69
- "exp": now + self.ttl,
70
- }
71
- return Response(
72
- content=body_bytes,
73
- status_code=resp.status_code,
74
- headers=headers,
75
- media_type=resp.media_type,
76
- )
77
- return resp
78
- return await call_next(request)
43
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
44
+ if scope.get("type") != "http":
45
+ await self.app(scope, receive, send)
46
+ return
47
+
48
+ path = scope.get("path", "")
49
+ method = scope.get("method", "GET")
50
+
51
+ # Skip specified paths using prefix matching
52
+ if any(path.startswith(skip) for skip in self.skip_paths):
53
+ await self.app(scope, receive, send)
54
+ return
55
+
56
+ # Only apply to mutating methods
57
+ if method not in {"POST", "PATCH", "DELETE"}:
58
+ await self.app(scope, receive, send)
59
+ return
60
+
61
+ # Get idempotency key from headers
62
+ headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
63
+ idkey = headers.get(self.header_name)
64
+
65
+ if not idkey:
66
+ # No idempotency key - pass through
67
+ await self.app(scope, receive, send)
68
+ return
69
+
70
+ # Buffer the request body
71
+ body_parts = []
72
+ while True:
73
+ message = await receive()
74
+ if message["type"] == "http.request":
75
+ body_parts.append(message.get("body", b"") or b"")
76
+ if not message.get("more_body", False):
77
+ break
78
+ elif message["type"] == "http.disconnect":
79
+ break
80
+ body = b"".join(body_parts)
81
+
82
+ k = self._cache_key(method, path, idkey)
83
+ now = time.time()
84
+ req_hash = hashlib.sha256(body).hexdigest()
85
+
86
+ existing = self.store.get(k)
87
+ if existing and existing.exp > now:
88
+ # If payload mismatches, return conflict
89
+ if existing.req_hash and existing.req_hash != req_hash:
90
+ await self._send_json_response(
91
+ send,
92
+ 409,
93
+ {
94
+ "type": "about:blank",
95
+ "title": "Conflict",
96
+ "detail": "Idempotency-Key re-used with different request payload.",
97
+ },
98
+ )
99
+ return
100
+ # If response cached and payload matches, replay it
101
+ if existing.status is not None and existing.body_b64 is not None:
102
+ await self._send_cached_response(send, existing)
103
+ return
104
+
105
+ # Claim the key
106
+ exp = now + self.ttl
107
+ created = self.store.set_initial(k, req_hash, exp)
108
+ if not created:
109
+ existing = self.store.get(k)
110
+ if existing and existing.req_hash and existing.req_hash != req_hash:
111
+ await self._send_json_response(
112
+ send,
113
+ 409,
114
+ {
115
+ "type": "about:blank",
116
+ "title": "Conflict",
117
+ "detail": "Idempotency-Key re-used with different request payload.",
118
+ },
119
+ )
120
+ return
121
+ if existing and existing.status is not None and existing.body_b64 is not None:
122
+ await self._send_cached_response(send, existing)
123
+ return
124
+
125
+ # Create a replay receive that returns buffered body
126
+ # IMPORTANT: After replaying the body, we must forward to original receive()
127
+ # so that Starlette's listen_for_disconnect can properly detect client disconnects.
128
+ # This is required for streaming responses on ASGI spec < 2.4.
129
+ body_sent = False
130
+
131
+ async def replay_receive():
132
+ nonlocal body_sent
133
+ if not body_sent:
134
+ body_sent = True
135
+ return {"type": "http.request", "body": body, "more_body": False}
136
+ # After body is sent, forward to original receive for disconnect detection
137
+ return await receive()
138
+
139
+ # Capture response for caching
140
+ response_started = False
141
+ response_status = 0
142
+ response_headers: list = []
143
+ response_body_parts = []
144
+
145
+ async def capture_send(message):
146
+ nonlocal response_started, response_status, response_headers
147
+ if message["type"] == "http.response.start":
148
+ response_started = True
149
+ response_status = message.get("status", 200)
150
+ response_headers = list(message.get("headers", []))
151
+ elif message["type"] == "http.response.body":
152
+ body_chunk = message.get("body", b"")
153
+ if body_chunk:
154
+ response_body_parts.append(body_chunk)
155
+ await send(message)
156
+
157
+ await self.app(scope, replay_receive, capture_send)
158
+
159
+ # Cache successful responses
160
+ if 200 <= response_status < 300:
161
+ response_body = b"".join(response_body_parts)
162
+ headers_dict = {k.decode(): v.decode() for k, v in response_headers}
163
+ media_type = headers_dict.get("content-type", "application/octet-stream")
164
+ self.store.set_response(
165
+ k,
166
+ status=response_status,
167
+ body=response_body,
168
+ headers=headers_dict,
169
+ media_type=media_type,
170
+ )
171
+
172
+ async def _send_json_response(self, send, status: int, content: dict) -> None:
173
+ body = json.dumps(content).encode("utf-8")
174
+ await send(
175
+ {
176
+ "type": "http.response.start",
177
+ "status": status,
178
+ "headers": [(b"content-type", b"application/json")],
179
+ }
180
+ )
181
+ await send({"type": "http.response.body", "body": body, "more_body": False})
182
+
183
+ async def _send_cached_response(self, send, existing) -> None:
184
+ headers = [(k.encode(), v.encode()) for k, v in (existing.headers or {}).items()]
185
+ if existing.media_type:
186
+ headers.append((b"content-type", existing.media_type.encode()))
187
+ await send(
188
+ {
189
+ "type": "http.response.start",
190
+ "status": existing.status,
191
+ "headers": headers,
192
+ }
193
+ )
194
+ await send(
195
+ {
196
+ "type": "http.response.body",
197
+ "body": base64.b64decode(existing.body_b64),
198
+ "more_body": False,
199
+ }
200
+ )
79
201
 
80
202
 
81
203
  async def require_idempotency_key(
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+
10
+ @dataclass
11
+ class IdempotencyEntry:
12
+ req_hash: str
13
+ exp: float
14
+ # Optional response fields when available
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
+
20
+
21
+ class IdempotencyStore(Protocol):
22
+ def get(self, key: str) -> IdempotencyEntry | None:
23
+ pass
24
+
25
+ def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
26
+ """Atomically create an entry if absent. Returns True if created, False if already exists."""
27
+ pass
28
+
29
+ def set_response(
30
+ self,
31
+ key: str,
32
+ *,
33
+ status: int,
34
+ body: bytes,
35
+ headers: dict[str, str],
36
+ media_type: str | None,
37
+ ) -> None:
38
+ pass
39
+
40
+ def delete(self, key: str) -> None:
41
+ pass
42
+
43
+
44
+ class InMemoryIdempotencyStore:
45
+ def __init__(self):
46
+ self._store: dict[str, IdempotencyEntry] = {}
47
+
48
+ def get(self, key: str) -> IdempotencyEntry | None:
49
+ entry = self._store.get(key)
50
+ if not entry:
51
+ return None
52
+ # expire lazily
53
+ if entry.exp <= time.time():
54
+ self._store.pop(key, None)
55
+ return None
56
+ return entry
57
+
58
+ def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
59
+ now = time.time()
60
+ existing = self._store.get(key)
61
+ if existing and existing.exp > now:
62
+ return False
63
+ self._store[key] = IdempotencyEntry(req_hash=req_hash, exp=exp)
64
+ return True
65
+
66
+ def set_response(
67
+ self,
68
+ key: str,
69
+ *,
70
+ status: int,
71
+ body: bytes,
72
+ headers: dict[str, str],
73
+ media_type: str | None,
74
+ ) -> None:
75
+ entry = self._store.get(key)
76
+ if not entry:
77
+ # Create if missing to ensure replay works until exp
78
+ entry = IdempotencyEntry(req_hash="", exp=time.time() + 60)
79
+ self._store[key] = entry
80
+ entry.status = status
81
+ entry.body_b64 = base64.b64encode(body).decode()
82
+ entry.headers = dict(headers)
83
+ entry.media_type = media_type
84
+
85
+ def delete(self, key: str) -> None:
86
+ self._store.pop(key, None)
87
+
88
+
89
+ class RedisIdempotencyStore:
90
+ """A simple Redis-backed store.
91
+
92
+ Notes:
93
+ - Uses GET/SET with JSON payload; initial claim uses SETNX semantics.
94
+ - Not fully atomic for response update; sufficient for basic dedupe.
95
+ - For strict guarantees, replace with a Lua script (future improvement).
96
+ """
97
+
98
+ def __init__(self, redis_client, *, prefix: str = "idmp"):
99
+ self.r = redis_client
100
+ self.prefix = prefix
101
+
102
+ def _k(self, key: str) -> str:
103
+ return f"{self.prefix}:{key}"
104
+
105
+ def get(self, key: str) -> IdempotencyEntry | None:
106
+ raw = self.r.get(self._k(key))
107
+ if not raw:
108
+ return None
109
+ try:
110
+ data = json.loads(raw)
111
+ except Exception:
112
+ return None
113
+ entry = IdempotencyEntry(
114
+ req_hash=data.get("req_hash", ""),
115
+ exp=float(data.get("exp", 0)),
116
+ status=data.get("status"),
117
+ body_b64=data.get("body_b64"),
118
+ headers=data.get("headers"),
119
+ media_type=data.get("media_type"),
120
+ )
121
+ if entry.exp <= time.time():
122
+ try:
123
+ self.r.delete(self._k(key))
124
+ except Exception:
125
+ pass
126
+ return None
127
+ return entry
128
+
129
+ def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
130
+ payload = json.dumps({"req_hash": req_hash, "exp": exp})
131
+ # Attempt NX set
132
+ ok = self.r.set(self._k(key), payload, nx=True)
133
+ # If set, also set TTL (expire at exp)
134
+ if ok:
135
+ ttl = max(1, int(exp - time.time()))
136
+ try:
137
+ self.r.expire(self._k(key), ttl)
138
+ except Exception:
139
+ pass
140
+ return True
141
+ # If exists but expired, overwrite
142
+ entry = self.get(key)
143
+ if not entry:
144
+ self.r.set(self._k(key), payload)
145
+ ttl = max(1, int(exp - time.time()))
146
+ try:
147
+ self.r.expire(self._k(key), ttl)
148
+ except Exception:
149
+ pass
150
+ return True
151
+ return False
152
+
153
+ def set_response(
154
+ self,
155
+ key: str,
156
+ *,
157
+ status: int,
158
+ body: bytes,
159
+ headers: dict[str, str],
160
+ media_type: str | None,
161
+ ) -> None:
162
+ entry = self.get(key)
163
+ if not entry:
164
+ # default short ttl if missing; caller should have set initial
165
+ entry = IdempotencyEntry(req_hash="", exp=time.time() + 60)
166
+ entry.status = status
167
+ entry.body_b64 = base64.b64encode(body).decode()
168
+ entry.headers = dict(headers)
169
+ entry.media_type = media_type
170
+ ttl = max(1, int(entry.exp - time.time()))
171
+ payload = json.dumps(
172
+ {
173
+ "req_hash": entry.req_hash,
174
+ "exp": entry.exp,
175
+ "status": entry.status,
176
+ "body_b64": entry.body_b64,
177
+ "headers": entry.headers,
178
+ "media_type": entry.media_type,
179
+ }
180
+ )
181
+ self.r.set(self._k(key), payload, ex=ttl)
182
+
183
+ def delete(self, key: str) -> None:
184
+ try:
185
+ self.r.delete(self._k(key))
186
+ except Exception:
187
+ pass
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Annotated, Any
5
+
6
+ from fastapi import Header, HTTPException
7
+
8
+
9
+ async def require_if_match(
10
+ version: Annotated[str | None, Header(alias="If-Match")] = None,
11
+ ) -> str:
12
+ """Require If-Match header for optimistic locking on mutating operations.
13
+
14
+ Returns the header value. Raises 428 if missing.
15
+ """
16
+ if not version:
17
+ raise HTTPException(
18
+ status_code=428, detail="Missing If-Match header for optimistic locking."
19
+ )
20
+ return version
21
+
22
+
23
+ def check_version_or_409(get_current_version: Callable[[], Any], provided: str) -> None:
24
+ """Compare provided version with current version; raise 409 on mismatch.
25
+
26
+ - get_current_version: callable returning the resource's current version (int/str)
27
+ - provided: header value; attempts to coerce to int if current is int
28
+ """
29
+ current = get_current_version()
30
+ p: int | str
31
+ if isinstance(current, int):
32
+ try:
33
+ p = int(provided)
34
+ except Exception:
35
+ raise HTTPException(status_code=400, detail="Invalid If-Match value; expected integer.")
36
+ else:
37
+ p = provided
38
+ if p != current:
39
+ raise HTTPException(status_code=409, detail="Version mismatch (optimistic locking).")
@@ -1,61 +1,158 @@
1
+ import json
1
2
  import time
2
3
 
3
- from starlette.middleware.base import BaseHTTPMiddleware
4
- from starlette.responses import JSONResponse
4
+ from fastapi import Request
5
+ from starlette.types import ASGIApp, Receive, Scope, Send
5
6
 
6
7
  from svc_infra.obs.metrics import emit_rate_limited
7
8
 
8
9
  from .ratelimit_store import InMemoryRateLimitStore, RateLimitStore
9
10
 
11
+ try:
12
+ # Optional import: tenancy may not be enabled in all apps
13
+ from svc_infra.api.fastapi.tenancy.context import (
14
+ resolve_tenant_id as _resolve_tenant_id,
15
+ )
16
+ except Exception: # pragma: no cover - fallback for minimal builds
17
+ _resolve_tenant_id = None # type: ignore[assignment]
18
+
19
+
20
+ class SimpleRateLimitMiddleware:
21
+ """
22
+ Pure ASGI rate limiting middleware.
23
+
24
+ Applies per-key rate limits with configurable windows. Use skip_paths for
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".
29
+ """
10
30
 
11
- class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
12
31
  def __init__(
13
32
  self,
14
- app,
33
+ app: ASGIApp,
15
34
  limit: int = 120,
16
35
  window: int = 60,
17
36
  key_fn=None,
37
+ *,
38
+ # When provided, dynamically computes a limit for the current request (e.g. per-tenant quotas)
39
+ # Signature: (request: Request, tenant_id: Optional[str]) -> int | None
40
+ limit_resolver=None,
41
+ # If True, automatically scopes the bucket key by tenant id when available
42
+ scope_by_tenant: bool = False,
43
+ # When True, allows unresolved tenant IDs to fall back to an "X-Tenant-Id" header value.
44
+ # Disabled by default to avoid trusting arbitrary client-provided headers which could
45
+ # otherwise be used to evade per-tenant limits when authentication fails.
46
+ allow_untrusted_tenant_header: bool = False,
18
47
  store: RateLimitStore | None = None,
48
+ skip_paths: list[str] | None = None,
19
49
  ):
20
- super().__init__(app)
50
+ self.app = app
21
51
  self.limit, self.window = limit, window
22
- self.key_fn = key_fn or (lambda r: r.headers.get("X-API-Key") or r.client.host)
52
+ self.key_fn = key_fn
53
+ self._limit_resolver = limit_resolver
54
+ self.scope_by_tenant = scope_by_tenant
55
+ self._allow_untrusted_tenant_header = allow_untrusted_tenant_header
23
56
  self.store = store or InMemoryRateLimitStore(limit=limit)
57
+ self.skip_paths = skip_paths or []
58
+
59
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
60
+ if scope.get("type") != "http":
61
+ await self.app(scope, receive, send)
62
+ return
63
+
64
+ path = scope.get("path", "")
65
+
66
+ # Skip specified paths using prefix matching
67
+ if any(path.startswith(skip) for skip in self.skip_paths):
68
+ await self.app(scope, receive, send)
69
+ return
70
+
71
+ # Create a Request object for key extraction and tenant resolution
72
+ request = Request(scope, receive)
73
+
74
+ # Default key function
75
+ key_fn = self.key_fn or (
76
+ lambda r: r.headers.get("X-API-Key") or (r.client.host if r.client else "unknown")
77
+ )
78
+
79
+ # Resolve tenant when possible
80
+ tenant_id = None
81
+ if self.scope_by_tenant or self._limit_resolver:
82
+ try:
83
+ if _resolve_tenant_id is not None:
84
+ tenant_id = await _resolve_tenant_id(request)
85
+ except Exception:
86
+ tenant_id = None
87
+ # Fallback header behavior - ONLY if explicitly allowed
88
+ # Never trust untrusted headers by default to prevent rate limit evasion
89
+ if not tenant_id and self._allow_untrusted_tenant_header:
90
+ tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get("X-Tenant-ID")
91
+
92
+ key = key_fn(request)
93
+ if self.scope_by_tenant and tenant_id:
94
+ key = f"{key}:tenant:{tenant_id}"
95
+
96
+ # Allow dynamic limit overrides
97
+ eff_limit = self.limit
98
+ if self._limit_resolver:
99
+ try:
100
+ v = self._limit_resolver(request, tenant_id)
101
+ eff_limit = int(v) if v is not None else self.limit
102
+ except Exception:
103
+ eff_limit = self.limit
24
104
 
25
- async def dispatch(self, request, call_next):
26
- key = self.key_fn(request)
27
105
  now = int(time.time())
28
- # Increment counter in store
29
- count, limit, reset = self.store.incr(str(key), self.window)
106
+ count, _store_limit, reset = self.store.incr(str(key), self.window)
107
+ limit = eff_limit
30
108
  remaining = max(0, limit - count)
31
109
 
32
- if remaining < 0: # defensive clamp
33
- remaining = 0
34
-
35
110
  if count > limit:
111
+ # Rate limited - return 429
36
112
  retry = max(0, reset - now)
37
113
  try:
38
114
  emit_rate_limited(str(key), limit, retry)
39
115
  except Exception:
40
116
  pass
41
- return JSONResponse(
42
- status_code=429,
43
- content={
117
+
118
+ body = json.dumps(
119
+ {
44
120
  "title": "Too Many Requests",
45
121
  "status": 429,
46
122
  "detail": "Rate limit exceeded.",
47
123
  "code": "RATE_LIMITED",
48
- },
49
- headers={
50
- "X-RateLimit-Limit": str(limit),
51
- "X-RateLimit-Remaining": "0",
52
- "X-RateLimit-Reset": str(reset),
53
- "Retry-After": str(retry),
54
- },
124
+ }
125
+ ).encode("utf-8")
126
+
127
+ await send(
128
+ {
129
+ "type": "http.response.start",
130
+ "status": 429,
131
+ "headers": [
132
+ (b"content-type", b"application/json"),
133
+ (b"x-ratelimit-limit", str(limit).encode()),
134
+ (b"x-ratelimit-remaining", b"0"),
135
+ (b"x-ratelimit-reset", str(reset).encode()),
136
+ (b"retry-after", str(retry).encode()),
137
+ ],
138
+ }
55
139
  )
140
+ await send({"type": "http.response.body", "body": body, "more_body": False})
141
+ return
142
+
143
+ # Not rate limited - add headers to response
144
+ async def send_with_headers(message):
145
+ if message["type"] == "http.response.start":
146
+ headers = list(message.get("headers", []))
147
+ # Add rate limit headers if not already present
148
+ header_names = {h[0].lower() for h in headers}
149
+ if b"x-ratelimit-limit" not in header_names:
150
+ headers.append((b"x-ratelimit-limit", str(limit).encode()))
151
+ if b"x-ratelimit-remaining" not in header_names:
152
+ headers.append((b"x-ratelimit-remaining", str(remaining).encode()))
153
+ if b"x-ratelimit-reset" not in header_names:
154
+ headers.append((b"x-ratelimit-reset", str(reset).encode()))
155
+ message = {**message, "headers": headers}
156
+ await send(message)
56
157
 
57
- resp = await call_next(request)
58
- resp.headers.setdefault("X-RateLimit-Limit", str(limit))
59
- resp.headers.setdefault("X-RateLimit-Remaining", str(remaining))
60
- resp.headers.setdefault("X-RateLimit-Reset", str(reset))
61
- return resp
158
+ await self.app(scope, receive, send_with_headers)