svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -1,22 +1,30 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Callable, Sequence
3
+ from typing import Callable
4
4
 
5
5
  from fastapi import APIRouter
6
- from fastapi.routing import APIRoute
7
6
 
8
7
  from .protected import protected_router, service_router, user_router
9
8
  from .public import public_router
10
9
  from .router import DualAPIRouter
11
- from .utils import _alt_with_slash, _norm_primary
12
10
 
13
11
 
14
12
  def dualize_into(
15
13
  src: APIRouter, dst_factory: Callable[..., DualAPIRouter], *, show_in_schema=True
16
14
  ) -> DualAPIRouter:
17
- """Clone routes from an APIRouter into a new DualAPIRouter created by `dst_factory`."""
15
+ """
16
+ Clone `src` into a DualAPIRouter without re-parsing the primary endpoints.
17
+
18
+ Strategy:
19
+ 1) Create an empty DualAPIRouter (prefix="").
20
+ 2) Include the original router `src` so the *original* APIRoute objects
21
+ (and their already-resolved request models) are used and shown in OpenAPI.
22
+ 3) Add *hidden* trailing-slash twins that point to the same endpoint callables.
23
+ These don’t show in OpenAPI, so re-parsing them is harmless.
24
+ """
25
+ # IMPORTANT: make a fresh router with NO prefix; we will include `src` with its own prefix.
18
26
  dst = dst_factory(
19
- prefix=src.prefix,
27
+ prefix="", # prevent double-prefixing on include_router
20
28
  tags=list(src.tags or []),
21
29
  dependencies=list(src.dependencies or []),
22
30
  default_response_class=src.default_response_class, # type: ignore[arg-type]
@@ -29,17 +37,37 @@ def dualize_into(
29
37
  on_shutdown=list(src.on_shutdown),
30
38
  )
31
39
 
40
+ # 1) Keep original routes *intact* (OpenAPI stays correct).
41
+ # We pass prefix=src.prefix so paths remain the same.
42
+ dst.include_router(
43
+ src,
44
+ prefix=src.prefix,
45
+ tags=src.tags,
46
+ include_in_schema=show_in_schema,
47
+ )
48
+
49
+ # 2) Add hidden trailing-slash twins (no OpenAPI).
50
+ from fastapi.routing import APIRoute
51
+
52
+ from .utils import _alt_with_slash, _norm_primary
53
+
32
54
  for r in src.routes:
33
55
  if not isinstance(r, APIRoute):
34
56
  continue
35
57
 
36
- methods: Sequence[str] = sorted(r.methods or [])
58
+ methods = sorted(r.methods or [])
37
59
  primary = _norm_primary(r.path)
38
60
  alt = _alt_with_slash(r.path)
39
61
 
40
- # visible primary (no trailing slash)
62
+ if alt == primary:
63
+ continue
64
+
65
+ # Build full path using the same prefix we used for include_router
66
+ alt_full = f"{src.prefix}{alt}"
67
+
68
+ # Add a hidden twin. Re-parsing here is okay because this route is not in the schema.
41
69
  dst.add_api_route(
42
- primary,
70
+ alt_full,
43
71
  r.endpoint,
44
72
  methods=list(methods),
45
73
  response_model=r.response_model,
@@ -51,37 +79,14 @@ def dualize_into(
51
79
  responses=r.responses,
52
80
  deprecated=r.deprecated,
53
81
  name=r.name,
54
- operation_id=r.operation_id,
82
+ operation_id=None,
55
83
  response_class=r.response_class,
56
84
  response_description=r.response_description,
57
85
  callbacks=r.callbacks,
58
86
  openapi_extra=r.openapi_extra,
59
- include_in_schema=show_in_schema,
87
+ include_in_schema=False,
60
88
  )
61
89
 
62
- # hidden twin (with trailing slash)
63
- if alt != primary:
64
- dst.add_api_route(
65
- alt,
66
- r.endpoint,
67
- methods=list(methods),
68
- response_model=r.response_model,
69
- status_code=r.status_code,
70
- tags=r.tags,
71
- dependencies=r.dependencies,
72
- summary=r.summary,
73
- description=r.description,
74
- responses=r.responses,
75
- deprecated=r.deprecated,
76
- name=r.name,
77
- operation_id=None,
78
- response_class=r.response_class,
79
- response_description=r.response_description,
80
- callbacks=r.callbacks,
81
- openapi_extra=r.openapi_extra,
82
- include_in_schema=False,
83
- )
84
-
85
90
  return dst
86
91
 
87
92
 
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable
3
+ from typing import Any, Callable, Type
4
4
 
5
5
  from fastapi import APIRouter
6
+ from fastapi.params import Depends
7
+ from pydantic import BaseModel
6
8
 
9
+ from ..pagination import Paginated, make_pagination_injector
7
10
  from .utils import _alt_with_slash, _norm_primary
8
11
 
9
12
 
@@ -85,6 +88,50 @@ class DualAPIRouter(APIRouter):
85
88
  def head(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
86
89
  return self._dual_decorator(path, ["HEAD"], show_in_schema=show_in_schema, **kwargs)
87
90
 
91
+ def list(
92
+ self,
93
+ path: str,
94
+ *,
95
+ model: Type[BaseModel],
96
+ envelope: bool = False,
97
+ cursor: bool = True,
98
+ page: bool = True,
99
+ default_limit: int = 50,
100
+ max_limit: int = 200,
101
+ show_in_schema: bool = True,
102
+ **kwargs: Any,
103
+ ):
104
+ """
105
+ Sugar for list endpoints.
106
+
107
+ - Auto-inject pagination/filter context (no Depends in your signature).
108
+ - Auto-picks response_model: list[model] or Paginated[model].
109
+ - Works with your OpenAPI mutators which already attach the shared params.
110
+ - Per-route opt-out of OpenAPI param auto-attach: openapi_extra={"x_no_auto_pagination": True}
111
+ """
112
+ # pick response model
113
+ if envelope:
114
+ response_model = Paginated[model] # type: ignore[index]
115
+ else:
116
+ response_model = list[model] # type: ignore[index]
117
+
118
+ injector = make_pagination_injector(
119
+ envelope=envelope,
120
+ allow_cursor=cursor,
121
+ allow_page=page,
122
+ default_limit=default_limit,
123
+ max_limit=max_limit,
124
+ )
125
+
126
+ # ensure our injector runs; don't mutate caller's dependencies
127
+ deps = list(kwargs.get("dependencies") or [])
128
+ deps.append(Depends(injector))
129
+ kwargs["dependencies"] = deps
130
+ kwargs["response_model"] = kwargs.get("response_model") or response_model
131
+
132
+ # we still want the dual-registration behavior
133
+ return self._dual_decorator(path, ["GET"], show_in_schema=show_in_schema, **kwargs)
134
+
88
135
  # ---------- WebSocket ----------
89
136
 
90
137
  def websocket(self, path: str, *_, **kwargs: Any):
@@ -7,7 +7,7 @@ Usage:
7
7
  easy_service_app, easy_service_api, EasyAppOptions, LoggingOptions, ObservabilityOptions,
8
8
 
9
9
  # Auth bootstrap
10
- add_auth, get_auth_settings, AuthSettings, AuthPolicy, DefaultAuthPolicy,
10
+ add_auth_users, get_auth_settings, AuthSettings, AuthPolicy, DefaultAuthPolicy,
11
11
 
12
12
  # Identity (endpoint params + router deps + guards)
13
13
  Principal, Identity, OptionalIdentity,
@@ -31,7 +31,7 @@ Usage:
31
31
  # ----------------
32
32
  # Auth bootstrap / config
33
33
  # ----------------
34
- from svc_infra.api.fastapi.auth.add import add_auth
34
+ from svc_infra.api.fastapi.auth.add import add_auth_users
35
35
  from svc_infra.api.fastapi.auth.mfa.router import mfa_router
36
36
  from svc_infra.api.fastapi.auth.mfa.security import RequireMFAIfEnabled
37
37
  from svc_infra.api.fastapi.auth.policy import AuthPolicy, DefaultAuthPolicy
@@ -102,7 +102,7 @@ __all__ = [
102
102
  "LoggingOptions",
103
103
  "ObservabilityOptions",
104
104
  # Auth bootstrap / config
105
- "add_auth",
105
+ "add_auth_users",
106
106
  "get_auth_settings",
107
107
  "AuthSettings",
108
108
  "AuthPolicy",
File without changes
@@ -0,0 +1,14 @@
1
+ from fastapi import HTTPException, Request, status
2
+
3
+
4
+ def require_if_match(request: Request, current_etag: str):
5
+ val = request.headers.get("If-Match")
6
+ if not val:
7
+ raise HTTPException(
8
+ status_code=status.HTTP_428_PRECONDITION_REQUIRED,
9
+ detail="If-Match header required for update.",
10
+ )
11
+ if current_etag not in [t.strip() for t in val.split(",")]:
12
+ raise HTTPException(
13
+ status_code=status.HTTP_412_PRECONDITION_FAILED, detail="ETag precondition failed."
14
+ )
@@ -0,0 +1,33 @@
1
+ from datetime import datetime, timezone
2
+ from email.utils import format_datetime, parsedate_to_datetime
3
+ from hashlib import sha256
4
+
5
+ from fastapi import Request, Response
6
+
7
+
8
+ def compute_etag(payload: bytes) -> str:
9
+ return '"' + sha256(payload).hexdigest() + '"'
10
+
11
+
12
+ def set_conditional_headers(
13
+ resp: Response, etag: str | None = None, last_modified: datetime | None = None
14
+ ):
15
+ if etag:
16
+ resp.headers["ETag"] = etag
17
+ if last_modified:
18
+ if last_modified.tzinfo is None:
19
+ last_modified = last_modified.replace(tzinfo=timezone.utc)
20
+ resp.headers["Last-Modified"] = format_datetime(last_modified)
21
+
22
+
23
+ def maybe_not_modified(request: Request, etag: str | None, last_modified: datetime | None) -> bool:
24
+ inm = request.headers.get("If-None-Match")
25
+ ims = request.headers.get("If-Modified-Since")
26
+ etag_ok = etag and inm and etag in [t.strip() for t in inm.split(",")]
27
+ time_ok = False
28
+ if last_modified and ims:
29
+ try:
30
+ time_ok = parsedate_to_datetime(ims) >= last_modified
31
+ except Exception:
32
+ pass
33
+ return bool(etag_ok or time_ok)
@@ -0,0 +1,21 @@
1
+ from functools import wraps
2
+
3
+
4
+ def deprecated(sunset_http_date: str | None = None, link: str | None = None):
5
+ def deco(handler):
6
+ @wraps(handler)
7
+ async def wrapper(*a, **kw):
8
+ resp = await handler(*a, **kw)
9
+ # starlette Response or FastAPI returns both OK
10
+ headers = getattr(resp, "headers", None)
11
+ if headers is not None:
12
+ headers.setdefault("Deprecation", "true")
13
+ if sunset_http_date:
14
+ headers.setdefault("Sunset", sunset_http_date)
15
+ if link:
16
+ headers.setdefault("Link", f'<{link}>; rel="deprecation"')
17
+ return resp
18
+
19
+ return wrapper
20
+
21
+ return deco
@@ -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,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,25 @@ 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
+ if getattr(exc, "headers", None):
138
+ # FastAPI/Starlette exceptions store headers as a dict[str, str]
139
+ hdrs = dict(getattr(exc, "headers")) # type: ignore[arg-type]
140
+ except Exception:
141
+ hdrs = None
115
142
  return problem_response(
116
143
  status=exc.status_code,
117
144
  title=title,
@@ -119,19 +146,29 @@ def register_error_handlers(app):
119
146
  code=title.replace(" ", "_").upper(),
120
147
  instance=str(request.url),
121
148
  trace_id=trace_id,
149
+ headers=hdrs,
122
150
  )
123
151
 
124
152
  @app.exception_handler(StarletteHTTPException)
125
153
  async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
126
154
  trace_id = _trace_id_from_request(request)
127
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
128
- exc.status_code, "Error"
129
- )
155
+ title = {
156
+ 401: "Unauthorized",
157
+ 403: "Forbidden",
158
+ 404: "Not Found",
159
+ 429: "Too Many Requests",
160
+ }.get(exc.status_code, "Error")
130
161
  detail = (
131
162
  exc.detail
132
163
  if not IS_PROD or exc.status_code < 500
133
164
  else "Something went wrong. Please contact support."
134
165
  )
166
+ hdrs: dict[str, str] | None = None
167
+ try:
168
+ if getattr(exc, "headers", None):
169
+ hdrs = dict(getattr(exc, "headers")) # type: ignore[arg-type]
170
+ except Exception:
171
+ hdrs = None
135
172
  return problem_response(
136
173
  status=exc.status_code,
137
174
  title=title,
@@ -139,6 +176,7 @@ def register_error_handlers(app):
139
176
  code=title.replace(" ", "_").upper(),
140
177
  instance=str(request.url),
141
178
  trace_id=trace_id,
179
+ headers=hdrs,
142
180
  )
143
181
 
144
182
  @app.exception_handler(IntegrityError)
@@ -0,0 +1,87 @@
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
+ state = scope.get("app").state # type: ignore[attr-defined]
39
+ state._inflight_requests = getattr(state, "_inflight_requests", 0) + 1
40
+ try:
41
+ await self.app(scope, receive, send)
42
+ finally:
43
+ state._inflight_requests = max(0, getattr(state, "_inflight_requests", 1) - 1)
44
+
45
+
46
+ async def _wait_for_drain(app: FastAPI, grace: float) -> None:
47
+ interval = 0.1
48
+ waited = 0.0
49
+ while waited < grace:
50
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
51
+ if inflight <= 0:
52
+ return
53
+ await asyncio.sleep(interval)
54
+ waited += interval
55
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
56
+ if inflight > 0:
57
+ logger.warning(
58
+ "Graceful shutdown timeout: %s in-flight request(s) after %.2fs", inflight, waited
59
+ )
60
+
61
+
62
+ def install_graceful_shutdown(app: FastAPI, *, grace_seconds: Optional[float] = None) -> None:
63
+ """Install inflight tracking and lifespan hooks to wait for requests to drain.
64
+
65
+ - Adds InflightTrackerMiddleware
66
+ - Registers a lifespan handler that initializes state and waits up to grace_seconds on shutdown
67
+ """
68
+ app.add_middleware(InflightTrackerMiddleware)
69
+
70
+ g = float(grace_seconds) if grace_seconds is not None else _get_grace_period_seconds()
71
+
72
+ # Preserve any existing lifespan and wrap it so our drain runs on shutdown.
73
+ previous_lifespan = getattr(app.router, "lifespan_context", None)
74
+
75
+ @asynccontextmanager
76
+ async def _lifespan(a: FastAPI): # noqa: ANN202
77
+ # Startup: initialize inflight counter
78
+ a.state._inflight_requests = 0
79
+ if previous_lifespan is not None:
80
+ async with previous_lifespan(a):
81
+ yield
82
+ else:
83
+ yield
84
+ # Shutdown: wait for in-flight requests to drain (up to grace period)
85
+ await _wait_for_drain(a, g)
86
+
87
+ app.router.lifespan_context = _lifespan
@@ -0,0 +1,116 @@
1
+ import base64
2
+ import hashlib
3
+ import time
4
+ from typing import Annotated, Dict, Optional
5
+
6
+ from fastapi import Header, HTTPException, Request
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.responses import JSONResponse, Response
9
+
10
+ from .idempotency_store import IdempotencyStore, InMemoryIdempotencyStore
11
+
12
+
13
+ class IdempotencyMiddleware(BaseHTTPMiddleware):
14
+ def __init__(
15
+ self,
16
+ app,
17
+ ttl_seconds: int = 24 * 3600,
18
+ store: Optional[IdempotencyStore] = None,
19
+ header_name: str = "Idempotency-Key",
20
+ ):
21
+ super().__init__(app)
22
+ self.ttl = ttl_seconds
23
+ self.store: IdempotencyStore = store or InMemoryIdempotencyStore()
24
+ self.header_name = header_name
25
+
26
+ def _cache_key(self, request, idkey: str):
27
+ # The cache key must NOT include the body to allow conflict detection for mismatched payloads.
28
+ sig = hashlib.sha256(
29
+ (request.method + "|" + request.url.path + "|" + idkey).encode()
30
+ ).hexdigest()
31
+ return f"idmp:{sig}"
32
+
33
+ async def dispatch(self, request, call_next):
34
+ if request.method in {"POST", "PATCH", "DELETE"}:
35
+ # read & buffer body once
36
+ body = await request.body()
37
+ request._body = body
38
+ idkey = request.headers.get(self.header_name)
39
+ if idkey:
40
+ k = self._cache_key(request, idkey)
41
+ now = time.time()
42
+ # build request hash to detect mismatched replays
43
+ req_hash = hashlib.sha256(body or b"").hexdigest()
44
+
45
+ existing = self.store.get(k)
46
+ if existing and existing.exp > now:
47
+ # If payload mismatches any existing claim, return conflict
48
+ if existing.req_hash and existing.req_hash != req_hash:
49
+ return JSONResponse(
50
+ status_code=409,
51
+ content={
52
+ "type": "about:blank",
53
+ "title": "Conflict",
54
+ "detail": "Idempotency-Key re-used with different request payload.",
55
+ },
56
+ )
57
+ # If response cached and payload matches, replay it
58
+ if existing.status is not None and existing.body_b64 is not None:
59
+ return Response(
60
+ content=base64.b64decode(existing.body_b64),
61
+ status_code=existing.status,
62
+ headers=existing.headers or {},
63
+ media_type=existing.media_type,
64
+ )
65
+
66
+ # Claim the key if not present
67
+ exp = now + self.ttl
68
+ created = self.store.set_initial(k, req_hash, exp)
69
+ if not created:
70
+ # Someone else claimed; re-check for conflict or replay
71
+ existing = self.store.get(k)
72
+ if existing and existing.req_hash and existing.req_hash != req_hash:
73
+ return JSONResponse(
74
+ status_code=409,
75
+ content={
76
+ "type": "about:blank",
77
+ "title": "Conflict",
78
+ "detail": "Idempotency-Key re-used with different request payload.",
79
+ },
80
+ )
81
+ if existing and existing.status is not None and existing.body_b64 is not None:
82
+ return Response(
83
+ content=base64.b64decode(existing.body_b64),
84
+ status_code=existing.status,
85
+ headers=existing.headers or {},
86
+ media_type=existing.media_type,
87
+ )
88
+
89
+ # Proceed to handler
90
+ resp = await call_next(request)
91
+ if 200 <= resp.status_code < 300:
92
+ body_bytes = b"".join([section async for section in resp.body_iterator])
93
+ headers: Dict[str, str] = dict(resp.headers)
94
+ self.store.set_response(
95
+ k,
96
+ status=resp.status_code,
97
+ body=body_bytes,
98
+ headers=headers,
99
+ media_type=resp.media_type,
100
+ )
101
+ return Response(
102
+ content=body_bytes,
103
+ status_code=resp.status_code,
104
+ headers=headers,
105
+ media_type=resp.media_type,
106
+ )
107
+ return resp
108
+ return await call_next(request)
109
+
110
+
111
+ async def require_idempotency_key(
112
+ idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
113
+ request: Request,
114
+ ) -> None:
115
+ if not idempotency_key.strip():
116
+ raise HTTPException(status_code=400, detail="Idempotency-Key must not be empty.")