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,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Type
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from fastapi import APIRouter
6
7
  from fastapi.params import Depends
@@ -37,10 +38,14 @@ class DualAPIRouter(APIRouter):
37
38
  if is_rootish:
38
39
  # primary root
39
40
  self.add_api_route(
40
- "", func, methods=methods, include_in_schema=show_in_schema, **kwargs
41
+ "",
42
+ func,
43
+ methods=methods,
44
+ include_in_schema=show_in_schema,
45
+ **kwargs,
41
46
  )
42
47
  # only add the "/" twin for *safe* methods
43
- if set(m.upper() for m in methods) <= safe_methods:
48
+ if {m.upper() for m in methods} <= safe_methods:
44
49
  self.add_api_route(
45
50
  "/", func, methods=methods, include_in_schema=False, **kwargs
46
51
  )
@@ -48,7 +53,11 @@ class DualAPIRouter(APIRouter):
48
53
 
49
54
  # non-root unchanged
50
55
  self.add_api_route(
51
- primary, func, methods=methods, include_in_schema=show_in_schema, **kwargs
56
+ primary,
57
+ func,
58
+ methods=methods,
59
+ include_in_schema=show_in_schema,
60
+ **kwargs,
52
61
  )
53
62
  if alt != primary:
54
63
  self.add_api_route(alt, func, methods=methods, include_in_schema=False, **kwargs)
@@ -57,7 +66,7 @@ class DualAPIRouter(APIRouter):
57
66
  return decorator
58
67
 
59
68
  def add_api_route(self, path, endpoint, **kwargs):
60
- methods = set((kwargs.get("methods") or []))
69
+ methods = set(kwargs.get("methods") or [])
61
70
  for r in self.routes:
62
71
  if getattr(r, "path", None) == path and methods & (
63
72
  getattr(r, "methods", set()) or set()
@@ -92,7 +101,7 @@ class DualAPIRouter(APIRouter):
92
101
  self,
93
102
  path: str,
94
103
  *,
95
- model: Type[BaseModel],
104
+ model: type[BaseModel],
96
105
  envelope: bool = False,
97
106
  cursor: bool = True,
98
107
  page: bool = True,
@@ -110,10 +119,11 @@ class DualAPIRouter(APIRouter):
110
119
  - Per-route opt-out of OpenAPI param auto-attach: openapi_extra={"x_no_auto_pagination": True}
111
120
  """
112
121
  # pick response model
122
+ response_model: Any
113
123
  if envelope:
114
- response_model = Paginated[model] # type: ignore[index]
124
+ response_model = Paginated[model] # type: ignore[valid-type]
115
125
  else:
116
- response_model = list[model] # type: ignore[index]
126
+ response_model = list[model] # type: ignore[valid-type]
117
127
 
118
128
  injector = make_pagination_injector(
119
129
  envelope=envelope,
@@ -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",
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from typing import Iterable, Sequence
4
+ from collections.abc import Iterable, Sequence
5
5
 
6
6
  from fastapi import FastAPI
7
7
  from pydantic import BaseModel, Field
@@ -64,7 +64,7 @@ class EasyAppOptions(BaseModel):
64
64
  observability: ObservabilityOptions = ObservabilityOptions()
65
65
 
66
66
  @classmethod
67
- def from_env(cls) -> "EasyAppOptions":
67
+ def from_env(cls) -> EasyAppOptions:
68
68
  """
69
69
  Build options from environment variables:
70
70
 
@@ -88,7 +88,7 @@ class EasyAppOptions(BaseModel):
88
88
  ),
89
89
  )
90
90
 
91
- def merged_with(self, override: "EasyAppOptions | None") -> "EasyAppOptions":
91
+ def merged_with(self, override: EasyAppOptions | None) -> EasyAppOptions:
92
92
  """
93
93
  Merge two option sets. Non-None fields in `override` win.
94
94
  (For iterables, if override provides a non-None value, it wins entirely.)
@@ -148,7 +148,33 @@ def easy_service_api(
148
148
  public_cors_origins: list[str] | str | None = None,
149
149
  root_public_base_url: str | None = None,
150
150
  root_include_api_key: bool | None = None,
151
+ skip_paths: list[str] | None = None,
152
+ **fastapi_kwargs, # Forward all other FastAPI kwargs
151
153
  ) -> FastAPI:
154
+ """
155
+ Create a FastAPI application with standard service configuration.
156
+
157
+ Args:
158
+ name: Service name for OpenAPI docs and logging.
159
+ release: Version string for the service.
160
+ versions: List of (tag, routers_package, public_base_url) tuples for API versioning.
161
+ root_routers: Router module(s) to mount at root level.
162
+ public_cors_origins: Origins to allow for CORS.
163
+ root_public_base_url: Public base URL for root-level routes.
164
+ root_include_api_key: Whether to include API key auth for root routes.
165
+ skip_paths: Path prefixes to exclude from timeout/rate-limit middleware.
166
+ Uses prefix matching: "/v1/chat" matches "/v1/chat" and "/v1/chat/stream"
167
+ but not "/api/v1/chat". Falls back to SKIP_MIDDLEWARE_PATHS env var.
168
+ **fastapi_kwargs: Additional kwargs passed to FastAPI constructor.
169
+
170
+ Returns:
171
+ Configured FastAPI application.
172
+ """
173
+ # Env fallback for skip_paths
174
+ effective_skip = (
175
+ skip_paths if skip_paths is not None else _env_csv_paths("SKIP_MIDDLEWARE_PATHS")
176
+ )
177
+
152
178
  service = ServiceInfo(name=name, release=release)
153
179
  specs = [
154
180
  APIVersionSpec(tag=str(tag), routers_package=pkg, public_base_url=base)
@@ -161,6 +187,8 @@ def easy_service_api(
161
187
  public_cors_origins=public_cors_origins,
162
188
  root_public_base_url=root_public_base_url,
163
189
  root_include_api_key=root_include_api_key,
190
+ skip_paths=effective_skip,
191
+ **fastapi_kwargs, # Forward to setup_service_api
164
192
  )
165
193
 
166
194
 
@@ -173,25 +201,47 @@ def easy_service_app(
173
201
  public_cors_origins: list[str] | str | None = None,
174
202
  root_public_base_url: str | None = None,
175
203
  root_include_api_key: bool | None = None,
204
+ skip_paths: list[str] | None = None,
176
205
  options: EasyAppOptions | None = None,
177
206
  enable_logging: bool | None = None,
178
207
  enable_observability: bool | None = None,
208
+ **fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
179
209
  ) -> FastAPI:
180
210
  """
181
- One-call bootstrap with env + options + flags:
182
-
183
- Precedence (strongest → weakest):
211
+ One-call bootstrap with env + options + flags.
212
+
213
+ Args:
214
+ name: Service name for OpenAPI docs and logging.
215
+ release: Version string for the service.
216
+ versions: List of (tag, routers_package, public_base_url) tuples for API versioning.
217
+ root_routers: Router module(s) to mount at root level.
218
+ public_cors_origins: Origins to allow for CORS.
219
+ root_public_base_url: Public base URL for root-level routes.
220
+ root_include_api_key: Whether to include API key auth for root routes.
221
+ skip_paths: Path prefixes to exclude from timeout/rate-limit middleware.
222
+ Uses prefix matching: "/v1/chat" matches "/v1/chat" and "/v1/chat/stream"
223
+ but not "/api/v1/chat". Falls back to SKIP_MIDDLEWARE_PATHS env var.
224
+ options: EasyAppOptions for logging/observability configuration.
225
+ enable_logging: Override to enable/disable logging.
226
+ enable_observability: Override to enable/disable observability.
227
+ **fastapi_kwargs: Additional kwargs passed to FastAPI constructor.
228
+
229
+ Precedence (strongest → weakest):
184
230
  1) enable_logging / enable_observability args
185
231
  2) `options=` object (per-field)
186
232
  3) `EasyAppOptions.from_env()`
187
233
 
188
- Env recognized:
234
+ Env recognized:
189
235
  ENABLE_LOGGING=true|false
190
236
  ENABLE_OBS=true|false
191
237
  LOG_LEVEL=DEBUG|INFO|...
192
238
  LOG_FORMAT=json|plain
193
239
  METRICS_PATH=/metrics
194
240
  OBS_SKIP_PATHS=/metrics,/health,/internal
241
+ SKIP_MIDDLEWARE_PATHS=/v1/chat,/v1/stream (for timeout/rate-limit skip)
242
+
243
+ Returns:
244
+ Configured FastAPI application with logging and observability.
195
245
  """
196
246
  # 0) Start from env
197
247
  env_opts = EasyAppOptions.from_env()
@@ -226,6 +276,8 @@ def easy_service_app(
226
276
  public_cors_origins=public_cors_origins,
227
277
  root_public_base_url=root_public_base_url,
228
278
  root_include_api_key=root_include_api_key,
279
+ skip_paths=skip_paths,
280
+ **fastapi_kwargs, # Forward FastAPI kwargs (lifespan, etc.)
229
281
  )
230
282
 
231
283
  # 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
  )
@@ -1,4 +1,4 @@
1
- from datetime import datetime, timezone
1
+ from datetime import UTC, datetime
2
2
  from email.utils import format_datetime, parsedate_to_datetime
3
3
  from hashlib import sha256
4
4
 
@@ -16,7 +16,7 @@ def set_conditional_headers(
16
16
  resp.headers["ETag"] = etag
17
17
  if last_modified:
18
18
  if last_modified.tzinfo is None:
19
- last_modified = last_modified.replace(tzinfo=timezone.utc)
19
+ last_modified = last_modified.replace(tzinfo=UTC)
20
20
  resp.headers["Last-Modified"] = format_datetime(last_modified)
21
21
 
22
22
 
@@ -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
@@ -1,6 +1,3 @@
1
- from typing import Optional
2
-
3
-
4
1
  class FastApiException(Exception):
5
2
  """
6
3
  Application error that should be rendered as Problem Details.
@@ -9,10 +6,10 @@ class FastApiException(Exception):
9
6
  def __init__(
10
7
  self,
11
8
  title: str,
12
- detail: Optional[str] = None,
9
+ detail: str | None = None,
13
10
  status_code: int = 400,
14
11
  *,
15
- code: str | None = None
12
+ code: str | None = None,
16
13
  ):
17
14
  self.title = title
18
15
  self.detail = detail
@@ -1,7 +1,8 @@
1
1
  import logging
2
2
  import traceback
3
- from typing import Any, Dict, Optional
3
+ from typing import Any
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
@@ -16,7 +17,7 @@ logger = logging.getLogger(__name__)
16
17
  PROBLEM_MT = "application/problem+json"
17
18
 
18
19
 
19
- def _trace_id_from_request(request: Request) -> Optional[str]:
20
+ def _trace_id_from_request(request: Request) -> str | None:
20
21
  # Try common headers first; fall back to None
21
22
  for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
22
23
  v = request.headers.get(h)
@@ -46,8 +47,9 @@ 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
- body: Dict[str, Any] = {
52
+ body: dict[str, Any] = {
51
53
  "type": type_uri,
52
54
  "title": title,
53
55
  "status": status,
@@ -62,10 +64,24 @@ def problem_response(
62
64
  body["errors"] = errors
63
65
  if trace_id:
64
66
  body["trace_id"] = trace_id
65
- return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT)
67
+ return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT, headers=headers)
66
68
 
67
69
 
68
70
  def register_error_handlers(app):
71
+ @app.exception_handler(httpx.TimeoutException)
72
+ async def handle_httpx_timeout(request: Request, exc: httpx.TimeoutException):
73
+ trace_id = _trace_id_from_request(request)
74
+ # Map outbound HTTP client timeouts to 504 Gateway Timeout
75
+ # Keep details generic in prod
76
+ return problem_response(
77
+ status=504,
78
+ title="Gateway Timeout",
79
+ detail=("Upstream request timed out." if IS_PROD else (str(exc) or "httpx timeout")),
80
+ code="GATEWAY_TIMEOUT",
81
+ instance=str(request.url),
82
+ trace_id=trace_id,
83
+ )
84
+
69
85
  @app.exception_handler(FastApiException)
70
86
  async def handle_app_exception(request: Request, exc: FastApiException):
71
87
  trace_id = _trace_id_from_request(request)
@@ -104,14 +120,26 @@ def register_error_handlers(app):
104
120
  @app.exception_handler(HTTPException)
105
121
  async def handle_http_exception(request: Request, exc: HTTPException):
106
122
  trace_id = _trace_id_from_request(request)
107
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
108
- exc.status_code, "Error"
109
- )
123
+ title = {
124
+ 401: "Unauthorized",
125
+ 403: "Forbidden",
126
+ 404: "Not Found",
127
+ 429: "Too Many Requests",
128
+ }.get(exc.status_code, "Error")
110
129
  detail = (
111
130
  exc.detail
112
131
  if not IS_PROD or exc.status_code < 500
113
132
  else "Something went wrong. Please contact support."
114
133
  )
134
+ # Preserve headers set on the exception (e.g., Retry-After for rate limits)
135
+ hdrs: dict[str, str] | None = None
136
+ try:
137
+ exc_headers = getattr(exc, "headers", None)
138
+ if exc_headers is not None:
139
+ # FastAPI/Starlette exceptions store headers as a dict[str, str]
140
+ hdrs = dict(exc_headers)
141
+ except Exception:
142
+ hdrs = None
115
143
  return problem_response(
116
144
  status=exc.status_code,
117
145
  title=title,
@@ -119,19 +147,30 @@ def register_error_handlers(app):
119
147
  code=title.replace(" ", "_").upper(),
120
148
  instance=str(request.url),
121
149
  trace_id=trace_id,
150
+ headers=hdrs,
122
151
  )
123
152
 
124
153
  @app.exception_handler(StarletteHTTPException)
125
154
  async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
126
155
  trace_id = _trace_id_from_request(request)
127
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
128
- exc.status_code, "Error"
129
- )
156
+ title = {
157
+ 401: "Unauthorized",
158
+ 403: "Forbidden",
159
+ 404: "Not Found",
160
+ 429: "Too Many Requests",
161
+ }.get(exc.status_code, "Error")
130
162
  detail = (
131
163
  exc.detail
132
164
  if not IS_PROD or exc.status_code < 500
133
165
  else "Something went wrong. Please contact support."
134
166
  )
167
+ hdrs: dict[str, str] | None = None
168
+ try:
169
+ exc_headers = getattr(exc, "headers", None)
170
+ if exc_headers is not None:
171
+ hdrs = dict(exc_headers)
172
+ except Exception:
173
+ hdrs = None
135
174
  return problem_response(
136
175
  status=exc.status_code,
137
176
  title=title,
@@ -139,6 +178,7 @@ def register_error_handlers(app):
139
178
  code=title.replace(" ", "_").upper(),
140
179
  instance=str(request.url),
141
180
  trace_id=trace_id,
181
+ headers=hdrs,
142
182
  )
143
183
 
144
184
  @app.exception_handler(IntegrityError)
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+
8
+ from fastapi import FastAPI
9
+ from starlette.types import ASGIApp, Receive, Scope, Send
10
+
11
+ from svc_infra.app.env import pick
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _get_grace_period_seconds() -> float:
17
+ default = pick(prod=20.0, nonprod=5.0)
18
+ raw = os.getenv("SHUTDOWN_GRACE_PERIOD_SECONDS")
19
+ if raw is None or raw == "":
20
+ return float(default)
21
+ try:
22
+ return float(raw)
23
+ except ValueError:
24
+ return float(default)
25
+
26
+
27
+ class InflightTrackerMiddleware:
28
+ """Tracks number of in-flight requests to support graceful shutdown drains."""
29
+
30
+ def __init__(self, app: ASGIApp):
31
+ self.app = app
32
+
33
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
34
+ if scope.get("type") != "http":
35
+ await self.app(scope, receive, send)
36
+ return
37
+ app = scope.get("app")
38
+ if app is None:
39
+ await self.app(scope, receive, send)
40
+ return
41
+ state = getattr(app, "state", None)
42
+ if state is None:
43
+ await self.app(scope, receive, send)
44
+ return
45
+ state._inflight_requests = getattr(state, "_inflight_requests", 0) + 1
46
+ try:
47
+ await self.app(scope, receive, send)
48
+ finally:
49
+ state._inflight_requests = max(0, getattr(state, "_inflight_requests", 1) - 1)
50
+
51
+
52
+ async def _wait_for_drain(app: FastAPI, grace: float) -> None:
53
+ interval = 0.1
54
+ waited = 0.0
55
+ while waited < grace:
56
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
57
+ if inflight <= 0:
58
+ return
59
+ await asyncio.sleep(interval)
60
+ waited += interval
61
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
62
+ if inflight > 0:
63
+ logger.warning(
64
+ "Graceful shutdown timeout: %s in-flight request(s) after %.2fs",
65
+ inflight,
66
+ waited,
67
+ )
68
+
69
+
70
+ def install_graceful_shutdown(app: FastAPI, *, grace_seconds: float | None = None) -> None:
71
+ """Install inflight tracking and lifespan hooks to wait for requests to drain.
72
+
73
+ - Adds InflightTrackerMiddleware
74
+ - Registers a lifespan handler that initializes state and waits up to grace_seconds on shutdown
75
+ """
76
+ app.add_middleware(InflightTrackerMiddleware)
77
+
78
+ g = float(grace_seconds) if grace_seconds is not None else _get_grace_period_seconds()
79
+
80
+ # Preserve any existing lifespan and wrap it so our drain runs on shutdown.
81
+ previous_lifespan = getattr(app.router, "lifespan_context", None)
82
+
83
+ @asynccontextmanager
84
+ async def _lifespan(a: FastAPI):
85
+ # Startup: initialize inflight counter
86
+ a.state._inflight_requests = 0
87
+ if previous_lifespan is not None:
88
+ async with previous_lifespan(a):
89
+ yield
90
+ else:
91
+ yield
92
+ # Shutdown: wait for in-flight requests to drain (up to grace period)
93
+ await _wait_for_drain(a, g)
94
+
95
+ app.router.lifespan_context = _lifespan