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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/api/fastapi/dx.py
CHANGED
|
@@ -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
|
-
|
|
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",
|
svc_infra/api/fastapi/ease.py
CHANGED
|
@@ -104,9 +104,13 @@ class EasyAppOptions(BaseModel):
|
|
|
104
104
|
else self.logging.enable
|
|
105
105
|
),
|
|
106
106
|
level=(
|
|
107
|
-
override.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
|
|
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,
|
|
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(
|
|
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",
|
|
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(
|
|
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(
|
|
57
|
+
await send(
|
|
58
|
+
{"type": "http.response.body", "body": body, "more_body": False}
|
|
59
|
+
)
|
|
@@ -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(
|
|
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 = {
|
|
108
|
-
|
|
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(
|
|
159
|
+
async def handle_starlette_http_exception(
|
|
160
|
+
request: Request, exc: StarletteHTTPException
|
|
161
|
+
):
|
|
126
162
|
trace_id = _trace_id_from_request(request)
|
|
127
|
-
title = {
|
|
128
|
-
|
|
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.
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
40
|
-
if
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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(
|
|
211
|
+
raise HTTPException(
|
|
212
|
+
status_code=400, detail="Idempotency-Key must not be empty."
|
|
213
|
+
)
|