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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
9
|
+
|
|
10
|
+
from svc_infra.api.fastapi.middleware.errors.handlers import problem_response
|
|
11
|
+
from svc_infra.app.env import pick
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _env_int(name: str, default: int) -> int:
|
|
15
|
+
v = os.getenv(name)
|
|
16
|
+
if v is None:
|
|
17
|
+
return default
|
|
18
|
+
try:
|
|
19
|
+
return int(v)
|
|
20
|
+
except Exception:
|
|
21
|
+
return default
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
REQUEST_BODY_TIMEOUT_SECONDS: int = pick(
|
|
25
|
+
prod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 15),
|
|
26
|
+
nonprod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 30),
|
|
27
|
+
)
|
|
28
|
+
REQUEST_TIMEOUT_SECONDS: int = pick(
|
|
29
|
+
prod=_env_int("REQUEST_TIMEOUT_SECONDS", 30),
|
|
30
|
+
nonprod=_env_int("REQUEST_TIMEOUT_SECONDS", 15),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HandlerTimeoutMiddleware:
|
|
35
|
+
"""
|
|
36
|
+
Caps total handler execution time. If exceeded, returns 504 Problem+JSON.
|
|
37
|
+
|
|
38
|
+
Use skip_paths for endpoints that may run longer than the timeout
|
|
39
|
+
(e.g., streaming responses, long-polling, file uploads).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
app: ASGIApp,
|
|
45
|
+
timeout_seconds: int | None = None,
|
|
46
|
+
skip_paths: list[str] | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.app = app
|
|
49
|
+
self.timeout_seconds = (
|
|
50
|
+
timeout_seconds if timeout_seconds is not None else REQUEST_TIMEOUT_SECONDS
|
|
51
|
+
)
|
|
52
|
+
self.skip_paths = skip_paths or []
|
|
53
|
+
|
|
54
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
55
|
+
if scope.get("type") != "http":
|
|
56
|
+
await self.app(scope, receive, send)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
path = scope.get("path", "")
|
|
60
|
+
|
|
61
|
+
# Skip specified paths (e.g., long-running endpoints)
|
|
62
|
+
if any(skip in path for skip in self.skip_paths):
|
|
63
|
+
await self.app(scope, receive, send)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Track if response has started (headers sent)
|
|
67
|
+
response_started = False
|
|
68
|
+
|
|
69
|
+
async def send_wrapper(message: dict) -> None:
|
|
70
|
+
nonlocal response_started
|
|
71
|
+
if message.get("type") == "http.response.start":
|
|
72
|
+
response_started = True
|
|
73
|
+
await send(message)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
await asyncio.wait_for(
|
|
77
|
+
self.app(scope, receive, send_wrapper), # type: ignore[arg-type] # ASGI send signature
|
|
78
|
+
timeout=self.timeout_seconds,
|
|
79
|
+
)
|
|
80
|
+
except asyncio.TimeoutError:
|
|
81
|
+
# Only send 504 if response hasn't started yet
|
|
82
|
+
if not response_started:
|
|
83
|
+
response = problem_response(
|
|
84
|
+
status=504,
|
|
85
|
+
title="Gateway Timeout",
|
|
86
|
+
detail=f"Handler did not complete within {self.timeout_seconds}s",
|
|
87
|
+
)
|
|
88
|
+
await response(scope, receive, send)
|
|
89
|
+
# If response already started, we can't change it - just let it fail
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BodyReadTimeoutMiddleware:
|
|
93
|
+
"""
|
|
94
|
+
Enforces a timeout while reading the request body to mitigate slowloris.
|
|
95
|
+
If body read does not make progress within the timeout, returns 408 Problem+JSON.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
|
|
99
|
+
self.app = app
|
|
100
|
+
self.timeout_seconds = (
|
|
101
|
+
timeout_seconds
|
|
102
|
+
if timeout_seconds is not None
|
|
103
|
+
else REQUEST_BODY_TIMEOUT_SECONDS
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
107
|
+
if scope.get("type") != "http":
|
|
108
|
+
await self.app(scope, receive, send)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Strategy: greedily drain the incoming request body here while enforcing
|
|
112
|
+
# per-receive timeout, then replay it to the downstream app from a buffer.
|
|
113
|
+
# This ensures we can detect slowloris-style uploads even if the app only
|
|
114
|
+
# reads the body later (after the server has finished buffering).
|
|
115
|
+
buffered = bytearray()
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
while True:
|
|
119
|
+
message = await asyncio.wait_for(
|
|
120
|
+
receive(), timeout=self.timeout_seconds
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
mtype = message.get("type")
|
|
124
|
+
if mtype == "http.request":
|
|
125
|
+
chunk = message.get("body", b"") or b""
|
|
126
|
+
if chunk:
|
|
127
|
+
buffered.extend(chunk)
|
|
128
|
+
# Stop when server indicates no more body
|
|
129
|
+
if not message.get("more_body", False):
|
|
130
|
+
break
|
|
131
|
+
# else: continue reading remaining chunks with timeout
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
if mtype == "http.disconnect": # client disconnected mid-upload
|
|
135
|
+
# Treat as end of body for the purposes of replay; downstream
|
|
136
|
+
# will see an empty body. No timeout response needed here.
|
|
137
|
+
break
|
|
138
|
+
# Ignore other message types and continue
|
|
139
|
+
except asyncio.TimeoutError:
|
|
140
|
+
# Timed out while waiting for the next body chunk → return 408
|
|
141
|
+
request = Request(scope, receive=receive)
|
|
142
|
+
trace_id = None
|
|
143
|
+
for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
|
|
144
|
+
v = request.headers.get(h)
|
|
145
|
+
if v:
|
|
146
|
+
trace_id = v
|
|
147
|
+
break
|
|
148
|
+
resp = problem_response(
|
|
149
|
+
status=408,
|
|
150
|
+
title="Request Timeout",
|
|
151
|
+
detail="Timed out while reading request body.",
|
|
152
|
+
code="REQUEST_TIMEOUT",
|
|
153
|
+
instance=str(request.url),
|
|
154
|
+
trace_id=trace_id,
|
|
155
|
+
)
|
|
156
|
+
await resp(scope, receive, send)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Replay the drained body to the app as a single http.request message.
|
|
160
|
+
# IMPORTANT: After replaying the body, we must forward the original receive()
|
|
161
|
+
# so that Starlette's listen_for_disconnect can properly detect client disconnects.
|
|
162
|
+
# This is required for streaming responses on ASGI spec < 2.4.
|
|
163
|
+
body_sent = False
|
|
164
|
+
|
|
165
|
+
async def _replay_receive() -> dict[str, Any]:
|
|
166
|
+
nonlocal body_sent
|
|
167
|
+
if not body_sent:
|
|
168
|
+
body_sent = True
|
|
169
|
+
return {
|
|
170
|
+
"type": "http.request",
|
|
171
|
+
"body": bytes(buffered),
|
|
172
|
+
"more_body": False,
|
|
173
|
+
}
|
|
174
|
+
# After body is sent, forward to original receive for disconnect detection
|
|
175
|
+
return dict(await receive())
|
|
176
|
+
|
|
177
|
+
await self.app(scope, _replay_receive, send)
|
|
@@ -5,7 +5,9 @@ from typing import Any, Callable
|
|
|
5
5
|
from fastapi import APIRouter
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def apply_default_security(
|
|
8
|
+
def apply_default_security(
|
|
9
|
+
router: APIRouter, *, default_security: list[dict] | None
|
|
10
|
+
) -> None:
|
|
9
11
|
if default_security is None:
|
|
10
12
|
return
|
|
11
13
|
original_add = router.add_api_route
|
|
@@ -17,7 +19,7 @@ def apply_default_security(router: APIRouter, *, default_security: list[dict] |
|
|
|
17
19
|
kwargs["openapi_extra"] = ox
|
|
18
20
|
return original_add(path, endpoint, **kwargs)
|
|
19
21
|
|
|
20
|
-
router
|
|
22
|
+
setattr(router, "add_api_route", _wrapped_add_api_route)
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
def apply_default_responses(router: APIRouter, defaults: dict[int, dict]) -> None:
|
|
@@ -38,4 +40,4 @@ def apply_default_responses(router: APIRouter, defaults: dict[int, dict]) -> Non
|
|
|
38
40
|
kwargs["responses"] = responses
|
|
39
41
|
return original_add(path, endpoint, **kwargs)
|
|
40
42
|
|
|
41
|
-
router
|
|
43
|
+
setattr(router, "add_api_route", _wrapped_add_api_route)
|
|
@@ -16,7 +16,11 @@ PROBLEM_SCHEMA: Dict[str, Any] = {
|
|
|
16
16
|
"description": "URI identifying the error type",
|
|
17
17
|
},
|
|
18
18
|
"title": {"type": "string", "description": "Short, human-readable summary"},
|
|
19
|
-
"status": {
|
|
19
|
+
"status": {
|
|
20
|
+
"type": "integer",
|
|
21
|
+
"format": "int32",
|
|
22
|
+
"description": "HTTP status code",
|
|
23
|
+
},
|
|
20
24
|
"detail": {"type": "string", "description": "Human-readable explanation"},
|
|
21
25
|
"instance": {
|
|
22
26
|
"type": "string",
|
|
@@ -36,7 +40,10 @@ PROBLEM_SCHEMA: Dict[str, Any] = {
|
|
|
36
40
|
},
|
|
37
41
|
},
|
|
38
42
|
},
|
|
39
|
-
"trace_id": {
|
|
43
|
+
"trace_id": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Correlation/trace id (if available)",
|
|
46
|
+
},
|
|
40
47
|
},
|
|
41
48
|
"required": ["title", "status"],
|
|
42
49
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from typing import Dict, Iterable, Iterator, Tuple
|
|
4
|
+
from typing import Callable, Dict, Iterable, Iterator, Tuple
|
|
5
5
|
|
|
6
6
|
from ..auth.security import auth_login_path
|
|
7
7
|
from .models import APIVersionSpec, ServiceInfo, VersionInfo
|
|
@@ -51,7 +51,7 @@ def pagination_components_mutator(
|
|
|
51
51
|
*,
|
|
52
52
|
default_limit: int = 50,
|
|
53
53
|
max_limit: int = 200,
|
|
54
|
-
) ->
|
|
54
|
+
) -> Callable[[dict], dict]:
|
|
55
55
|
"""
|
|
56
56
|
Adds reusable pagination/filtering parameters & paginated envelope schemas.
|
|
57
57
|
- Cursor: cursor/limit
|
|
@@ -196,7 +196,7 @@ def auto_attach_pagination_params_mutator(
|
|
|
196
196
|
attach_filters: bool = True,
|
|
197
197
|
apply_when: str = "array_200",
|
|
198
198
|
flag_disable: str = "x_no_auto_pagination",
|
|
199
|
-
) ->
|
|
199
|
+
) -> Callable[[dict], dict]:
|
|
200
200
|
"""
|
|
201
201
|
Attaches reusable pagination/filter parameters to GET "listy" operations.
|
|
202
202
|
|
|
@@ -273,7 +273,9 @@ def normalize_problem_and_examples_mutator():
|
|
|
273
273
|
if not isinstance(val, dict):
|
|
274
274
|
return
|
|
275
275
|
inst = val.get("instance")
|
|
276
|
-
if isinstance(inst, str) and (
|
|
276
|
+
if isinstance(inst, str) and (
|
|
277
|
+
inst.startswith("/") or inst.startswith("about:")
|
|
278
|
+
):
|
|
277
279
|
# make absolute to satisfy format: uri
|
|
278
280
|
val["instance"] = ABSOLUTE_INSTANCE
|
|
279
281
|
|
|
@@ -487,13 +489,18 @@ def ensure_global_tags_mutator(default_desc: str = "Operations related to {tag}.
|
|
|
487
489
|
# add missing tags; do NOT override existing descriptions
|
|
488
490
|
for name in sorted(used):
|
|
489
491
|
if name not in existing_map:
|
|
490
|
-
existing_map[name] = {
|
|
492
|
+
existing_map[name] = {
|
|
493
|
+
"name": name,
|
|
494
|
+
"description": default_desc.format(tag=name),
|
|
495
|
+
}
|
|
491
496
|
else:
|
|
492
497
|
if not existing_map[name].get("description"):
|
|
493
498
|
existing_map[name]["description"] = default_desc.format(tag=name)
|
|
494
499
|
|
|
495
500
|
if existing_map:
|
|
496
|
-
schema["tags"] = sorted(
|
|
501
|
+
schema["tags"] = sorted(
|
|
502
|
+
existing_map.values(), key=lambda x: x.get("name", "")
|
|
503
|
+
)
|
|
497
504
|
|
|
498
505
|
return schema
|
|
499
506
|
|
|
@@ -541,7 +548,7 @@ def attach_standard_responses_mutator(
|
|
|
541
548
|
|
|
542
549
|
|
|
543
550
|
def drop_unused_components_mutator(
|
|
544
|
-
drop_responses: list[str] = None, drop_schemas: list[str] = None
|
|
551
|
+
drop_responses: list[str] | None = None, drop_schemas: list[str] | None = None
|
|
545
552
|
):
|
|
546
553
|
drop_responses = drop_responses or []
|
|
547
554
|
drop_schemas = drop_schemas or []
|
|
@@ -640,7 +647,9 @@ def ensure_media_type_schemas_mutator():
|
|
|
640
647
|
|
|
641
648
|
|
|
642
649
|
# ---------- 3) Request body descriptions ----------
|
|
643
|
-
def ensure_request_body_descriptions_mutator(
|
|
650
|
+
def ensure_request_body_descriptions_mutator(
|
|
651
|
+
default_template="Request body for {method} {path}.",
|
|
652
|
+
):
|
|
644
653
|
def m(schema: dict) -> dict:
|
|
645
654
|
schema = dict(schema)
|
|
646
655
|
for path, method, op in _iter_ops(schema):
|
|
@@ -648,7 +657,9 @@ def ensure_request_body_descriptions_mutator(default_template="Request body for
|
|
|
648
657
|
if isinstance(rb, dict):
|
|
649
658
|
desc = rb.get("description")
|
|
650
659
|
if not isinstance(desc, str) or not desc.strip():
|
|
651
|
-
rb["description"] = default_template.format(
|
|
660
|
+
rb["description"] = default_template.format(
|
|
661
|
+
method=method.upper(), path=path
|
|
662
|
+
)
|
|
652
663
|
return schema
|
|
653
664
|
|
|
654
665
|
return m
|
|
@@ -836,7 +847,9 @@ def inject_safe_examples_mutator():
|
|
|
836
847
|
"""
|
|
837
848
|
|
|
838
849
|
def _has_examples(mt_obj: dict) -> bool:
|
|
839
|
-
return isinstance(mt_obj, dict) and (
|
|
850
|
+
return isinstance(mt_obj, dict) and (
|
|
851
|
+
"example" in mt_obj or "examples" in mt_obj
|
|
852
|
+
)
|
|
840
853
|
|
|
841
854
|
def m(schema: dict) -> dict:
|
|
842
855
|
schema = dict(schema)
|
|
@@ -1082,7 +1095,11 @@ def ensure_success_examples_mutator():
|
|
|
1082
1095
|
if not (200 <= ic < 300) or ic == 204:
|
|
1083
1096
|
continue
|
|
1084
1097
|
mt_obj = (resp.get("content") or {}).get("application/json")
|
|
1085
|
-
if
|
|
1098
|
+
if (
|
|
1099
|
+
not isinstance(mt_obj, dict)
|
|
1100
|
+
or "example" in mt_obj
|
|
1101
|
+
or "examples" in mt_obj
|
|
1102
|
+
):
|
|
1086
1103
|
continue
|
|
1087
1104
|
sch = mt_obj.get("schema") or {}
|
|
1088
1105
|
|
|
@@ -1102,6 +1119,119 @@ def ensure_success_examples_mutator():
|
|
|
1102
1119
|
return m
|
|
1103
1120
|
|
|
1104
1121
|
|
|
1122
|
+
# --- NEW: attach minimal x-codeSamples for common operations ---
|
|
1123
|
+
def attach_code_samples_mutator():
|
|
1124
|
+
"""Attach minimal curl/httpie x-codeSamples for each operation if missing.
|
|
1125
|
+
|
|
1126
|
+
We avoid templating parameters; samples illustrate method and path only.
|
|
1127
|
+
"""
|
|
1128
|
+
|
|
1129
|
+
def m(schema: dict) -> dict:
|
|
1130
|
+
schema = dict(schema)
|
|
1131
|
+
servers = schema.get("servers") or [{"url": ""}]
|
|
1132
|
+
base = servers[0].get("url") or ""
|
|
1133
|
+
|
|
1134
|
+
for path, method, op in _iter_ops(schema):
|
|
1135
|
+
# Don't override existing samples
|
|
1136
|
+
if isinstance(op.get("x-codeSamples"), list) and op["x-codeSamples"]:
|
|
1137
|
+
continue
|
|
1138
|
+
url = f"{base}{path}"
|
|
1139
|
+
method_up = method.upper()
|
|
1140
|
+
samples = [
|
|
1141
|
+
{
|
|
1142
|
+
"lang": "bash",
|
|
1143
|
+
"label": "curl",
|
|
1144
|
+
"source": f"curl -X {method_up} '{url}'",
|
|
1145
|
+
},
|
|
1146
|
+
{
|
|
1147
|
+
"lang": "bash",
|
|
1148
|
+
"label": "httpie",
|
|
1149
|
+
"source": f"http {method_up} '{url}'",
|
|
1150
|
+
},
|
|
1151
|
+
]
|
|
1152
|
+
op["x-codeSamples"] = samples
|
|
1153
|
+
return schema
|
|
1154
|
+
|
|
1155
|
+
return m
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
# --- NEW: ensure Problem+JSON examples exist for standard error responses ---
|
|
1159
|
+
def ensure_problem_examples_mutator():
|
|
1160
|
+
"""Add example objects for 4xx/5xx responses using Problem schema if absent."""
|
|
1161
|
+
|
|
1162
|
+
try:
|
|
1163
|
+
# Internal helper with sensible defaults
|
|
1164
|
+
from .conventions import _problem_example
|
|
1165
|
+
except Exception: # pragma: no cover - fallback
|
|
1166
|
+
|
|
1167
|
+
def _problem_example(**kw): # type: ignore
|
|
1168
|
+
base = {
|
|
1169
|
+
"type": "about:blank",
|
|
1170
|
+
"title": "Error",
|
|
1171
|
+
"status": 500,
|
|
1172
|
+
"detail": "An error occurred.",
|
|
1173
|
+
"instance": "/request/trace",
|
|
1174
|
+
"code": "INTERNAL_ERROR",
|
|
1175
|
+
}
|
|
1176
|
+
base.update(kw)
|
|
1177
|
+
return base
|
|
1178
|
+
|
|
1179
|
+
def m(schema: dict) -> dict:
|
|
1180
|
+
schema = dict(schema)
|
|
1181
|
+
for _, _, op in _iter_ops(schema):
|
|
1182
|
+
resps = op.get("responses") or {}
|
|
1183
|
+
for code, resp in resps.items():
|
|
1184
|
+
if not isinstance(resp, dict):
|
|
1185
|
+
continue
|
|
1186
|
+
try:
|
|
1187
|
+
ic = int(code)
|
|
1188
|
+
except Exception:
|
|
1189
|
+
continue
|
|
1190
|
+
if ic < 400:
|
|
1191
|
+
continue
|
|
1192
|
+
# Do not add content if response is a $ref; avoid creating siblings
|
|
1193
|
+
if "$ref" in resp:
|
|
1194
|
+
continue
|
|
1195
|
+
content = resp.setdefault("content", {})
|
|
1196
|
+
# prefer problem+json but also set application/json if present
|
|
1197
|
+
for mt in ("application/problem+json", "application/json"):
|
|
1198
|
+
mt_obj = content.get(mt)
|
|
1199
|
+
if mt_obj is None:
|
|
1200
|
+
# Create a basic media type referencing Problem schema when appropriate
|
|
1201
|
+
if mt == "application/problem+json":
|
|
1202
|
+
mt_obj = {
|
|
1203
|
+
"schema": {"$ref": "#/components/schemas/Problem"}
|
|
1204
|
+
}
|
|
1205
|
+
content[mt] = mt_obj
|
|
1206
|
+
else:
|
|
1207
|
+
continue
|
|
1208
|
+
if not isinstance(mt_obj, dict):
|
|
1209
|
+
continue
|
|
1210
|
+
if "example" in mt_obj or "examples" in mt_obj:
|
|
1211
|
+
continue
|
|
1212
|
+
mt_obj["example"] = _problem_example(status=ic)
|
|
1213
|
+
return schema
|
|
1214
|
+
|
|
1215
|
+
return m
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
# --- NEW: attach default tags from first path segment when missing ---
|
|
1219
|
+
def attach_default_tags_mutator():
|
|
1220
|
+
"""If an operation has no tags, tag it by its first path segment."""
|
|
1221
|
+
|
|
1222
|
+
def m(schema: dict) -> dict:
|
|
1223
|
+
schema = dict(schema)
|
|
1224
|
+
for path, _method, op in _iter_ops(schema):
|
|
1225
|
+
tags = op.get("tags")
|
|
1226
|
+
if tags:
|
|
1227
|
+
continue
|
|
1228
|
+
seg = path.strip("/").split("/", 1)[0] or "root"
|
|
1229
|
+
op["tags"] = [seg]
|
|
1230
|
+
return schema
|
|
1231
|
+
|
|
1232
|
+
return m
|
|
1233
|
+
|
|
1234
|
+
|
|
1105
1235
|
def dedupe_tags_mutator():
|
|
1106
1236
|
def m(schema: dict) -> dict:
|
|
1107
1237
|
schema = dict(schema)
|
|
@@ -1140,7 +1270,7 @@ def scrub_invalid_object_examples_mutator():
|
|
|
1140
1270
|
sch = mt_obj.get("schema")
|
|
1141
1271
|
ex = mt_obj.get("example")
|
|
1142
1272
|
if "example" in mt_obj and _invalid_object_example(
|
|
1143
|
-
sch if isinstance(sch, dict) else {}, ex
|
|
1273
|
+
sch if isinstance(sch, dict) else {}, ex if isinstance(ex, dict) else {}
|
|
1144
1274
|
):
|
|
1145
1275
|
mt_obj.pop("example", None)
|
|
1146
1276
|
|
|
@@ -1269,17 +1399,20 @@ def hardening_components_mutator():
|
|
|
1269
1399
|
},
|
|
1270
1400
|
)
|
|
1271
1401
|
headers.setdefault(
|
|
1272
|
-
"XRateLimitLimit",
|
|
1402
|
+
"XRateLimitLimit",
|
|
1403
|
+
{"schema": {"type": "integer"}, "description": "Tokens in window."},
|
|
1273
1404
|
)
|
|
1274
1405
|
headers.setdefault(
|
|
1275
1406
|
"XRateLimitRemaining",
|
|
1276
1407
|
{"schema": {"type": "integer"}, "description": "Remaining tokens."},
|
|
1277
1408
|
)
|
|
1278
1409
|
headers.setdefault(
|
|
1279
|
-
"XRateLimitReset",
|
|
1410
|
+
"XRateLimitReset",
|
|
1411
|
+
{"schema": {"type": "integer"}, "description": "Unix reset time."},
|
|
1280
1412
|
)
|
|
1281
1413
|
headers.setdefault(
|
|
1282
|
-
"XRequestId",
|
|
1414
|
+
"XRequestId",
|
|
1415
|
+
{"schema": {"type": "string"}, "description": "Correlation id."},
|
|
1283
1416
|
)
|
|
1284
1417
|
headers.setdefault(
|
|
1285
1418
|
"Deprecation",
|
|
@@ -1290,7 +1423,10 @@ def hardening_components_mutator():
|
|
|
1290
1423
|
)
|
|
1291
1424
|
headers.setdefault(
|
|
1292
1425
|
"Sunset",
|
|
1293
|
-
{
|
|
1426
|
+
{
|
|
1427
|
+
"schema": {"type": "string"},
|
|
1428
|
+
"description": "HTTP-date for deprecation sunset.",
|
|
1429
|
+
},
|
|
1294
1430
|
)
|
|
1295
1431
|
return schema
|
|
1296
1432
|
|
|
@@ -1359,17 +1495,23 @@ def attach_header_params_mutator():
|
|
|
1359
1495
|
if 200 <= ic < 300:
|
|
1360
1496
|
hdrs = resp.setdefault("headers", {})
|
|
1361
1497
|
hdrs.setdefault("ETag", {"$ref": "#/components/headers/ETag"})
|
|
1362
|
-
hdrs.setdefault("Last-Modified", {"$ref": "#/components/headers/LastModified"})
|
|
1363
|
-
hdrs.setdefault("X-Request-Id", {"$ref": "#/components/headers/XRequestId"})
|
|
1364
1498
|
hdrs.setdefault(
|
|
1365
|
-
"
|
|
1499
|
+
"Last-Modified", {"$ref": "#/components/headers/LastModified"}
|
|
1500
|
+
)
|
|
1501
|
+
hdrs.setdefault(
|
|
1502
|
+
"X-Request-Id", {"$ref": "#/components/headers/XRequestId"}
|
|
1503
|
+
)
|
|
1504
|
+
hdrs.setdefault(
|
|
1505
|
+
"X-RateLimit-Limit",
|
|
1506
|
+
{"$ref": "#/components/headers/XRateLimitLimit"},
|
|
1366
1507
|
)
|
|
1367
1508
|
hdrs.setdefault(
|
|
1368
1509
|
"X-RateLimit-Remaining",
|
|
1369
1510
|
{"$ref": "#/components/headers/XRateLimitRemaining"},
|
|
1370
1511
|
)
|
|
1371
1512
|
hdrs.setdefault(
|
|
1372
|
-
"X-RateLimit-Reset",
|
|
1513
|
+
"X-RateLimit-Reset",
|
|
1514
|
+
{"$ref": "#/components/headers/XRateLimitReset"},
|
|
1373
1515
|
)
|
|
1374
1516
|
if code == "429":
|
|
1375
1517
|
resp.setdefault("headers", {})["Retry-After"] = {
|
|
@@ -1429,6 +1571,9 @@ def setup_mutators(
|
|
|
1429
1571
|
ensure_media_type_schemas_mutator(),
|
|
1430
1572
|
ensure_examples_for_json_mutator(),
|
|
1431
1573
|
ensure_success_examples_mutator(),
|
|
1574
|
+
attach_default_tags_mutator(),
|
|
1575
|
+
attach_code_samples_mutator(),
|
|
1576
|
+
ensure_problem_examples_mutator(),
|
|
1432
1577
|
ensure_media_examples_mutator(),
|
|
1433
1578
|
scrub_invalid_object_examples_mutator(),
|
|
1434
1579
|
normalize_no_content_204_mutator(),
|
|
@@ -6,7 +6,9 @@ from .mutators import auth_mutator
|
|
|
6
6
|
from .pipeline import apply_mutators
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def _normalize_security_list(
|
|
9
|
+
def _normalize_security_list(
|
|
10
|
+
sec: list | None, *, drop_schemes: set[str] | None = None
|
|
11
|
+
) -> list:
|
|
10
12
|
if not sec:
|
|
11
13
|
return []
|
|
12
14
|
drop_schemes = drop_schemes or set()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_probes(
|
|
11
|
+
app: FastAPI,
|
|
12
|
+
*,
|
|
13
|
+
prefix: str = "/_ops",
|
|
14
|
+
include_in_schema: bool = False,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Mount basic liveness/readiness/startup probes under prefix."""
|
|
17
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
18
|
+
|
|
19
|
+
router = public_router(
|
|
20
|
+
prefix=prefix, tags=["ops"], include_in_schema=include_in_schema
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@router.get("/live")
|
|
24
|
+
async def live() -> JSONResponse: # noqa: D401, ANN201
|
|
25
|
+
return JSONResponse({"status": "ok"})
|
|
26
|
+
|
|
27
|
+
@router.get("/ready")
|
|
28
|
+
async def ready() -> JSONResponse: # noqa: D401, ANN201
|
|
29
|
+
# In the future, add checks (DB ping, cache ping) via DI hooks.
|
|
30
|
+
return JSONResponse({"status": "ok"})
|
|
31
|
+
|
|
32
|
+
@router.get("/startup")
|
|
33
|
+
async def startup_probe() -> JSONResponse: # noqa: D401, ANN201
|
|
34
|
+
return JSONResponse({"status": "ok"})
|
|
35
|
+
|
|
36
|
+
app.include_router(router)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def add_maintenance_mode(
|
|
40
|
+
app: FastAPI,
|
|
41
|
+
*,
|
|
42
|
+
env_var: str = "MAINTENANCE_MODE",
|
|
43
|
+
exempt_prefixes: tuple[str, ...] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Enable a simple maintenance gate controlled by an env var.
|
|
46
|
+
|
|
47
|
+
When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
@app.middleware("http")
|
|
51
|
+
async def _maintenance_gate(request: Request, call_next): # noqa: ANN001, ANN202
|
|
52
|
+
flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
|
|
53
|
+
if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
54
|
+
path = request.scope.get("path", "")
|
|
55
|
+
if exempt_prefixes and any(path.startswith(p) for p in exempt_prefixes):
|
|
56
|
+
return await call_next(request)
|
|
57
|
+
return JSONResponse({"detail": "maintenance"}, status_code=503)
|
|
58
|
+
return await call_next(request)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
|
|
62
|
+
"""Return a dependency that can trip rejective errors based on external metrics.
|
|
63
|
+
|
|
64
|
+
This is a placeholder; callers can swap with a provider that tracks failures and opens the
|
|
65
|
+
breaker. Here, we read an env var to simulate an open breaker.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
async def _dep(_: Request) -> None: # noqa: D401, ANN202
|
|
69
|
+
if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
|
|
70
|
+
raise HTTPException(status_code=503, detail="circuit open")
|
|
71
|
+
|
|
72
|
+
return _dep
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]
|