svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -1,81 +1,206 @@
1
+ import base64
1
2
  import hashlib
3
+ import json
2
4
  import time
3
- from typing import Annotated
5
+ from typing import Annotated, Optional
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
+
22
+ def __init__(
23
+ self,
24
+ app: ASGIApp,
25
+ ttl_seconds: int = 24 * 3600,
26
+ store: Optional[IdempotencyStore] = None,
27
+ header_name: str = "Idempotency-Key",
28
+ skip_paths: Optional[list[str]] = None,
29
+ ):
30
+ self.app = app
13
31
  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()
32
+ self.store: IdempotencyStore = store or InMemoryIdempotencyStore()
33
+ self.header_name = header_name.lower()
34
+ self.skip_paths = skip_paths or []
35
+
36
+ def _cache_key(self, method: str, path: str, idkey: str) -> str:
37
+ sig = hashlib.sha256((method + "|" + path + "|" + idkey).encode()).hexdigest()
37
38
  return f"idmp:{sig}"
38
39
 
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)
40
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
41
+ if scope.get("type") != "http":
42
+ await self.app(scope, receive, send)
43
+ return
44
+
45
+ path = scope.get("path", "")
46
+ method = scope.get("method", "GET")
47
+
48
+ # Skip specified paths
49
+ if any(skip in path for skip in self.skip_paths):
50
+ await self.app(scope, receive, send)
51
+ return
52
+
53
+ # Only apply to mutating methods
54
+ if method not in {"POST", "PATCH", "DELETE"}:
55
+ await self.app(scope, receive, send)
56
+ return
57
+
58
+ # Get idempotency key from headers
59
+ headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
60
+ idkey = headers.get(self.header_name)
61
+
62
+ if not idkey:
63
+ # No idempotency key - pass through
64
+ await self.app(scope, receive, send)
65
+ return
66
+
67
+ # Buffer the request body
68
+ body_parts = []
69
+ while True:
70
+ message = await receive()
71
+ if message["type"] == "http.request":
72
+ body_parts.append(message.get("body", b"") or b"")
73
+ if not message.get("more_body", False):
74
+ break
75
+ elif message["type"] == "http.disconnect":
76
+ break
77
+ body = b"".join(body_parts)
78
+
79
+ k = self._cache_key(method, path, idkey)
80
+ now = time.time()
81
+ req_hash = hashlib.sha256(body).hexdigest()
82
+
83
+ existing = self.store.get(k)
84
+ if existing and existing.exp > now:
85
+ # If payload mismatches, return conflict
86
+ if existing.req_hash and existing.req_hash != req_hash:
87
+ await self._send_json_response(
88
+ send,
89
+ 409,
90
+ {
91
+ "type": "about:blank",
92
+ "title": "Conflict",
93
+ "detail": "Idempotency-Key re-used with different request payload.",
94
+ },
95
+ )
96
+ return
97
+ # If response cached and payload matches, replay it
98
+ if existing.status is not None and existing.body_b64 is not None:
99
+ await self._send_cached_response(send, existing)
100
+ return
101
+
102
+ # Claim the key
103
+ exp = now + self.ttl
104
+ created = self.store.set_initial(k, req_hash, exp)
105
+ if not created:
106
+ existing = self.store.get(k)
107
+ if existing and existing.req_hash and existing.req_hash != req_hash:
108
+ await self._send_json_response(
109
+ send,
110
+ 409,
111
+ {
112
+ "type": "about:blank",
113
+ "title": "Conflict",
114
+ "detail": "Idempotency-Key re-used with different request payload.",
115
+ },
116
+ )
117
+ return
118
+ if (
119
+ existing
120
+ and existing.status is not None
121
+ and existing.body_b64 is not None
122
+ ):
123
+ await self._send_cached_response(send, existing)
124
+ return
125
+
126
+ # Create a replay receive that returns buffered body
127
+ # IMPORTANT: After replaying the body, we must forward to original receive()
128
+ # so that Starlette's listen_for_disconnect can properly detect client disconnects.
129
+ # This is required for streaming responses on ASGI spec < 2.4.
130
+ body_sent = False
131
+
132
+ async def replay_receive():
133
+ nonlocal body_sent
134
+ if not body_sent:
135
+ body_sent = True
136
+ return {"type": "http.request", "body": body, "more_body": False}
137
+ # After body is sent, forward to original receive for disconnect detection
138
+ return await receive()
139
+
140
+ # Capture response for caching
141
+ response_started = False
142
+ response_status = 0
143
+ response_headers: list = []
144
+ response_body_parts = []
145
+
146
+ async def capture_send(message):
147
+ nonlocal response_started, response_status, response_headers
148
+ if message["type"] == "http.response.start":
149
+ response_started = True
150
+ response_status = message.get("status", 200)
151
+ response_headers = list(message.get("headers", []))
152
+ elif message["type"] == "http.response.body":
153
+ body_chunk = message.get("body", b"")
154
+ if body_chunk:
155
+ response_body_parts.append(body_chunk)
156
+ await send(message)
157
+
158
+ await self.app(scope, replay_receive, capture_send)
159
+
160
+ # Cache successful responses
161
+ if 200 <= response_status < 300:
162
+ response_body = b"".join(response_body_parts)
163
+ headers_dict = {k.decode(): v.decode() for k, v in response_headers}
164
+ media_type = headers_dict.get("content-type", "application/octet-stream")
165
+ self.store.set_response(
166
+ k,
167
+ status=response_status,
168
+ body=response_body,
169
+ headers=headers_dict,
170
+ media_type=media_type,
171
+ )
172
+
173
+ async def _send_json_response(self, send, status: int, content: dict) -> None:
174
+ body = json.dumps(content).encode("utf-8")
175
+ await send(
176
+ {
177
+ "type": "http.response.start",
178
+ "status": status,
179
+ "headers": [(b"content-type", b"application/json")],
180
+ }
181
+ )
182
+ await send({"type": "http.response.body", "body": body, "more_body": False})
183
+
184
+ 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
+ ]
188
+ if existing.media_type:
189
+ headers.append((b"content-type", existing.media_type.encode()))
190
+ await send(
191
+ {
192
+ "type": "http.response.start",
193
+ "status": existing.status,
194
+ "headers": headers,
195
+ }
196
+ )
197
+ await send(
198
+ {
199
+ "type": "http.response.body",
200
+ "body": base64.b64decode(existing.body_b64),
201
+ "more_body": False,
202
+ }
203
+ )
79
204
 
80
205
 
81
206
  async def require_idempotency_key(
@@ -83,4 +208,6 @@ async def require_idempotency_key(
83
208
  request: Request,
84
209
  ) -> None:
85
210
  if not idempotency_key.strip():
86
- raise HTTPException(status_code=400, detail="Idempotency-Key must not be empty.")
211
+ raise HTTPException(
212
+ status_code=400, detail="Idempotency-Key must not be empty."
213
+ )
@@ -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 Dict, Optional, Protocol
8
+
9
+
10
+ @dataclass
11
+ class IdempotencyEntry:
12
+ req_hash: str
13
+ exp: float
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
19
+
20
+
21
+ class IdempotencyStore(Protocol):
22
+ def get(self, key: str) -> Optional[IdempotencyEntry]:
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: Optional[str],
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) -> Optional[IdempotencyEntry]:
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: Optional[str],
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) -> Optional[IdempotencyEntry]:
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: Optional[str],
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,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any, Callable, Optional
4
+
5
+ from fastapi import Header, HTTPException
6
+
7
+
8
+ async def require_if_match(
9
+ version: Annotated[Optional[str], Header(alias="If-Match")] = None,
10
+ ) -> str:
11
+ """Require If-Match header for optimistic locking on mutating operations.
12
+
13
+ Returns the header value. Raises 428 if missing.
14
+ """
15
+ if not version:
16
+ raise HTTPException(
17
+ status_code=428, detail="Missing If-Match header for optimistic locking."
18
+ )
19
+ return version
20
+
21
+
22
+ def check_version_or_409(get_current_version: Callable[[], Any], provided: str) -> None:
23
+ """Compare provided version with current version; raise 409 on mismatch.
24
+
25
+ - get_current_version: callable returning the resource's current version (int/str)
26
+ - provided: header value; attempts to coerce to int if current is int
27
+ """
28
+ current = get_current_version()
29
+ p: int | str
30
+ if isinstance(current, int):
31
+ try:
32
+ p = int(provided)
33
+ except Exception:
34
+ raise HTTPException(
35
+ status_code=400, detail="Invalid If-Match value; expected integer."
36
+ )
37
+ else:
38
+ p = provided
39
+ if p != current:
40
+ raise HTTPException(
41
+ status_code=409, detail="Version mismatch (optimistic locking)."
42
+ )