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