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,11 +1,36 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
4
+ import os
3
5
  import time
4
- from typing import Optional, Protocol, Tuple
6
+ import warnings
7
+ from collections.abc import Callable
8
+ from typing import Protocol
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _INMEMORY_WARNED = False
13
+
14
+
15
+ def _check_inmemory_production_warning(class_name: str) -> None:
16
+ """Warn if in-memory store is used in production."""
17
+ global _INMEMORY_WARNED
18
+ if _INMEMORY_WARNED:
19
+ return
20
+ env = os.getenv("ENV", "development").lower()
21
+ if env in ("production", "staging", "prod"):
22
+ _INMEMORY_WARNED = True
23
+ msg = (
24
+ f"{class_name} is being used in {env} environment. "
25
+ "This is NOT suitable for production - data will be lost on restart. "
26
+ "Use RedisRateLimitStore instead."
27
+ )
28
+ warnings.warn(msg, RuntimeWarning, stacklevel=3)
29
+ logger.critical(msg)
5
30
 
6
31
 
7
32
  class RateLimitStore(Protocol):
8
- def incr(self, key: str, window: int) -> Tuple[int, int, int]:
33
+ def incr(self, key: str, window: int) -> tuple[int, int, int]:
9
34
  """Increment and return (count, limit, resetEpoch).
10
35
 
11
36
  Implementations should manage per-window buckets. The 'limit' is stored configuration.
@@ -15,15 +40,22 @@ class RateLimitStore(Protocol):
15
40
 
16
41
  class InMemoryRateLimitStore:
17
42
  def __init__(self, limit: int = 120):
43
+ _check_inmemory_production_warning("InMemoryRateLimitStore")
18
44
  self.limit = limit
19
- self._buckets: dict[tuple[str, int], int] = {}
20
-
21
- def incr(self, key: str, window: int) -> Tuple[int, int, int]:
22
- now = int(time.time())
23
- win = now - (now % window)
24
- count = self._buckets.get((key, win), 0) + 1
25
- self._buckets[(key, win)] = count
26
- reset = win + window
45
+ # Track per-key rolling windows: key -> (count, window_start_epoch)
46
+ self._state: dict[str, tuple[int, float]] = {}
47
+
48
+ def incr(self, key: str, window: int) -> tuple[int, int, int]:
49
+ now = time.time()
50
+ count, window_start = self._state.get(key, (0, now))
51
+ # If outside the rolling window, reset
52
+ if now >= window_start + window:
53
+ count = 1
54
+ window_start = now
55
+ else:
56
+ count += 1
57
+ self._state[key] = (count, window_start)
58
+ reset = int(window_start + window)
27
59
  return count, self.limit, reset
28
60
 
29
61
 
@@ -43,20 +75,20 @@ class RedisRateLimitStore:
43
75
  *,
44
76
  limit: int = 120,
45
77
  prefix: str = "ratelimit",
46
- clock: Optional[callable] = None,
78
+ clock: Callable[[], float] | None = None,
47
79
  ):
48
80
  self.redis = redis_client
49
81
  self.limit = limit
50
82
  self.prefix = prefix
51
83
  self._clock = clock or time.time
52
84
 
53
- def _window_key(self, key: str, window: int) -> tuple[str, int, str]:
85
+ def _window_key(self, key: str, window: int) -> tuple[str, int, int]:
54
86
  now = int(self._clock())
55
87
  win = now - (now % window)
56
88
  redis_key = f"{self.prefix}:{key}:{win}"
57
89
  return redis_key, win, now
58
90
 
59
- def incr(self, key: str, window: int) -> Tuple[int, int, int]:
91
+ def incr(self, key: str, window: int) -> tuple[int, int, int]:
60
92
  rkey, win, now = self._window_key(key, window)
61
93
  # Increment; if this is the first time we've seen this window key, set expiry to window end
62
94
  pipe = self.redis.pipeline()
@@ -1,23 +1,37 @@
1
1
  import contextvars
2
2
  from uuid import uuid4
3
3
 
4
- from starlette.middleware.base import BaseHTTPMiddleware
5
- from starlette.types import ASGIApp
4
+ from starlette.datastructures import Headers, MutableHeaders
5
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
6
6
 
7
7
  request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="")
8
8
 
9
9
 
10
- class RequestIdMiddleware(BaseHTTPMiddleware):
10
+ class RequestIdMiddleware:
11
+ """Pure ASGI middleware that adds request IDs. Compatible with streaming responses."""
12
+
11
13
  def __init__(self, app: ASGIApp, header_name: str = "X-Request-Id"):
12
- super().__init__(app)
13
- self.header_name = header_name
14
+ self.app = app
15
+ self.header_name = header_name.lower()
16
+
17
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
18
+ if scope["type"] != "http":
19
+ await self.app(scope, receive, send)
20
+ return
14
21
 
15
- async def dispatch(self, request, call_next):
16
- rid = request.headers.get(self.header_name) or uuid4().hex
22
+ # Extract or generate request ID
23
+ headers = Headers(scope=scope)
24
+ rid = headers.get(self.header_name) or uuid4().hex
17
25
  token = request_id_ctx.set(rid)
26
+
27
+ async def send_with_request_id(message: Message) -> None:
28
+ if message["type"] == "http.response.start":
29
+ # Add request ID to response headers
30
+ response_headers = MutableHeaders(scope=message)
31
+ response_headers.append(self.header_name, rid)
32
+ await send(message)
33
+
18
34
  try:
19
- resp = await call_next(request)
20
- resp.headers[self.header_name] = rid
21
- return resp
35
+ await self.app(scope, receive, send_with_request_id)
22
36
  finally:
23
37
  request_id_ctx.reset(token)
@@ -19,9 +19,9 @@ class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
19
19
  size = None
20
20
  if size is not None and size > self.max_bytes:
21
21
  try:
22
- emit_suspect_payload(
23
- getattr(request, "url", None).path if hasattr(request, "url") else None, size
24
- )
22
+ url = getattr(request, "url", None)
23
+ path = url.path if url is not None else None
24
+ emit_suspect_payload(path, size)
25
25
  except Exception:
26
26
  pass
27
27
  return JSONResponse(
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from typing import Any
6
+
7
+ from fastapi import Request
8
+ from starlette.types import ASGIApp, Receive, Scope, Send
9
+
10
+ from svc_infra.api.fastapi.middleware.errors.handlers import problem_response
11
+ from svc_infra.app.env import pick
12
+
13
+
14
+ def _env_int(name: str, default: int) -> int:
15
+ v = os.getenv(name)
16
+ if v is None:
17
+ return default
18
+ try:
19
+ return int(v)
20
+ except Exception:
21
+ return default
22
+
23
+
24
+ REQUEST_BODY_TIMEOUT_SECONDS: int = pick(
25
+ prod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 15),
26
+ nonprod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 30),
27
+ )
28
+ REQUEST_TIMEOUT_SECONDS: int = pick(
29
+ prod=_env_int("REQUEST_TIMEOUT_SECONDS", 30),
30
+ nonprod=_env_int("REQUEST_TIMEOUT_SECONDS", 15),
31
+ )
32
+
33
+
34
+ class HandlerTimeoutMiddleware:
35
+ """
36
+ Caps total handler execution time. If exceeded, returns 504 Problem+JSON.
37
+
38
+ Use skip_paths for endpoints that may run longer than the timeout
39
+ (e.g., streaming responses, long-polling, file uploads).
40
+
41
+ Matching uses prefix matching: "/v1/chat" matches "/v1/chat", "/v1/chat/stream",
42
+ but not "/api/v1/chat" or "/v1/chatter".
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ app: ASGIApp,
48
+ timeout_seconds: int | None = None,
49
+ skip_paths: list[str] | None = None,
50
+ ) -> None:
51
+ self.app = app
52
+ self.timeout_seconds = (
53
+ timeout_seconds if timeout_seconds is not None else REQUEST_TIMEOUT_SECONDS
54
+ )
55
+ self.skip_paths = skip_paths or []
56
+
57
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
58
+ if scope.get("type") != "http":
59
+ await self.app(scope, receive, send)
60
+ return
61
+
62
+ path = scope.get("path", "")
63
+
64
+ # Skip specified paths using prefix matching (e.g., long-running endpoints)
65
+ if any(path.startswith(skip) for skip in self.skip_paths):
66
+ await self.app(scope, receive, send)
67
+ return
68
+
69
+ # Track if response has started (headers sent)
70
+ response_started = False
71
+
72
+ async def send_wrapper(message: dict) -> None:
73
+ nonlocal response_started
74
+ if message.get("type") == "http.response.start":
75
+ response_started = True
76
+ await send(message)
77
+
78
+ try:
79
+ await asyncio.wait_for(
80
+ self.app(scope, receive, send_wrapper), # type: ignore[arg-type] # ASGI send signature
81
+ timeout=self.timeout_seconds,
82
+ )
83
+ except TimeoutError:
84
+ # Only send 504 if response hasn't started yet
85
+ if not response_started:
86
+ response = problem_response(
87
+ status=504,
88
+ title="Gateway Timeout",
89
+ detail=f"Handler did not complete within {self.timeout_seconds}s",
90
+ )
91
+ await response(scope, receive, send)
92
+ # If response already started, we can't change it - just let it fail
93
+
94
+
95
+ class BodyReadTimeoutMiddleware:
96
+ """
97
+ Enforces a timeout while reading the request body to mitigate slowloris.
98
+ If body read does not make progress within the timeout, returns 408 Problem+JSON.
99
+ """
100
+
101
+ def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
102
+ self.app = app
103
+ self.timeout_seconds = (
104
+ timeout_seconds if timeout_seconds is not None else REQUEST_BODY_TIMEOUT_SECONDS
105
+ )
106
+
107
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
108
+ if scope.get("type") != "http":
109
+ await self.app(scope, receive, send)
110
+ return
111
+
112
+ # Strategy: greedily drain the incoming request body here while enforcing
113
+ # per-receive timeout, then replay it to the downstream app from a buffer.
114
+ # This ensures we can detect slowloris-style uploads even if the app only
115
+ # reads the body later (after the server has finished buffering).
116
+ buffered = bytearray()
117
+
118
+ try:
119
+ while True:
120
+ message = await asyncio.wait_for(receive(), timeout=self.timeout_seconds)
121
+
122
+ mtype = message.get("type")
123
+ if mtype == "http.request":
124
+ chunk = message.get("body", b"") or b""
125
+ if chunk:
126
+ buffered.extend(chunk)
127
+ # Stop when server indicates no more body
128
+ if not message.get("more_body", False):
129
+ break
130
+ # else: continue reading remaining chunks with timeout
131
+ continue
132
+
133
+ if mtype == "http.disconnect": # client disconnected mid-upload
134
+ # Treat as end of body for the purposes of replay; downstream
135
+ # will see an empty body. No timeout response needed here.
136
+ break
137
+ # Ignore other message types and continue
138
+ except TimeoutError:
139
+ # Timed out while waiting for the next body chunk → return 408
140
+ request = Request(scope, receive=receive)
141
+ trace_id = None
142
+ for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
143
+ v = request.headers.get(h)
144
+ if v:
145
+ trace_id = v
146
+ break
147
+ resp = problem_response(
148
+ status=408,
149
+ title="Request Timeout",
150
+ detail="Timed out while reading request body.",
151
+ code="REQUEST_TIMEOUT",
152
+ instance=str(request.url),
153
+ trace_id=trace_id,
154
+ )
155
+ await resp(scope, receive, send)
156
+ return
157
+
158
+ # Replay the drained body to the app as a single http.request message.
159
+ # IMPORTANT: After replaying the body, we must forward the original receive()
160
+ # so that Starlette's listen_for_disconnect can properly detect client disconnects.
161
+ # This is required for streaming responses on ASGI spec < 2.4.
162
+ body_sent = False
163
+
164
+ async def _replay_receive() -> dict[str, Any]:
165
+ nonlocal body_sent
166
+ if not body_sent:
167
+ body_sent = True
168
+ return {
169
+ "type": "http.request",
170
+ "body": bytes(buffered),
171
+ "more_body": False,
172
+ }
173
+ # After body is sent, forward to original receive for disconnect detection
174
+ return dict(await receive())
175
+
176
+ await self.app(scope, _replay_receive, send)