svc-infra 0.1.706__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/apf_payments/models.py +47 -108
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +42 -100
- svc_infra/apf_payments/provider/base.py +10 -26
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +63 -135
- svc_infra/apf_payments/schemas.py +82 -90
- svc_infra/apf_payments/service.py +40 -86
- svc_infra/apf_payments/settings.py +10 -13
- svc_infra/api/__init__.py +13 -13
- svc_infra/api/fastapi/__init__.py +19 -0
- svc_infra/api/fastapi/admin/add.py +13 -18
- svc_infra/api/fastapi/apf_payments/router.py +47 -84
- svc_infra/api/fastapi/apf_payments/setup.py +7 -13
- svc_infra/api/fastapi/auth/__init__.py +1 -1
- svc_infra/api/fastapi/auth/_cookies.py +3 -9
- svc_infra/api/fastapi/auth/add.py +4 -8
- svc_infra/api/fastapi/auth/gaurd.py +9 -26
- svc_infra/api/fastapi/auth/mfa/models.py +4 -7
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
- svc_infra/api/fastapi/auth/mfa/router.py +9 -15
- svc_infra/api/fastapi/auth/mfa/security.py +3 -5
- svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
- svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
- svc_infra/api/fastapi/auth/providers.py +4 -6
- svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
- svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
- svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
- svc_infra/api/fastapi/auth/security.py +17 -28
- svc_infra/api/fastapi/auth/sender.py +1 -3
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +6 -7
- svc_infra/api/fastapi/auth/ws_security.py +2 -2
- svc_infra/api/fastapi/billing/router.py +6 -8
- svc_infra/api/fastapi/db/http.py +10 -11
- svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
- svc_infra/api/fastapi/db/sql/add.py +6 -14
- svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
- svc_infra/api/fastapi/db/sql/health.py +1 -3
- svc_infra/api/fastapi/db/sql/session.py +4 -5
- svc_infra/api/fastapi/db/sql/users.py +8 -11
- svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
- svc_infra/api/fastapi/docs/add.py +13 -23
- svc_infra/api/fastapi/docs/landing.py +6 -8
- svc_infra/api/fastapi/docs/scoped.py +34 -42
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +12 -21
- svc_infra/api/fastapi/dual/router.py +14 -31
- svc_infra/api/fastapi/ease.py +57 -13
- svc_infra/api/fastapi/http/conditional.py +3 -5
- svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
- svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
- svc_infra/api/fastapi/middleware/idempotency.py +11 -16
- svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
- svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
- svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
- svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
- svc_infra/api/fastapi/middleware/request_id.py +1 -3
- svc_infra/api/fastapi/middleware/timeout.py +9 -10
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -6
- svc_infra/api/fastapi/openapi/conventions.py +4 -4
- svc_infra/api/fastapi/openapi/mutators.py +13 -31
- 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 -3
- svc_infra/api/fastapi/ops/add.py +7 -9
- svc_infra/api/fastapi/pagination.py +25 -37
- svc_infra/api/fastapi/routers/__init__.py +16 -38
- svc_infra/api/fastapi/setup.py +13 -31
- svc_infra/api/fastapi/tenancy/add.py +3 -2
- svc_infra/api/fastapi/tenancy/context.py +8 -7
- svc_infra/api/fastapi/versioned.py +3 -2
- svc_infra/app/env.py +5 -7
- svc_infra/app/logging/add.py +2 -1
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +3 -2
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +19 -2
- svc_infra/billing/async_service.py +27 -7
- svc_infra/billing/jobs.py +23 -33
- svc_infra/billing/models.py +21 -52
- svc_infra/billing/quotas.py +5 -7
- svc_infra/billing/schemas.py +4 -6
- svc_infra/cache/__init__.py +12 -5
- svc_infra/cache/add.py +6 -9
- svc_infra/cache/backend.py +6 -5
- svc_infra/cache/decorators.py +17 -28
- svc_infra/cache/keys.py +2 -2
- svc_infra/cache/recache.py +22 -35
- svc_infra/cache/resources.py +8 -16
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +5 -6
- svc_infra/cli/__init__.py +4 -12
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
- svc_infra/cli/cmds/db/ops_cmds.py +3 -6
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
- svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
- svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
- svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
- svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
- svc_infra/cli/foundation/runner.py +6 -11
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +5 -5
- svc_infra/data/backup.py +8 -10
- svc_infra/data/erasure.py +3 -2
- svc_infra/data/fixtures.py +3 -3
- svc_infra/data/retention.py +8 -13
- svc_infra/db/crud_schema.py +9 -8
- svc_infra/db/nosql/__init__.py +0 -1
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +7 -14
- svc_infra/db/nosql/indexes.py +11 -10
- svc_infra/db/nosql/management.py +3 -3
- svc_infra/db/nosql/mongo/client.py +3 -3
- svc_infra/db/nosql/mongo/settings.py +2 -6
- svc_infra/db/nosql/repository.py +27 -28
- svc_infra/db/nosql/resource.py +15 -20
- svc_infra/db/nosql/scaffold.py +13 -17
- svc_infra/db/nosql/service.py +3 -4
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/types.py +2 -6
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +14 -18
- svc_infra/db/outbox.py +15 -18
- svc_infra/db/sql/apikey.py +12 -21
- svc_infra/db/sql/authref.py +3 -7
- svc_infra/db/sql/constants.py +9 -9
- svc_infra/db/sql/core.py +11 -11
- svc_infra/db/sql/management.py +2 -6
- svc_infra/db/sql/repository.py +17 -24
- svc_infra/db/sql/resource.py +14 -13
- svc_infra/db/sql/scaffold.py +13 -17
- svc_infra/db/sql/service.py +7 -16
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/tenant.py +6 -14
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +14 -19
- svc_infra/db/sql/utils.py +24 -53
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +8 -15
- svc_infra/documents/add.py +7 -8
- svc_infra/documents/ease.py +8 -8
- svc_infra/documents/models.py +3 -3
- svc_infra/documents/storage.py +11 -13
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +1 -3
- svc_infra/dx/changelog.py +2 -2
- svc_infra/dx/checks.py +1 -1
- svc_infra/health/__init__.py +15 -16
- svc_infra/http/client.py +10 -14
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +3 -5
- svc_infra/jobs/builtins/webhook_delivery.py +1 -3
- svc_infra/jobs/loader.py +4 -5
- svc_infra/jobs/queue.py +14 -24
- svc_infra/jobs/redis_queue.py +20 -34
- svc_infra/jobs/runner.py +7 -11
- svc_infra/jobs/scheduler.py +5 -5
- svc_infra/jobs/worker.py +1 -1
- svc_infra/loaders/base.py +5 -4
- svc_infra/loaders/github.py +1 -3
- svc_infra/loaders/url.py +3 -9
- svc_infra/logging/__init__.py +7 -6
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +2 -2
- svc_infra/obs/add.py +4 -3
- svc_infra/obs/cloud_dash.py +1 -1
- svc_infra/obs/metrics/__init__.py +3 -3
- svc_infra/obs/metrics/asgi.py +9 -14
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +5 -9
- svc_infra/obs/metrics/sqlalchemy.py +9 -12
- svc_infra/obs/metrics.py +3 -3
- svc_infra/obs/settings.py +2 -6
- 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 +5 -9
- svc_infra/security/audit.py +14 -17
- svc_infra/security/audit_service.py +9 -9
- svc_infra/security/hibp.py +3 -6
- svc_infra/security/jwt_rotation.py +7 -10
- svc_infra/security/lockout.py +12 -11
- svc_infra/security/models.py +37 -46
- svc_infra/security/oauth_models.py +8 -8
- svc_infra/security/org_invites.py +11 -13
- svc_infra/security/passwords.py +4 -6
- svc_infra/security/permissions.py +8 -7
- svc_infra/security/session.py +6 -7
- svc_infra/security/signed_cookies.py +9 -9
- svc_infra/storage/add.py +5 -8
- svc_infra/storage/backends/local.py +13 -21
- svc_infra/storage/backends/memory.py +4 -7
- svc_infra/storage/backends/s3.py +17 -36
- svc_infra/storage/base.py +2 -2
- svc_infra/storage/easy.py +4 -8
- svc_infra/storage/settings.py +16 -18
- svc_infra/testing/__init__.py +36 -39
- svc_infra/utils.py +169 -8
- svc_infra/webhooks/__init__.py +1 -1
- svc_infra/webhooks/add.py +17 -29
- svc_infra/webhooks/encryption.py +2 -2
- svc_infra/webhooks/fastapi.py +2 -4
- svc_infra/webhooks/router.py +3 -3
- svc_infra/webhooks/service.py +5 -6
- svc_infra/webhooks/signing.py +5 -5
- svc_infra/websocket/add.py +2 -3
- svc_infra/websocket/client.py +3 -2
- svc_infra/websocket/config.py +6 -18
- svc_infra/websocket/manager.py +9 -10
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra/billing/service.py +0 -123
- svc_infra-0.1.706.dist-info/RECORD +0 -357
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import traceback
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
from fastapi import Request
|
|
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
|
|
|
17
17
|
PROBLEM_MT = "application/problem+json"
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def _trace_id_from_request(request: Request) ->
|
|
20
|
+
def _trace_id_from_request(request: Request) -> str | None:
|
|
21
21
|
# Try common headers first; fall back to None
|
|
22
22
|
for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
|
|
23
23
|
v = request.headers.get(h)
|
|
@@ -49,7 +49,7 @@ def problem_response(
|
|
|
49
49
|
trace_id: str | None = None,
|
|
50
50
|
headers: dict[str, str] | None = None,
|
|
51
51
|
) -> Response:
|
|
52
|
-
body:
|
|
52
|
+
body: dict[str, Any] = {
|
|
53
53
|
"type": type_uri,
|
|
54
54
|
"title": title,
|
|
55
55
|
"status": status,
|
|
@@ -64,9 +64,7 @@ def problem_response(
|
|
|
64
64
|
body["errors"] = errors
|
|
65
65
|
if trace_id:
|
|
66
66
|
body["trace_id"] = trace_id
|
|
67
|
-
return JSONResponse(
|
|
68
|
-
status_code=status, content=body, media_type=PROBLEM_MT, headers=headers
|
|
69
|
-
)
|
|
67
|
+
return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT, headers=headers)
|
|
70
68
|
|
|
71
69
|
|
|
72
70
|
def register_error_handlers(app):
|
|
@@ -78,11 +76,7 @@ def register_error_handlers(app):
|
|
|
78
76
|
return problem_response(
|
|
79
77
|
status=504,
|
|
80
78
|
title="Gateway Timeout",
|
|
81
|
-
detail=(
|
|
82
|
-
"Upstream request timed out."
|
|
83
|
-
if IS_PROD
|
|
84
|
-
else (str(exc) or "httpx timeout")
|
|
85
|
-
),
|
|
79
|
+
detail=("Upstream request timed out." if IS_PROD else (str(exc) or "httpx timeout")),
|
|
86
80
|
code="GATEWAY_TIMEOUT",
|
|
87
81
|
instance=str(request.url),
|
|
88
82
|
trace_id=trace_id,
|
|
@@ -140,9 +134,10 @@ def register_error_handlers(app):
|
|
|
140
134
|
# Preserve headers set on the exception (e.g., Retry-After for rate limits)
|
|
141
135
|
hdrs: dict[str, str] | None = None
|
|
142
136
|
try:
|
|
143
|
-
|
|
137
|
+
exc_headers = getattr(exc, "headers", None)
|
|
138
|
+
if exc_headers is not None:
|
|
144
139
|
# FastAPI/Starlette exceptions store headers as a dict[str, str]
|
|
145
|
-
hdrs = dict(
|
|
140
|
+
hdrs = dict(exc_headers)
|
|
146
141
|
except Exception:
|
|
147
142
|
hdrs = None
|
|
148
143
|
return problem_response(
|
|
@@ -156,9 +151,7 @@ def register_error_handlers(app):
|
|
|
156
151
|
)
|
|
157
152
|
|
|
158
153
|
@app.exception_handler(StarletteHTTPException)
|
|
159
|
-
async def handle_starlette_http_exception(
|
|
160
|
-
request: Request, exc: StarletteHTTPException
|
|
161
|
-
):
|
|
154
|
+
async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
|
|
162
155
|
trace_id = _trace_id_from_request(request)
|
|
163
156
|
title = {
|
|
164
157
|
401: "Unauthorized",
|
|
@@ -173,8 +166,9 @@ def register_error_handlers(app):
|
|
|
173
166
|
)
|
|
174
167
|
hdrs: dict[str, str] | None = None
|
|
175
168
|
try:
|
|
176
|
-
|
|
177
|
-
|
|
169
|
+
exc_headers = getattr(exc, "headers", None)
|
|
170
|
+
if exc_headers is not None:
|
|
171
|
+
hdrs = dict(exc_headers)
|
|
178
172
|
except Exception:
|
|
179
173
|
hdrs = None
|
|
180
174
|
return problem_response(
|
|
@@ -4,7 +4,6 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
from contextlib import asynccontextmanager
|
|
7
|
-
from typing import Optional
|
|
8
7
|
|
|
9
8
|
from fastapi import FastAPI
|
|
10
9
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
@@ -47,9 +46,7 @@ class InflightTrackerMiddleware:
|
|
|
47
46
|
try:
|
|
48
47
|
await self.app(scope, receive, send)
|
|
49
48
|
finally:
|
|
50
|
-
state._inflight_requests = max(
|
|
51
|
-
0, getattr(state, "_inflight_requests", 1) - 1
|
|
52
|
-
)
|
|
49
|
+
state._inflight_requests = max(0, getattr(state, "_inflight_requests", 1) - 1)
|
|
53
50
|
|
|
54
51
|
|
|
55
52
|
async def _wait_for_drain(app: FastAPI, grace: float) -> None:
|
|
@@ -70,9 +67,7 @@ async def _wait_for_drain(app: FastAPI, grace: float) -> None:
|
|
|
70
67
|
)
|
|
71
68
|
|
|
72
69
|
|
|
73
|
-
def install_graceful_shutdown(
|
|
74
|
-
app: FastAPI, *, grace_seconds: Optional[float] = None
|
|
75
|
-
) -> None:
|
|
70
|
+
def install_graceful_shutdown(app: FastAPI, *, grace_seconds: float | None = None) -> None:
|
|
76
71
|
"""Install inflight tracking and lifespan hooks to wait for requests to drain.
|
|
77
72
|
|
|
78
73
|
- Adds InflightTrackerMiddleware
|
|
@@ -80,17 +75,13 @@ def install_graceful_shutdown(
|
|
|
80
75
|
"""
|
|
81
76
|
app.add_middleware(InflightTrackerMiddleware)
|
|
82
77
|
|
|
83
|
-
g = (
|
|
84
|
-
float(grace_seconds)
|
|
85
|
-
if grace_seconds is not None
|
|
86
|
-
else _get_grace_period_seconds()
|
|
87
|
-
)
|
|
78
|
+
g = float(grace_seconds) if grace_seconds is not None else _get_grace_period_seconds()
|
|
88
79
|
|
|
89
80
|
# Preserve any existing lifespan and wrap it so our drain runs on shutdown.
|
|
90
81
|
previous_lifespan = getattr(app.router, "lifespan_context", None)
|
|
91
82
|
|
|
92
83
|
@asynccontextmanager
|
|
93
|
-
async def _lifespan(a: FastAPI):
|
|
84
|
+
async def _lifespan(a: FastAPI):
|
|
94
85
|
# Startup: initialize inflight counter
|
|
95
86
|
a.state._inflight_requests = 0
|
|
96
87
|
if previous_lifespan is not None:
|
|
@@ -2,7 +2,7 @@ import base64
|
|
|
2
2
|
import hashlib
|
|
3
3
|
import json
|
|
4
4
|
import time
|
|
5
|
-
from typing import Annotated
|
|
5
|
+
from typing import Annotated
|
|
6
6
|
|
|
7
7
|
from fastapi import Header, HTTPException, Request
|
|
8
8
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
@@ -17,15 +17,18 @@ class IdempotencyMiddleware:
|
|
|
17
17
|
Caches responses for requests with Idempotency-Key header to ensure
|
|
18
18
|
duplicate requests return the same response. Use skip_paths for endpoints
|
|
19
19
|
where idempotency caching is not appropriate (e.g., streaming responses).
|
|
20
|
+
|
|
21
|
+
Matching uses prefix matching: "/v1/chat" matches "/v1/chat", "/v1/chat/stream",
|
|
22
|
+
but not "/api/v1/chat" or "/v1/chatter".
|
|
20
23
|
"""
|
|
21
24
|
|
|
22
25
|
def __init__(
|
|
23
26
|
self,
|
|
24
27
|
app: ASGIApp,
|
|
25
28
|
ttl_seconds: int = 24 * 3600,
|
|
26
|
-
store:
|
|
29
|
+
store: IdempotencyStore | None = None,
|
|
27
30
|
header_name: str = "Idempotency-Key",
|
|
28
|
-
skip_paths:
|
|
31
|
+
skip_paths: list[str] | None = None,
|
|
29
32
|
):
|
|
30
33
|
self.app = app
|
|
31
34
|
self.ttl = ttl_seconds
|
|
@@ -45,8 +48,8 @@ class IdempotencyMiddleware:
|
|
|
45
48
|
path = scope.get("path", "")
|
|
46
49
|
method = scope.get("method", "GET")
|
|
47
50
|
|
|
48
|
-
# Skip specified paths
|
|
49
|
-
if any(skip
|
|
51
|
+
# Skip specified paths using prefix matching
|
|
52
|
+
if any(path.startswith(skip) for skip in self.skip_paths):
|
|
50
53
|
await self.app(scope, receive, send)
|
|
51
54
|
return
|
|
52
55
|
|
|
@@ -115,11 +118,7 @@ class IdempotencyMiddleware:
|
|
|
115
118
|
},
|
|
116
119
|
)
|
|
117
120
|
return
|
|
118
|
-
if
|
|
119
|
-
existing
|
|
120
|
-
and existing.status is not None
|
|
121
|
-
and existing.body_b64 is not None
|
|
122
|
-
):
|
|
121
|
+
if existing and existing.status is not None and existing.body_b64 is not None:
|
|
123
122
|
await self._send_cached_response(send, existing)
|
|
124
123
|
return
|
|
125
124
|
|
|
@@ -182,9 +181,7 @@ class IdempotencyMiddleware:
|
|
|
182
181
|
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
183
182
|
|
|
184
183
|
async def _send_cached_response(self, send, existing) -> None:
|
|
185
|
-
headers = [
|
|
186
|
-
(k.encode(), v.encode()) for k, v in (existing.headers or {}).items()
|
|
187
|
-
]
|
|
184
|
+
headers = [(k.encode(), v.encode()) for k, v in (existing.headers or {}).items()]
|
|
188
185
|
if existing.media_type:
|
|
189
186
|
headers.append((b"content-type", existing.media_type.encode()))
|
|
190
187
|
await send(
|
|
@@ -208,6 +205,4 @@ async def require_idempotency_key(
|
|
|
208
205
|
request: Request,
|
|
209
206
|
) -> None:
|
|
210
207
|
if not idempotency_key.strip():
|
|
211
|
-
raise HTTPException(
|
|
212
|
-
status_code=400, detail="Idempotency-Key must not be empty."
|
|
213
|
-
)
|
|
208
|
+
raise HTTPException(status_code=400, detail="Idempotency-Key must not be empty.")
|
|
@@ -4,7 +4,7 @@ import base64
|
|
|
4
4
|
import json
|
|
5
5
|
import time
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import Protocol
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@dataclass
|
|
@@ -12,14 +12,14 @@ class IdempotencyEntry:
|
|
|
12
12
|
req_hash: str
|
|
13
13
|
exp: float
|
|
14
14
|
# Optional response fields when available
|
|
15
|
-
status:
|
|
16
|
-
body_b64:
|
|
17
|
-
headers:
|
|
18
|
-
media_type:
|
|
15
|
+
status: int | None = None
|
|
16
|
+
body_b64: str | None = None
|
|
17
|
+
headers: dict[str, str] | None = None
|
|
18
|
+
media_type: str | None = None
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class IdempotencyStore(Protocol):
|
|
22
|
-
def get(self, key: str) ->
|
|
22
|
+
def get(self, key: str) -> IdempotencyEntry | None:
|
|
23
23
|
pass
|
|
24
24
|
|
|
25
25
|
def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
|
|
@@ -32,8 +32,8 @@ class IdempotencyStore(Protocol):
|
|
|
32
32
|
*,
|
|
33
33
|
status: int,
|
|
34
34
|
body: bytes,
|
|
35
|
-
headers:
|
|
36
|
-
media_type:
|
|
35
|
+
headers: dict[str, str],
|
|
36
|
+
media_type: str | None,
|
|
37
37
|
) -> None:
|
|
38
38
|
pass
|
|
39
39
|
|
|
@@ -45,7 +45,7 @@ class InMemoryIdempotencyStore:
|
|
|
45
45
|
def __init__(self):
|
|
46
46
|
self._store: dict[str, IdempotencyEntry] = {}
|
|
47
47
|
|
|
48
|
-
def get(self, key: str) ->
|
|
48
|
+
def get(self, key: str) -> IdempotencyEntry | None:
|
|
49
49
|
entry = self._store.get(key)
|
|
50
50
|
if not entry:
|
|
51
51
|
return None
|
|
@@ -69,8 +69,8 @@ class InMemoryIdempotencyStore:
|
|
|
69
69
|
*,
|
|
70
70
|
status: int,
|
|
71
71
|
body: bytes,
|
|
72
|
-
headers:
|
|
73
|
-
media_type:
|
|
72
|
+
headers: dict[str, str],
|
|
73
|
+
media_type: str | None,
|
|
74
74
|
) -> None:
|
|
75
75
|
entry = self._store.get(key)
|
|
76
76
|
if not entry:
|
|
@@ -102,7 +102,7 @@ class RedisIdempotencyStore:
|
|
|
102
102
|
def _k(self, key: str) -> str:
|
|
103
103
|
return f"{self.prefix}:{key}"
|
|
104
104
|
|
|
105
|
-
def get(self, key: str) ->
|
|
105
|
+
def get(self, key: str) -> IdempotencyEntry | None:
|
|
106
106
|
raw = self.r.get(self._k(key))
|
|
107
107
|
if not raw:
|
|
108
108
|
return None
|
|
@@ -156,8 +156,8 @@ class RedisIdempotencyStore:
|
|
|
156
156
|
*,
|
|
157
157
|
status: int,
|
|
158
158
|
body: bytes,
|
|
159
|
-
headers:
|
|
160
|
-
media_type:
|
|
159
|
+
headers: dict[str, str],
|
|
160
|
+
media_type: str | None,
|
|
161
161
|
) -> None:
|
|
162
162
|
entry = self.get(key)
|
|
163
163
|
if not entry:
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated, Any
|
|
4
5
|
|
|
5
6
|
from fastapi import Header, HTTPException
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
async def require_if_match(
|
|
9
|
-
version: Annotated[
|
|
10
|
+
version: Annotated[str | None, Header(alias="If-Match")] = None,
|
|
10
11
|
) -> str:
|
|
11
12
|
"""Require If-Match header for optimistic locking on mutating operations.
|
|
12
13
|
|
|
@@ -31,12 +32,8 @@ def check_version_or_409(get_current_version: Callable[[], Any], provided: str)
|
|
|
31
32
|
try:
|
|
32
33
|
p = int(provided)
|
|
33
34
|
except Exception:
|
|
34
|
-
raise HTTPException(
|
|
35
|
-
status_code=400, detail="Invalid If-Match value; expected integer."
|
|
36
|
-
)
|
|
35
|
+
raise HTTPException(status_code=400, detail="Invalid If-Match value; expected integer.")
|
|
37
36
|
else:
|
|
38
37
|
p = provided
|
|
39
38
|
if p != current:
|
|
40
|
-
raise HTTPException(
|
|
41
|
-
status_code=409, detail="Version mismatch (optimistic locking)."
|
|
42
|
-
)
|
|
39
|
+
raise HTTPException(status_code=409, detail="Version mismatch (optimistic locking).")
|
|
@@ -23,6 +23,9 @@ class SimpleRateLimitMiddleware:
|
|
|
23
23
|
|
|
24
24
|
Applies per-key rate limits with configurable windows. Use skip_paths for
|
|
25
25
|
endpoints that should bypass rate limiting (e.g., health checks, webhooks).
|
|
26
|
+
|
|
27
|
+
Matching uses prefix matching: "/v1/chat" matches "/v1/chat", "/v1/chat/stream",
|
|
28
|
+
but not "/api/v1/chat" or "/v1/chatter".
|
|
26
29
|
"""
|
|
27
30
|
|
|
28
31
|
def __init__(
|
|
@@ -60,8 +63,8 @@ class SimpleRateLimitMiddleware:
|
|
|
60
63
|
|
|
61
64
|
path = scope.get("path", "")
|
|
62
65
|
|
|
63
|
-
# Skip specified paths
|
|
64
|
-
if any(skip
|
|
66
|
+
# Skip specified paths using prefix matching
|
|
67
|
+
if any(path.startswith(skip) for skip in self.skip_paths):
|
|
65
68
|
await self.app(scope, receive, send)
|
|
66
69
|
return
|
|
67
70
|
|
|
@@ -70,8 +73,7 @@ class SimpleRateLimitMiddleware:
|
|
|
70
73
|
|
|
71
74
|
# Default key function
|
|
72
75
|
key_fn = self.key_fn or (
|
|
73
|
-
lambda r: r.headers.get("X-API-Key")
|
|
74
|
-
or (r.client.host if r.client else "unknown")
|
|
76
|
+
lambda r: r.headers.get("X-API-Key") or (r.client.host if r.client else "unknown")
|
|
75
77
|
)
|
|
76
78
|
|
|
77
79
|
# Resolve tenant when possible
|
|
@@ -85,9 +87,7 @@ class SimpleRateLimitMiddleware:
|
|
|
85
87
|
# Fallback header behavior - ONLY if explicitly allowed
|
|
86
88
|
# Never trust untrusted headers by default to prevent rate limit evasion
|
|
87
89
|
if not tenant_id and self._allow_untrusted_tenant_header:
|
|
88
|
-
tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get(
|
|
89
|
-
"X-Tenant-ID"
|
|
90
|
-
)
|
|
90
|
+
tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get("X-Tenant-ID")
|
|
91
91
|
|
|
92
92
|
key = key_fn(request)
|
|
93
93
|
if self.scope_by_tenant and tenant_id:
|
|
@@ -103,7 +103,7 @@ class SimpleRateLimitMiddleware:
|
|
|
103
103
|
eff_limit = self.limit
|
|
104
104
|
|
|
105
105
|
now = int(time.time())
|
|
106
|
-
count,
|
|
106
|
+
count, _store_limit, reset = self.store.incr(str(key), self.window)
|
|
107
107
|
limit = eff_limit
|
|
108
108
|
remaining = max(0, limit - count)
|
|
109
109
|
|
|
@@ -4,7 +4,8 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import time
|
|
6
6
|
import warnings
|
|
7
|
-
from
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Protocol
|
|
8
9
|
|
|
9
10
|
logger = logging.getLogger(__name__)
|
|
10
11
|
|
|
@@ -29,7 +30,7 @@ def _check_inmemory_production_warning(class_name: str) -> None:
|
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
class RateLimitStore(Protocol):
|
|
32
|
-
def incr(self, key: str, window: int) ->
|
|
33
|
+
def incr(self, key: str, window: int) -> tuple[int, int, int]:
|
|
33
34
|
"""Increment and return (count, limit, resetEpoch).
|
|
34
35
|
|
|
35
36
|
Implementations should manage per-window buckets. The 'limit' is stored configuration.
|
|
@@ -44,7 +45,7 @@ class InMemoryRateLimitStore:
|
|
|
44
45
|
# Track per-key rolling windows: key -> (count, window_start_epoch)
|
|
45
46
|
self._state: dict[str, tuple[int, float]] = {}
|
|
46
47
|
|
|
47
|
-
def incr(self, key: str, window: int) ->
|
|
48
|
+
def incr(self, key: str, window: int) -> tuple[int, int, int]:
|
|
48
49
|
now = time.time()
|
|
49
50
|
count, window_start = self._state.get(key, (0, now))
|
|
50
51
|
# If outside the rolling window, reset
|
|
@@ -74,7 +75,7 @@ class RedisRateLimitStore:
|
|
|
74
75
|
*,
|
|
75
76
|
limit: int = 120,
|
|
76
77
|
prefix: str = "ratelimit",
|
|
77
|
-
clock:
|
|
78
|
+
clock: Callable[[], float] | None = None,
|
|
78
79
|
):
|
|
79
80
|
self.redis = redis_client
|
|
80
81
|
self.limit = limit
|
|
@@ -87,16 +88,14 @@ class RedisRateLimitStore:
|
|
|
87
88
|
redis_key = f"{self.prefix}:{key}:{win}"
|
|
88
89
|
return redis_key, win, now
|
|
89
90
|
|
|
90
|
-
def incr(self, key: str, window: int) ->
|
|
91
|
+
def incr(self, key: str, window: int) -> tuple[int, int, int]:
|
|
91
92
|
rkey, win, now = self._window_key(key, window)
|
|
92
93
|
# Increment; if this is the first time we've seen this window key, set expiry to window end
|
|
93
94
|
pipe = self.redis.pipeline()
|
|
94
95
|
pipe.incr(rkey)
|
|
95
96
|
pipe.ttl(rkey)
|
|
96
97
|
count, ttl = pipe.execute()
|
|
97
|
-
if
|
|
98
|
-
ttl == -1
|
|
99
|
-
): # key exists without expire or just created; set expire to end of window
|
|
98
|
+
if ttl == -1: # key exists without expire or just created; set expire to end of window
|
|
100
99
|
expire_sec = (win + window) - now
|
|
101
100
|
if expire_sec <= 0:
|
|
102
101
|
expire_sec = window
|
|
@@ -4,9 +4,7 @@ from uuid import uuid4
|
|
|
4
4
|
from starlette.datastructures import Headers, MutableHeaders
|
|
5
5
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
6
6
|
|
|
7
|
-
request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
8
|
-
"request_id", default=""
|
|
9
|
-
)
|
|
7
|
+
request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="")
|
|
10
8
|
|
|
11
9
|
|
|
12
10
|
class RequestIdMiddleware:
|
|
@@ -37,6 +37,9 @@ class HandlerTimeoutMiddleware:
|
|
|
37
37
|
|
|
38
38
|
Use skip_paths for endpoints that may run longer than the timeout
|
|
39
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".
|
|
40
43
|
"""
|
|
41
44
|
|
|
42
45
|
def __init__(
|
|
@@ -58,8 +61,8 @@ class HandlerTimeoutMiddleware:
|
|
|
58
61
|
|
|
59
62
|
path = scope.get("path", "")
|
|
60
63
|
|
|
61
|
-
# Skip specified paths (e.g., long-running endpoints)
|
|
62
|
-
if any(skip
|
|
64
|
+
# Skip specified paths using prefix matching (e.g., long-running endpoints)
|
|
65
|
+
if any(path.startswith(skip) for skip in self.skip_paths):
|
|
63
66
|
await self.app(scope, receive, send)
|
|
64
67
|
return
|
|
65
68
|
|
|
@@ -77,7 +80,7 @@ class HandlerTimeoutMiddleware:
|
|
|
77
80
|
self.app(scope, receive, send_wrapper), # type: ignore[arg-type] # ASGI send signature
|
|
78
81
|
timeout=self.timeout_seconds,
|
|
79
82
|
)
|
|
80
|
-
except
|
|
83
|
+
except TimeoutError:
|
|
81
84
|
# Only send 504 if response hasn't started yet
|
|
82
85
|
if not response_started:
|
|
83
86
|
response = problem_response(
|
|
@@ -98,9 +101,7 @@ class BodyReadTimeoutMiddleware:
|
|
|
98
101
|
def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
|
|
99
102
|
self.app = app
|
|
100
103
|
self.timeout_seconds = (
|
|
101
|
-
timeout_seconds
|
|
102
|
-
if timeout_seconds is not None
|
|
103
|
-
else REQUEST_BODY_TIMEOUT_SECONDS
|
|
104
|
+
timeout_seconds if timeout_seconds is not None else REQUEST_BODY_TIMEOUT_SECONDS
|
|
104
105
|
)
|
|
105
106
|
|
|
106
107
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
@@ -116,9 +117,7 @@ class BodyReadTimeoutMiddleware:
|
|
|
116
117
|
|
|
117
118
|
try:
|
|
118
119
|
while True:
|
|
119
|
-
message = await asyncio.wait_for(
|
|
120
|
-
receive(), timeout=self.timeout_seconds
|
|
121
|
-
)
|
|
120
|
+
message = await asyncio.wait_for(receive(), timeout=self.timeout_seconds)
|
|
122
121
|
|
|
123
122
|
mtype = message.get("type")
|
|
124
123
|
if mtype == "http.request":
|
|
@@ -136,7 +135,7 @@ class BodyReadTimeoutMiddleware:
|
|
|
136
135
|
# will see an empty body. No timeout response needed here.
|
|
137
136
|
break
|
|
138
137
|
# Ignore other message types and continue
|
|
139
|
-
except
|
|
138
|
+
except TimeoutError:
|
|
140
139
|
# Timed out while waiting for the next body chunk → return 408
|
|
141
140
|
request = Request(scope, receive=receive)
|
|
142
141
|
trace_id = None
|