svc-infra 0.1.589__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 (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -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 +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -59,19 +59,36 @@ from svc_infra.api.fastapi.auth.security import (
59
59
  RequireUser,
60
60
  )
61
61
  from svc_infra.api.fastapi.auth.settings import AuthSettings, get_auth_settings
62
- from svc_infra.api.fastapi.dual.protected import (
62
+
63
+ # ----------------
64
+ # WebSocket identity primitives (lightweight JWT, no DB required)
65
+ # ----------------
66
+ from svc_infra.api.fastapi.auth.ws_security import (
67
+ AllowWSIdentity,
68
+ OptionalWSIdentity,
69
+ RequireWSAnyScope,
70
+ RequireWSIdentity,
71
+ RequireWSScopes,
72
+ WSIdentity,
73
+ WSPrincipal,
74
+ )
75
+ from svc_infra.api.fastapi.dual.protected import ( # WebSocket routers (DualAPIRouter with JWT auth, no DB required)
63
76
  optional_identity_router,
64
77
  protected_router,
65
78
  roles_router,
66
79
  scopes_router,
67
80
  service_router,
68
81
  user_router,
82
+ ws_optional_router,
83
+ ws_protected_router,
84
+ ws_scopes_router,
85
+ ws_user_router,
69
86
  )
70
87
 
71
88
  # ----------------
72
89
  # Pre-wired routers (OpenAPI security auto-injected)
73
90
  # ----------------
74
- from svc_infra.api.fastapi.dual.public import public_router
91
+ from svc_infra.api.fastapi.dual.public import public_router, ws_public_router
75
92
 
76
93
  # ----------------
77
94
  # App bootstrap
@@ -127,6 +144,20 @@ __all__ = [
127
144
  "service_router",
128
145
  "scopes_router",
129
146
  "roles_router",
147
+ # WebSocket identity
148
+ "WSPrincipal",
149
+ "WSIdentity",
150
+ "OptionalWSIdentity",
151
+ "RequireWSIdentity",
152
+ "AllowWSIdentity",
153
+ "RequireWSScopes",
154
+ "RequireWSAnyScope",
155
+ # WebSocket routers
156
+ "ws_public_router",
157
+ "ws_protected_router",
158
+ "ws_user_router",
159
+ "ws_scopes_router",
160
+ "ws_optional_router",
130
161
  # Feature routers
131
162
  "apikey_router",
132
163
  "mfa_router",
@@ -104,9 +104,13 @@ class EasyAppOptions(BaseModel):
104
104
  else self.logging.enable
105
105
  ),
106
106
  level=(
107
- override.logging.level if override.logging.level is not None else self.logging.level
107
+ override.logging.level
108
+ if override.logging.level is not None
109
+ else self.logging.level
108
110
  ),
109
- fmt=override.logging.fmt if override.logging.fmt is not None else self.logging.fmt,
111
+ fmt=override.logging.fmt
112
+ if override.logging.fmt is not None
113
+ else self.logging.fmt,
110
114
  )
111
115
 
112
116
  # observability
@@ -148,6 +152,7 @@ def easy_service_api(
148
152
  public_cors_origins: list[str] | str | None = None,
149
153
  root_public_base_url: str | None = None,
150
154
  root_include_api_key: bool | None = None,
155
+ **fastapi_kwargs, # Forward all other FastAPI kwargs
151
156
  ) -> FastAPI:
152
157
  service = ServiceInfo(name=name, release=release)
153
158
  specs = [
@@ -161,6 +166,7 @@ def easy_service_api(
161
166
  public_cors_origins=public_cors_origins,
162
167
  root_public_base_url=root_public_base_url,
163
168
  root_include_api_key=root_include_api_key,
169
+ **fastapi_kwargs, # Forward to setup_service_api
164
170
  )
165
171
 
166
172
 
@@ -176,6 +182,7 @@ def easy_service_app(
176
182
  options: EasyAppOptions | None = None,
177
183
  enable_logging: bool | None = None,
178
184
  enable_observability: bool | None = None,
185
+ **fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
179
186
  ) -> FastAPI:
180
187
  """
181
188
  One-call bootstrap with env + options + flags:
@@ -226,6 +233,7 @@ def easy_service_app(
226
233
  public_cors_origins=public_cors_origins,
227
234
  root_public_base_url=root_public_base_url,
228
235
  root_include_api_key=root_include_api_key,
236
+ **fastapi_kwargs, # Forward FastAPI kwargs (lifespan, etc.)
229
237
  )
230
238
 
231
239
  # 5) Observability
@@ -10,5 +10,6 @@ def require_if_match(request: Request, current_etag: str):
10
10
  )
11
11
  if current_etag not in [t.strip() for t in val.split(",")]:
12
12
  raise HTTPException(
13
- status_code=status.HTTP_412_PRECONDITION_FAILED, detail="ETag precondition failed."
13
+ status_code=status.HTTP_412_PRECONDITION_FAILED,
14
+ detail="ETag precondition failed.",
14
15
  )
@@ -20,7 +20,9 @@ def set_conditional_headers(
20
20
  resp.headers["Last-Modified"] = format_datetime(last_modified)
21
21
 
22
22
 
23
- def maybe_not_modified(request: Request, etag: str | None, last_modified: datetime | None) -> bool:
23
+ def maybe_not_modified(
24
+ request: Request, etag: str | None, last_modified: datetime | None
25
+ ) -> bool:
24
26
  inm = request.headers.get("If-None-Match")
25
27
  ims = request.headers.get("If-Modified-Since")
26
28
  etag_ok = etag and inm and etag in [t.strip() for t in inm.split(",")]
@@ -15,6 +15,9 @@ class RouteDebugMiddleware(BaseHTTPMiddleware):
15
15
  route = request.scope.get("route")
16
16
  ep = getattr(route, "endpoint", None) if route else None
17
17
  log.info(
18
- "MATCHED %s %s -> %s", request.method, request.url.path, getattr(ep, "__name__", ep)
18
+ "MATCHED %s %s -> %s",
19
+ request.method,
20
+ request.url.path,
21
+ getattr(ep, "__name__", ep),
19
22
  )
20
23
  return response
@@ -31,7 +31,9 @@ class CatchAllExceptionMiddleware:
31
31
 
32
32
  if response_started:
33
33
  try:
34
- await send({"type": "http.response.body", "body": b"", "more_body": False})
34
+ await send(
35
+ {"type": "http.response.body", "body": b"", "more_body": False}
36
+ )
35
37
  except Exception:
36
38
  pass
37
39
  else:
@@ -52,4 +54,6 @@ class CatchAllExceptionMiddleware:
52
54
  "headers": [(b"content-type", PROBLEM_MT.encode("ascii"))],
53
55
  }
54
56
  )
55
- await send({"type": "http.response.body", "body": body, "more_body": False})
57
+ await send(
58
+ {"type": "http.response.body", "body": body, "more_body": False}
59
+ )
@@ -12,7 +12,7 @@ class FastApiException(Exception):
12
12
  detail: Optional[str] = None,
13
13
  status_code: int = 400,
14
14
  *,
15
- code: str | None = None
15
+ code: str | None = None,
16
16
  ):
17
17
  self.title = title
18
18
  self.detail = detail
@@ -2,6 +2,7 @@ import logging
2
2
  import traceback
3
3
  from typing import Any, Dict, Optional
4
4
 
5
+ import httpx
5
6
  from fastapi import Request
6
7
  from fastapi.exceptions import HTTPException, RequestValidationError
7
8
  from fastapi.responses import JSONResponse, Response
@@ -46,6 +47,7 @@ def problem_response(
46
47
  code: str | None = None,
47
48
  errors: list[dict] | None = None,
48
49
  trace_id: str | None = None,
50
+ headers: dict[str, str] | None = None,
49
51
  ) -> Response:
50
52
  body: Dict[str, Any] = {
51
53
  "type": type_uri,
@@ -62,10 +64,30 @@ def problem_response(
62
64
  body["errors"] = errors
63
65
  if trace_id:
64
66
  body["trace_id"] = trace_id
65
- return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT)
67
+ return JSONResponse(
68
+ status_code=status, content=body, media_type=PROBLEM_MT, headers=headers
69
+ )
66
70
 
67
71
 
68
72
  def register_error_handlers(app):
73
+ @app.exception_handler(httpx.TimeoutException)
74
+ async def handle_httpx_timeout(request: Request, exc: httpx.TimeoutException):
75
+ trace_id = _trace_id_from_request(request)
76
+ # Map outbound HTTP client timeouts to 504 Gateway Timeout
77
+ # Keep details generic in prod
78
+ return problem_response(
79
+ status=504,
80
+ title="Gateway Timeout",
81
+ detail=(
82
+ "Upstream request timed out."
83
+ if IS_PROD
84
+ else (str(exc) or "httpx timeout")
85
+ ),
86
+ code="GATEWAY_TIMEOUT",
87
+ instance=str(request.url),
88
+ trace_id=trace_id,
89
+ )
90
+
69
91
  @app.exception_handler(FastApiException)
70
92
  async def handle_app_exception(request: Request, exc: FastApiException):
71
93
  trace_id = _trace_id_from_request(request)
@@ -104,14 +126,25 @@ def register_error_handlers(app):
104
126
  @app.exception_handler(HTTPException)
105
127
  async def handle_http_exception(request: Request, exc: HTTPException):
106
128
  trace_id = _trace_id_from_request(request)
107
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
108
- exc.status_code, "Error"
109
- )
129
+ title = {
130
+ 401: "Unauthorized",
131
+ 403: "Forbidden",
132
+ 404: "Not Found",
133
+ 429: "Too Many Requests",
134
+ }.get(exc.status_code, "Error")
110
135
  detail = (
111
136
  exc.detail
112
137
  if not IS_PROD or exc.status_code < 500
113
138
  else "Something went wrong. Please contact support."
114
139
  )
140
+ # Preserve headers set on the exception (e.g., Retry-After for rate limits)
141
+ hdrs: dict[str, str] | None = None
142
+ try:
143
+ if getattr(exc, "headers", None):
144
+ # FastAPI/Starlette exceptions store headers as a dict[str, str]
145
+ hdrs = dict(getattr(exc, "headers"))
146
+ except Exception:
147
+ hdrs = None
115
148
  return problem_response(
116
149
  status=exc.status_code,
117
150
  title=title,
@@ -119,19 +152,31 @@ def register_error_handlers(app):
119
152
  code=title.replace(" ", "_").upper(),
120
153
  instance=str(request.url),
121
154
  trace_id=trace_id,
155
+ headers=hdrs,
122
156
  )
123
157
 
124
158
  @app.exception_handler(StarletteHTTPException)
125
- async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
159
+ async def handle_starlette_http_exception(
160
+ request: Request, exc: StarletteHTTPException
161
+ ):
126
162
  trace_id = _trace_id_from_request(request)
127
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
128
- exc.status_code, "Error"
129
- )
163
+ title = {
164
+ 401: "Unauthorized",
165
+ 403: "Forbidden",
166
+ 404: "Not Found",
167
+ 429: "Too Many Requests",
168
+ }.get(exc.status_code, "Error")
130
169
  detail = (
131
170
  exc.detail
132
171
  if not IS_PROD or exc.status_code < 500
133
172
  else "Something went wrong. Please contact support."
134
173
  )
174
+ hdrs: dict[str, str] | None = None
175
+ try:
176
+ if getattr(exc, "headers", None):
177
+ hdrs = dict(getattr(exc, "headers"))
178
+ except Exception:
179
+ hdrs = None
135
180
  return problem_response(
136
181
  status=exc.status_code,
137
182
  title=title,
@@ -139,6 +184,7 @@ def register_error_handlers(app):
139
184
  code=title.replace(" ", "_").upper(),
140
185
  instance=str(request.url),
141
186
  trace_id=trace_id,
187
+ headers=hdrs,
142
188
  )
143
189
 
144
190
  @app.exception_handler(IntegrityError)
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+ from typing import Optional
8
+
9
+ from fastapi import FastAPI
10
+ from starlette.types import ASGIApp, Receive, Scope, Send
11
+
12
+ from svc_infra.app.env import pick
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _get_grace_period_seconds() -> float:
18
+ default = pick(prod=20.0, nonprod=5.0)
19
+ raw = os.getenv("SHUTDOWN_GRACE_PERIOD_SECONDS")
20
+ if raw is None or raw == "":
21
+ return float(default)
22
+ try:
23
+ return float(raw)
24
+ except ValueError:
25
+ return float(default)
26
+
27
+
28
+ class InflightTrackerMiddleware:
29
+ """Tracks number of in-flight requests to support graceful shutdown drains."""
30
+
31
+ def __init__(self, app: ASGIApp):
32
+ self.app = app
33
+
34
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
35
+ if scope.get("type") != "http":
36
+ await self.app(scope, receive, send)
37
+ return
38
+ app = scope.get("app")
39
+ if app is None:
40
+ await self.app(scope, receive, send)
41
+ return
42
+ state = getattr(app, "state", None)
43
+ if state is None:
44
+ await self.app(scope, receive, send)
45
+ return
46
+ state._inflight_requests = getattr(state, "_inflight_requests", 0) + 1
47
+ try:
48
+ await self.app(scope, receive, send)
49
+ finally:
50
+ state._inflight_requests = max(
51
+ 0, getattr(state, "_inflight_requests", 1) - 1
52
+ )
53
+
54
+
55
+ async def _wait_for_drain(app: FastAPI, grace: float) -> None:
56
+ interval = 0.1
57
+ waited = 0.0
58
+ while waited < grace:
59
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
60
+ if inflight <= 0:
61
+ return
62
+ await asyncio.sleep(interval)
63
+ waited += interval
64
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
65
+ if inflight > 0:
66
+ logger.warning(
67
+ "Graceful shutdown timeout: %s in-flight request(s) after %.2fs",
68
+ inflight,
69
+ waited,
70
+ )
71
+
72
+
73
+ def install_graceful_shutdown(
74
+ app: FastAPI, *, grace_seconds: Optional[float] = None
75
+ ) -> None:
76
+ """Install inflight tracking and lifespan hooks to wait for requests to drain.
77
+
78
+ - Adds InflightTrackerMiddleware
79
+ - Registers a lifespan handler that initializes state and waits up to grace_seconds on shutdown
80
+ """
81
+ app.add_middleware(InflightTrackerMiddleware)
82
+
83
+ g = (
84
+ float(grace_seconds)
85
+ if grace_seconds is not None
86
+ else _get_grace_period_seconds()
87
+ )
88
+
89
+ # Preserve any existing lifespan and wrap it so our drain runs on shutdown.
90
+ previous_lifespan = getattr(app.router, "lifespan_context", None)
91
+
92
+ @asynccontextmanager
93
+ async def _lifespan(a: FastAPI): # noqa: ANN202
94
+ # Startup: initialize inflight counter
95
+ a.state._inflight_requests = 0
96
+ if previous_lifespan is not None:
97
+ async with previous_lifespan(a):
98
+ yield
99
+ else:
100
+ yield
101
+ # Shutdown: wait for in-flight requests to drain (up to grace period)
102
+ await _wait_for_drain(a, g)
103
+
104
+ app.router.lifespan_context = _lifespan
@@ -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
+ )