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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -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 +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -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 +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -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 +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -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 +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- 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 +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -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 +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -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 +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from fastapi import APIRouter
|
|
6
7
|
from fastapi.params import Depends
|
|
@@ -37,10 +38,14 @@ class DualAPIRouter(APIRouter):
|
|
|
37
38
|
if is_rootish:
|
|
38
39
|
# primary root
|
|
39
40
|
self.add_api_route(
|
|
40
|
-
"",
|
|
41
|
+
"",
|
|
42
|
+
func,
|
|
43
|
+
methods=methods,
|
|
44
|
+
include_in_schema=show_in_schema,
|
|
45
|
+
**kwargs,
|
|
41
46
|
)
|
|
42
47
|
# only add the "/" twin for *safe* methods
|
|
43
|
-
if
|
|
48
|
+
if {m.upper() for m in methods} <= safe_methods:
|
|
44
49
|
self.add_api_route(
|
|
45
50
|
"/", func, methods=methods, include_in_schema=False, **kwargs
|
|
46
51
|
)
|
|
@@ -48,7 +53,11 @@ class DualAPIRouter(APIRouter):
|
|
|
48
53
|
|
|
49
54
|
# non-root unchanged
|
|
50
55
|
self.add_api_route(
|
|
51
|
-
primary,
|
|
56
|
+
primary,
|
|
57
|
+
func,
|
|
58
|
+
methods=methods,
|
|
59
|
+
include_in_schema=show_in_schema,
|
|
60
|
+
**kwargs,
|
|
52
61
|
)
|
|
53
62
|
if alt != primary:
|
|
54
63
|
self.add_api_route(alt, func, methods=methods, include_in_schema=False, **kwargs)
|
|
@@ -57,7 +66,7 @@ class DualAPIRouter(APIRouter):
|
|
|
57
66
|
return decorator
|
|
58
67
|
|
|
59
68
|
def add_api_route(self, path, endpoint, **kwargs):
|
|
60
|
-
methods = set(
|
|
69
|
+
methods = set(kwargs.get("methods") or [])
|
|
61
70
|
for r in self.routes:
|
|
62
71
|
if getattr(r, "path", None) == path and methods & (
|
|
63
72
|
getattr(r, "methods", set()) or set()
|
|
@@ -92,7 +101,7 @@ class DualAPIRouter(APIRouter):
|
|
|
92
101
|
self,
|
|
93
102
|
path: str,
|
|
94
103
|
*,
|
|
95
|
-
model:
|
|
104
|
+
model: type[BaseModel],
|
|
96
105
|
envelope: bool = False,
|
|
97
106
|
cursor: bool = True,
|
|
98
107
|
page: bool = True,
|
|
@@ -110,10 +119,11 @@ class DualAPIRouter(APIRouter):
|
|
|
110
119
|
- Per-route opt-out of OpenAPI param auto-attach: openapi_extra={"x_no_auto_pagination": True}
|
|
111
120
|
"""
|
|
112
121
|
# pick response model
|
|
122
|
+
response_model: Any
|
|
113
123
|
if envelope:
|
|
114
|
-
response_model = Paginated[model] # type: ignore[
|
|
124
|
+
response_model = Paginated[model] # type: ignore[valid-type]
|
|
115
125
|
else:
|
|
116
|
-
response_model = list[model] # type: ignore[
|
|
126
|
+
response_model = list[model] # type: ignore[valid-type]
|
|
117
127
|
|
|
118
128
|
injector = make_pagination_injector(
|
|
119
129
|
envelope=envelope,
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
5
|
|
|
6
6
|
from fastapi import FastAPI
|
|
7
7
|
from pydantic import BaseModel, Field
|
|
@@ -64,7 +64,7 @@ class EasyAppOptions(BaseModel):
|
|
|
64
64
|
observability: ObservabilityOptions = ObservabilityOptions()
|
|
65
65
|
|
|
66
66
|
@classmethod
|
|
67
|
-
def from_env(cls) ->
|
|
67
|
+
def from_env(cls) -> EasyAppOptions:
|
|
68
68
|
"""
|
|
69
69
|
Build options from environment variables:
|
|
70
70
|
|
|
@@ -88,7 +88,7 @@ class EasyAppOptions(BaseModel):
|
|
|
88
88
|
),
|
|
89
89
|
)
|
|
90
90
|
|
|
91
|
-
def merged_with(self, override:
|
|
91
|
+
def merged_with(self, override: EasyAppOptions | None) -> EasyAppOptions:
|
|
92
92
|
"""
|
|
93
93
|
Merge two option sets. Non-None fields in `override` win.
|
|
94
94
|
(For iterables, if override provides a non-None value, it wins entirely.)
|
|
@@ -148,7 +148,33 @@ def easy_service_api(
|
|
|
148
148
|
public_cors_origins: list[str] | str | None = None,
|
|
149
149
|
root_public_base_url: str | None = None,
|
|
150
150
|
root_include_api_key: bool | None = None,
|
|
151
|
+
skip_paths: list[str] | None = None,
|
|
152
|
+
**fastapi_kwargs, # Forward all other FastAPI kwargs
|
|
151
153
|
) -> FastAPI:
|
|
154
|
+
"""
|
|
155
|
+
Create a FastAPI application with standard service configuration.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
name: Service name for OpenAPI docs and logging.
|
|
159
|
+
release: Version string for the service.
|
|
160
|
+
versions: List of (tag, routers_package, public_base_url) tuples for API versioning.
|
|
161
|
+
root_routers: Router module(s) to mount at root level.
|
|
162
|
+
public_cors_origins: Origins to allow for CORS.
|
|
163
|
+
root_public_base_url: Public base URL for root-level routes.
|
|
164
|
+
root_include_api_key: Whether to include API key auth for root routes.
|
|
165
|
+
skip_paths: Path prefixes to exclude from timeout/rate-limit middleware.
|
|
166
|
+
Uses prefix matching: "/v1/chat" matches "/v1/chat" and "/v1/chat/stream"
|
|
167
|
+
but not "/api/v1/chat". Falls back to SKIP_MIDDLEWARE_PATHS env var.
|
|
168
|
+
**fastapi_kwargs: Additional kwargs passed to FastAPI constructor.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Configured FastAPI application.
|
|
172
|
+
"""
|
|
173
|
+
# Env fallback for skip_paths
|
|
174
|
+
effective_skip = (
|
|
175
|
+
skip_paths if skip_paths is not None else _env_csv_paths("SKIP_MIDDLEWARE_PATHS")
|
|
176
|
+
)
|
|
177
|
+
|
|
152
178
|
service = ServiceInfo(name=name, release=release)
|
|
153
179
|
specs = [
|
|
154
180
|
APIVersionSpec(tag=str(tag), routers_package=pkg, public_base_url=base)
|
|
@@ -161,6 +187,8 @@ def easy_service_api(
|
|
|
161
187
|
public_cors_origins=public_cors_origins,
|
|
162
188
|
root_public_base_url=root_public_base_url,
|
|
163
189
|
root_include_api_key=root_include_api_key,
|
|
190
|
+
skip_paths=effective_skip,
|
|
191
|
+
**fastapi_kwargs, # Forward to setup_service_api
|
|
164
192
|
)
|
|
165
193
|
|
|
166
194
|
|
|
@@ -173,25 +201,47 @@ def easy_service_app(
|
|
|
173
201
|
public_cors_origins: list[str] | str | None = None,
|
|
174
202
|
root_public_base_url: str | None = None,
|
|
175
203
|
root_include_api_key: bool | None = None,
|
|
204
|
+
skip_paths: list[str] | None = None,
|
|
176
205
|
options: EasyAppOptions | None = None,
|
|
177
206
|
enable_logging: bool | None = None,
|
|
178
207
|
enable_observability: bool | None = None,
|
|
208
|
+
**fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
|
|
179
209
|
) -> FastAPI:
|
|
180
210
|
"""
|
|
181
|
-
One-call bootstrap with env + options + flags
|
|
182
|
-
|
|
183
|
-
|
|
211
|
+
One-call bootstrap with env + options + flags.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
name: Service name for OpenAPI docs and logging.
|
|
215
|
+
release: Version string for the service.
|
|
216
|
+
versions: List of (tag, routers_package, public_base_url) tuples for API versioning.
|
|
217
|
+
root_routers: Router module(s) to mount at root level.
|
|
218
|
+
public_cors_origins: Origins to allow for CORS.
|
|
219
|
+
root_public_base_url: Public base URL for root-level routes.
|
|
220
|
+
root_include_api_key: Whether to include API key auth for root routes.
|
|
221
|
+
skip_paths: Path prefixes to exclude from timeout/rate-limit middleware.
|
|
222
|
+
Uses prefix matching: "/v1/chat" matches "/v1/chat" and "/v1/chat/stream"
|
|
223
|
+
but not "/api/v1/chat". Falls back to SKIP_MIDDLEWARE_PATHS env var.
|
|
224
|
+
options: EasyAppOptions for logging/observability configuration.
|
|
225
|
+
enable_logging: Override to enable/disable logging.
|
|
226
|
+
enable_observability: Override to enable/disable observability.
|
|
227
|
+
**fastapi_kwargs: Additional kwargs passed to FastAPI constructor.
|
|
228
|
+
|
|
229
|
+
Precedence (strongest → weakest):
|
|
184
230
|
1) enable_logging / enable_observability args
|
|
185
231
|
2) `options=` object (per-field)
|
|
186
232
|
3) `EasyAppOptions.from_env()`
|
|
187
233
|
|
|
188
|
-
|
|
234
|
+
Env recognized:
|
|
189
235
|
ENABLE_LOGGING=true|false
|
|
190
236
|
ENABLE_OBS=true|false
|
|
191
237
|
LOG_LEVEL=DEBUG|INFO|...
|
|
192
238
|
LOG_FORMAT=json|plain
|
|
193
239
|
METRICS_PATH=/metrics
|
|
194
240
|
OBS_SKIP_PATHS=/metrics,/health,/internal
|
|
241
|
+
SKIP_MIDDLEWARE_PATHS=/v1/chat,/v1/stream (for timeout/rate-limit skip)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Configured FastAPI application with logging and observability.
|
|
195
245
|
"""
|
|
196
246
|
# 0) Start from env
|
|
197
247
|
env_opts = EasyAppOptions.from_env()
|
|
@@ -226,6 +276,8 @@ def easy_service_app(
|
|
|
226
276
|
public_cors_origins=public_cors_origins,
|
|
227
277
|
root_public_base_url=root_public_base_url,
|
|
228
278
|
root_include_api_key=root_include_api_key,
|
|
279
|
+
skip_paths=skip_paths,
|
|
280
|
+
**fastapi_kwargs, # Forward FastAPI kwargs (lifespan, etc.)
|
|
229
281
|
)
|
|
230
282
|
|
|
231
283
|
# 5) Observability
|
|
@@ -10,5 +10,6 @@ def require_if_match(request: Request, current_etag: str):
|
|
|
10
10
|
)
|
|
11
11
|
if current_etag not in [t.strip() for t in val.split(",")]:
|
|
12
12
|
raise HTTPException(
|
|
13
|
-
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
|
13
|
+
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
|
14
|
+
detail="ETag precondition failed.",
|
|
14
15
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datetime import
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
2
|
from email.utils import format_datetime, parsedate_to_datetime
|
|
3
3
|
from hashlib import sha256
|
|
4
4
|
|
|
@@ -16,7 +16,7 @@ def set_conditional_headers(
|
|
|
16
16
|
resp.headers["ETag"] = etag
|
|
17
17
|
if last_modified:
|
|
18
18
|
if last_modified.tzinfo is None:
|
|
19
|
-
last_modified = last_modified.replace(tzinfo=
|
|
19
|
+
last_modified = last_modified.replace(tzinfo=UTC)
|
|
20
20
|
resp.headers["Last-Modified"] = format_datetime(last_modified)
|
|
21
21
|
|
|
22
22
|
|
|
@@ -15,6 +15,9 @@ class RouteDebugMiddleware(BaseHTTPMiddleware):
|
|
|
15
15
|
route = request.scope.get("route")
|
|
16
16
|
ep = getattr(route, "endpoint", None) if route else None
|
|
17
17
|
log.info(
|
|
18
|
-
"MATCHED %s %s -> %s",
|
|
18
|
+
"MATCHED %s %s -> %s",
|
|
19
|
+
request.method,
|
|
20
|
+
request.url.path,
|
|
21
|
+
getattr(ep, "__name__", ep),
|
|
19
22
|
)
|
|
20
23
|
return response
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
from typing import Optional
|
|
2
|
-
|
|
3
|
-
|
|
4
1
|
class FastApiException(Exception):
|
|
5
2
|
"""
|
|
6
3
|
Application error that should be rendered as Problem Details.
|
|
@@ -9,10 +6,10 @@ class FastApiException(Exception):
|
|
|
9
6
|
def __init__(
|
|
10
7
|
self,
|
|
11
8
|
title: str,
|
|
12
|
-
detail:
|
|
9
|
+
detail: str | None = None,
|
|
13
10
|
status_code: int = 400,
|
|
14
11
|
*,
|
|
15
|
-
code: str | None = None
|
|
12
|
+
code: str | None = None,
|
|
16
13
|
):
|
|
17
14
|
self.title = title
|
|
18
15
|
self.detail = detail
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import traceback
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any
|
|
4
4
|
|
|
5
|
+
import httpx
|
|
5
6
|
from fastapi import Request
|
|
6
7
|
from fastapi.exceptions import HTTPException, RequestValidationError
|
|
7
8
|
from fastapi.responses import JSONResponse, Response
|
|
@@ -16,7 +17,7 @@ logger = logging.getLogger(__name__)
|
|
|
16
17
|
PROBLEM_MT = "application/problem+json"
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
def _trace_id_from_request(request: Request) ->
|
|
20
|
+
def _trace_id_from_request(request: Request) -> str | None:
|
|
20
21
|
# Try common headers first; fall back to None
|
|
21
22
|
for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
|
|
22
23
|
v = request.headers.get(h)
|
|
@@ -46,8 +47,9 @@ def problem_response(
|
|
|
46
47
|
code: str | None = None,
|
|
47
48
|
errors: list[dict] | None = None,
|
|
48
49
|
trace_id: str | None = None,
|
|
50
|
+
headers: dict[str, str] | None = None,
|
|
49
51
|
) -> Response:
|
|
50
|
-
body:
|
|
52
|
+
body: dict[str, Any] = {
|
|
51
53
|
"type": type_uri,
|
|
52
54
|
"title": title,
|
|
53
55
|
"status": status,
|
|
@@ -62,10 +64,24 @@ def problem_response(
|
|
|
62
64
|
body["errors"] = errors
|
|
63
65
|
if trace_id:
|
|
64
66
|
body["trace_id"] = trace_id
|
|
65
|
-
return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT)
|
|
67
|
+
return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT, headers=headers)
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def register_error_handlers(app):
|
|
71
|
+
@app.exception_handler(httpx.TimeoutException)
|
|
72
|
+
async def handle_httpx_timeout(request: Request, exc: httpx.TimeoutException):
|
|
73
|
+
trace_id = _trace_id_from_request(request)
|
|
74
|
+
# Map outbound HTTP client timeouts to 504 Gateway Timeout
|
|
75
|
+
# Keep details generic in prod
|
|
76
|
+
return problem_response(
|
|
77
|
+
status=504,
|
|
78
|
+
title="Gateway Timeout",
|
|
79
|
+
detail=("Upstream request timed out." if IS_PROD else (str(exc) or "httpx timeout")),
|
|
80
|
+
code="GATEWAY_TIMEOUT",
|
|
81
|
+
instance=str(request.url),
|
|
82
|
+
trace_id=trace_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
69
85
|
@app.exception_handler(FastApiException)
|
|
70
86
|
async def handle_app_exception(request: Request, exc: FastApiException):
|
|
71
87
|
trace_id = _trace_id_from_request(request)
|
|
@@ -104,14 +120,26 @@ def register_error_handlers(app):
|
|
|
104
120
|
@app.exception_handler(HTTPException)
|
|
105
121
|
async def handle_http_exception(request: Request, exc: HTTPException):
|
|
106
122
|
trace_id = _trace_id_from_request(request)
|
|
107
|
-
title = {
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
title = {
|
|
124
|
+
401: "Unauthorized",
|
|
125
|
+
403: "Forbidden",
|
|
126
|
+
404: "Not Found",
|
|
127
|
+
429: "Too Many Requests",
|
|
128
|
+
}.get(exc.status_code, "Error")
|
|
110
129
|
detail = (
|
|
111
130
|
exc.detail
|
|
112
131
|
if not IS_PROD or exc.status_code < 500
|
|
113
132
|
else "Something went wrong. Please contact support."
|
|
114
133
|
)
|
|
134
|
+
# Preserve headers set on the exception (e.g., Retry-After for rate limits)
|
|
135
|
+
hdrs: dict[str, str] | None = None
|
|
136
|
+
try:
|
|
137
|
+
exc_headers = getattr(exc, "headers", None)
|
|
138
|
+
if exc_headers is not None:
|
|
139
|
+
# FastAPI/Starlette exceptions store headers as a dict[str, str]
|
|
140
|
+
hdrs = dict(exc_headers)
|
|
141
|
+
except Exception:
|
|
142
|
+
hdrs = None
|
|
115
143
|
return problem_response(
|
|
116
144
|
status=exc.status_code,
|
|
117
145
|
title=title,
|
|
@@ -119,19 +147,30 @@ def register_error_handlers(app):
|
|
|
119
147
|
code=title.replace(" ", "_").upper(),
|
|
120
148
|
instance=str(request.url),
|
|
121
149
|
trace_id=trace_id,
|
|
150
|
+
headers=hdrs,
|
|
122
151
|
)
|
|
123
152
|
|
|
124
153
|
@app.exception_handler(StarletteHTTPException)
|
|
125
154
|
async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
|
|
126
155
|
trace_id = _trace_id_from_request(request)
|
|
127
|
-
title = {
|
|
128
|
-
|
|
129
|
-
|
|
156
|
+
title = {
|
|
157
|
+
401: "Unauthorized",
|
|
158
|
+
403: "Forbidden",
|
|
159
|
+
404: "Not Found",
|
|
160
|
+
429: "Too Many Requests",
|
|
161
|
+
}.get(exc.status_code, "Error")
|
|
130
162
|
detail = (
|
|
131
163
|
exc.detail
|
|
132
164
|
if not IS_PROD or exc.status_code < 500
|
|
133
165
|
else "Something went wrong. Please contact support."
|
|
134
166
|
)
|
|
167
|
+
hdrs: dict[str, str] | None = None
|
|
168
|
+
try:
|
|
169
|
+
exc_headers = getattr(exc, "headers", None)
|
|
170
|
+
if exc_headers is not None:
|
|
171
|
+
hdrs = dict(exc_headers)
|
|
172
|
+
except Exception:
|
|
173
|
+
hdrs = None
|
|
135
174
|
return problem_response(
|
|
136
175
|
status=exc.status_code,
|
|
137
176
|
title=title,
|
|
@@ -139,6 +178,7 @@ def register_error_handlers(app):
|
|
|
139
178
|
code=title.replace(" ", "_").upper(),
|
|
140
179
|
instance=str(request.url),
|
|
141
180
|
trace_id=trace_id,
|
|
181
|
+
headers=hdrs,
|
|
142
182
|
)
|
|
143
183
|
|
|
144
184
|
@app.exception_handler(IntegrityError)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
10
|
+
|
|
11
|
+
from svc_infra.app.env import pick
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_grace_period_seconds() -> float:
|
|
17
|
+
default = pick(prod=20.0, nonprod=5.0)
|
|
18
|
+
raw = os.getenv("SHUTDOWN_GRACE_PERIOD_SECONDS")
|
|
19
|
+
if raw is None or raw == "":
|
|
20
|
+
return float(default)
|
|
21
|
+
try:
|
|
22
|
+
return float(raw)
|
|
23
|
+
except ValueError:
|
|
24
|
+
return float(default)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InflightTrackerMiddleware:
|
|
28
|
+
"""Tracks number of in-flight requests to support graceful shutdown drains."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, app: ASGIApp):
|
|
31
|
+
self.app = app
|
|
32
|
+
|
|
33
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
34
|
+
if scope.get("type") != "http":
|
|
35
|
+
await self.app(scope, receive, send)
|
|
36
|
+
return
|
|
37
|
+
app = scope.get("app")
|
|
38
|
+
if app is None:
|
|
39
|
+
await self.app(scope, receive, send)
|
|
40
|
+
return
|
|
41
|
+
state = getattr(app, "state", None)
|
|
42
|
+
if state is None:
|
|
43
|
+
await self.app(scope, receive, send)
|
|
44
|
+
return
|
|
45
|
+
state._inflight_requests = getattr(state, "_inflight_requests", 0) + 1
|
|
46
|
+
try:
|
|
47
|
+
await self.app(scope, receive, send)
|
|
48
|
+
finally:
|
|
49
|
+
state._inflight_requests = max(0, getattr(state, "_inflight_requests", 1) - 1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _wait_for_drain(app: FastAPI, grace: float) -> None:
|
|
53
|
+
interval = 0.1
|
|
54
|
+
waited = 0.0
|
|
55
|
+
while waited < grace:
|
|
56
|
+
inflight = int(getattr(app.state, "_inflight_requests", 0))
|
|
57
|
+
if inflight <= 0:
|
|
58
|
+
return
|
|
59
|
+
await asyncio.sleep(interval)
|
|
60
|
+
waited += interval
|
|
61
|
+
inflight = int(getattr(app.state, "_inflight_requests", 0))
|
|
62
|
+
if inflight > 0:
|
|
63
|
+
logger.warning(
|
|
64
|
+
"Graceful shutdown timeout: %s in-flight request(s) after %.2fs",
|
|
65
|
+
inflight,
|
|
66
|
+
waited,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def install_graceful_shutdown(app: FastAPI, *, grace_seconds: float | None = None) -> None:
|
|
71
|
+
"""Install inflight tracking and lifespan hooks to wait for requests to drain.
|
|
72
|
+
|
|
73
|
+
- Adds InflightTrackerMiddleware
|
|
74
|
+
- Registers a lifespan handler that initializes state and waits up to grace_seconds on shutdown
|
|
75
|
+
"""
|
|
76
|
+
app.add_middleware(InflightTrackerMiddleware)
|
|
77
|
+
|
|
78
|
+
g = float(grace_seconds) if grace_seconds is not None else _get_grace_period_seconds()
|
|
79
|
+
|
|
80
|
+
# Preserve any existing lifespan and wrap it so our drain runs on shutdown.
|
|
81
|
+
previous_lifespan = getattr(app.router, "lifespan_context", None)
|
|
82
|
+
|
|
83
|
+
@asynccontextmanager
|
|
84
|
+
async def _lifespan(a: FastAPI):
|
|
85
|
+
# Startup: initialize inflight counter
|
|
86
|
+
a.state._inflight_requests = 0
|
|
87
|
+
if previous_lifespan is not None:
|
|
88
|
+
async with previous_lifespan(a):
|
|
89
|
+
yield
|
|
90
|
+
else:
|
|
91
|
+
yield
|
|
92
|
+
# Shutdown: wait for in-flight requests to drain (up to grace period)
|
|
93
|
+
await _wait_for_drain(a, g)
|
|
94
|
+
|
|
95
|
+
app.router.lifespan_context = _lifespan
|