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,81 +1,203 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import hashlib
|
|
3
|
+
import json
|
|
2
4
|
import time
|
|
3
5
|
from typing import Annotated
|
|
4
6
|
|
|
5
7
|
from fastapi import Header, HTTPException, Request
|
|
6
|
-
from starlette.
|
|
7
|
-
from starlette.responses import Response
|
|
8
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
8
9
|
|
|
10
|
+
from .idempotency_store import IdempotencyStore, InMemoryIdempotencyStore
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
class IdempotencyMiddleware:
|
|
14
|
+
"""
|
|
15
|
+
Pure ASGI idempotency middleware.
|
|
16
|
+
|
|
17
|
+
Caches responses for requests with Idempotency-Key header to ensure
|
|
18
|
+
duplicate requests return the same response. Use skip_paths for endpoints
|
|
19
|
+
where idempotency caching is not appropriate (e.g., streaming responses).
|
|
20
|
+
|
|
21
|
+
Matching uses prefix matching: "/v1/chat" matches "/v1/chat", "/v1/chat/stream",
|
|
22
|
+
but not "/api/v1/chat" or "/v1/chatter".
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
app: ASGIApp,
|
|
28
|
+
ttl_seconds: int = 24 * 3600,
|
|
29
|
+
store: IdempotencyStore | None = None,
|
|
30
|
+
header_name: str = "Idempotency-Key",
|
|
31
|
+
skip_paths: list[str] | None = None,
|
|
32
|
+
):
|
|
33
|
+
self.app = app
|
|
13
34
|
self.ttl = ttl_seconds
|
|
14
|
-
self.store = store or
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
async def _read():
|
|
22
|
-
data = await request.body()
|
|
23
|
-
request._body = data # stash for downstream
|
|
24
|
-
return data
|
|
25
|
-
|
|
26
|
-
# read once
|
|
27
|
-
# note: starlette Request is awaitable; we read in dispatch below
|
|
28
|
-
|
|
29
|
-
sig = hashlib.sha256(
|
|
30
|
-
(
|
|
31
|
-
request.method + "|" + request.url.path + "|" + idkey + "|" + (request._body or b"")
|
|
32
|
-
).encode()
|
|
33
|
-
if isinstance(request._body, str)
|
|
34
|
-
else (request.method + "|" + request.url.path + "|" + idkey).encode()
|
|
35
|
-
+ (request._body or b"")
|
|
36
|
-
).hexdigest()
|
|
35
|
+
self.store: IdempotencyStore = store or InMemoryIdempotencyStore()
|
|
36
|
+
self.header_name = header_name.lower()
|
|
37
|
+
self.skip_paths = skip_paths or []
|
|
38
|
+
|
|
39
|
+
def _cache_key(self, method: str, path: str, idkey: str) -> str:
|
|
40
|
+
sig = hashlib.sha256((method + "|" + path + "|" + idkey).encode()).hexdigest()
|
|
37
41
|
return f"idmp:{sig}"
|
|
38
42
|
|
|
39
|
-
async def
|
|
40
|
-
if
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
43
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
44
|
+
if scope.get("type") != "http":
|
|
45
|
+
await self.app(scope, receive, send)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
path = scope.get("path", "")
|
|
49
|
+
method = scope.get("method", "GET")
|
|
50
|
+
|
|
51
|
+
# Skip specified paths using prefix matching
|
|
52
|
+
if any(path.startswith(skip) for skip in self.skip_paths):
|
|
53
|
+
await self.app(scope, receive, send)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Only apply to mutating methods
|
|
57
|
+
if method not in {"POST", "PATCH", "DELETE"}:
|
|
58
|
+
await self.app(scope, receive, send)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Get idempotency key from headers
|
|
62
|
+
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
|
63
|
+
idkey = headers.get(self.header_name)
|
|
64
|
+
|
|
65
|
+
if not idkey:
|
|
66
|
+
# No idempotency key - pass through
|
|
67
|
+
await self.app(scope, receive, send)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
# Buffer the request body
|
|
71
|
+
body_parts = []
|
|
72
|
+
while True:
|
|
73
|
+
message = await receive()
|
|
74
|
+
if message["type"] == "http.request":
|
|
75
|
+
body_parts.append(message.get("body", b"") or b"")
|
|
76
|
+
if not message.get("more_body", False):
|
|
77
|
+
break
|
|
78
|
+
elif message["type"] == "http.disconnect":
|
|
79
|
+
break
|
|
80
|
+
body = b"".join(body_parts)
|
|
81
|
+
|
|
82
|
+
k = self._cache_key(method, path, idkey)
|
|
83
|
+
now = time.time()
|
|
84
|
+
req_hash = hashlib.sha256(body).hexdigest()
|
|
85
|
+
|
|
86
|
+
existing = self.store.get(k)
|
|
87
|
+
if existing and existing.exp > now:
|
|
88
|
+
# If payload mismatches, return conflict
|
|
89
|
+
if existing.req_hash and existing.req_hash != req_hash:
|
|
90
|
+
await self._send_json_response(
|
|
91
|
+
send,
|
|
92
|
+
409,
|
|
93
|
+
{
|
|
94
|
+
"type": "about:blank",
|
|
95
|
+
"title": "Conflict",
|
|
96
|
+
"detail": "Idempotency-Key re-used with different request payload.",
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
# If response cached and payload matches, replay it
|
|
101
|
+
if existing.status is not None and existing.body_b64 is not None:
|
|
102
|
+
await self._send_cached_response(send, existing)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Claim the key
|
|
106
|
+
exp = now + self.ttl
|
|
107
|
+
created = self.store.set_initial(k, req_hash, exp)
|
|
108
|
+
if not created:
|
|
109
|
+
existing = self.store.get(k)
|
|
110
|
+
if existing and existing.req_hash and existing.req_hash != req_hash:
|
|
111
|
+
await self._send_json_response(
|
|
112
|
+
send,
|
|
113
|
+
409,
|
|
114
|
+
{
|
|
115
|
+
"type": "about:blank",
|
|
116
|
+
"title": "Conflict",
|
|
117
|
+
"detail": "Idempotency-Key re-used with different request payload.",
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
if existing and existing.status is not None and existing.body_b64 is not None:
|
|
122
|
+
await self._send_cached_response(send, existing)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Create a replay receive that returns buffered body
|
|
126
|
+
# IMPORTANT: After replaying the body, we must forward to original receive()
|
|
127
|
+
# so that Starlette's listen_for_disconnect can properly detect client disconnects.
|
|
128
|
+
# This is required for streaming responses on ASGI spec < 2.4.
|
|
129
|
+
body_sent = False
|
|
130
|
+
|
|
131
|
+
async def replay_receive():
|
|
132
|
+
nonlocal body_sent
|
|
133
|
+
if not body_sent:
|
|
134
|
+
body_sent = True
|
|
135
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
136
|
+
# After body is sent, forward to original receive for disconnect detection
|
|
137
|
+
return await receive()
|
|
138
|
+
|
|
139
|
+
# Capture response for caching
|
|
140
|
+
response_started = False
|
|
141
|
+
response_status = 0
|
|
142
|
+
response_headers: list = []
|
|
143
|
+
response_body_parts = []
|
|
144
|
+
|
|
145
|
+
async def capture_send(message):
|
|
146
|
+
nonlocal response_started, response_status, response_headers
|
|
147
|
+
if message["type"] == "http.response.start":
|
|
148
|
+
response_started = True
|
|
149
|
+
response_status = message.get("status", 200)
|
|
150
|
+
response_headers = list(message.get("headers", []))
|
|
151
|
+
elif message["type"] == "http.response.body":
|
|
152
|
+
body_chunk = message.get("body", b"")
|
|
153
|
+
if body_chunk:
|
|
154
|
+
response_body_parts.append(body_chunk)
|
|
155
|
+
await send(message)
|
|
156
|
+
|
|
157
|
+
await self.app(scope, replay_receive, capture_send)
|
|
158
|
+
|
|
159
|
+
# Cache successful responses
|
|
160
|
+
if 200 <= response_status < 300:
|
|
161
|
+
response_body = b"".join(response_body_parts)
|
|
162
|
+
headers_dict = {k.decode(): v.decode() for k, v in response_headers}
|
|
163
|
+
media_type = headers_dict.get("content-type", "application/octet-stream")
|
|
164
|
+
self.store.set_response(
|
|
165
|
+
k,
|
|
166
|
+
status=response_status,
|
|
167
|
+
body=response_body,
|
|
168
|
+
headers=headers_dict,
|
|
169
|
+
media_type=media_type,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def _send_json_response(self, send, status: int, content: dict) -> None:
|
|
173
|
+
body = json.dumps(content).encode("utf-8")
|
|
174
|
+
await send(
|
|
175
|
+
{
|
|
176
|
+
"type": "http.response.start",
|
|
177
|
+
"status": status,
|
|
178
|
+
"headers": [(b"content-type", b"application/json")],
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
182
|
+
|
|
183
|
+
async def _send_cached_response(self, send, existing) -> None:
|
|
184
|
+
headers = [(k.encode(), v.encode()) for k, v in (existing.headers or {}).items()]
|
|
185
|
+
if existing.media_type:
|
|
186
|
+
headers.append((b"content-type", existing.media_type.encode()))
|
|
187
|
+
await send(
|
|
188
|
+
{
|
|
189
|
+
"type": "http.response.start",
|
|
190
|
+
"status": existing.status,
|
|
191
|
+
"headers": headers,
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
await send(
|
|
195
|
+
{
|
|
196
|
+
"type": "http.response.body",
|
|
197
|
+
"body": base64.b64decode(existing.body_b64),
|
|
198
|
+
"more_body": False,
|
|
199
|
+
}
|
|
200
|
+
)
|
|
79
201
|
|
|
80
202
|
|
|
81
203
|
async def require_idempotency_key(
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class IdempotencyEntry:
|
|
12
|
+
req_hash: str
|
|
13
|
+
exp: float
|
|
14
|
+
# Optional response fields when available
|
|
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
|
+
|
|
20
|
+
|
|
21
|
+
class IdempotencyStore(Protocol):
|
|
22
|
+
def get(self, key: str) -> IdempotencyEntry | None:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
|
|
26
|
+
"""Atomically create an entry if absent. Returns True if created, False if already exists."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def set_response(
|
|
30
|
+
self,
|
|
31
|
+
key: str,
|
|
32
|
+
*,
|
|
33
|
+
status: int,
|
|
34
|
+
body: bytes,
|
|
35
|
+
headers: dict[str, str],
|
|
36
|
+
media_type: str | None,
|
|
37
|
+
) -> None:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def delete(self, key: str) -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InMemoryIdempotencyStore:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self._store: dict[str, IdempotencyEntry] = {}
|
|
47
|
+
|
|
48
|
+
def get(self, key: str) -> IdempotencyEntry | None:
|
|
49
|
+
entry = self._store.get(key)
|
|
50
|
+
if not entry:
|
|
51
|
+
return None
|
|
52
|
+
# expire lazily
|
|
53
|
+
if entry.exp <= time.time():
|
|
54
|
+
self._store.pop(key, None)
|
|
55
|
+
return None
|
|
56
|
+
return entry
|
|
57
|
+
|
|
58
|
+
def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
|
|
59
|
+
now = time.time()
|
|
60
|
+
existing = self._store.get(key)
|
|
61
|
+
if existing and existing.exp > now:
|
|
62
|
+
return False
|
|
63
|
+
self._store[key] = IdempotencyEntry(req_hash=req_hash, exp=exp)
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
def set_response(
|
|
67
|
+
self,
|
|
68
|
+
key: str,
|
|
69
|
+
*,
|
|
70
|
+
status: int,
|
|
71
|
+
body: bytes,
|
|
72
|
+
headers: dict[str, str],
|
|
73
|
+
media_type: str | None,
|
|
74
|
+
) -> None:
|
|
75
|
+
entry = self._store.get(key)
|
|
76
|
+
if not entry:
|
|
77
|
+
# Create if missing to ensure replay works until exp
|
|
78
|
+
entry = IdempotencyEntry(req_hash="", exp=time.time() + 60)
|
|
79
|
+
self._store[key] = entry
|
|
80
|
+
entry.status = status
|
|
81
|
+
entry.body_b64 = base64.b64encode(body).decode()
|
|
82
|
+
entry.headers = dict(headers)
|
|
83
|
+
entry.media_type = media_type
|
|
84
|
+
|
|
85
|
+
def delete(self, key: str) -> None:
|
|
86
|
+
self._store.pop(key, None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RedisIdempotencyStore:
|
|
90
|
+
"""A simple Redis-backed store.
|
|
91
|
+
|
|
92
|
+
Notes:
|
|
93
|
+
- Uses GET/SET with JSON payload; initial claim uses SETNX semantics.
|
|
94
|
+
- Not fully atomic for response update; sufficient for basic dedupe.
|
|
95
|
+
- For strict guarantees, replace with a Lua script (future improvement).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, redis_client, *, prefix: str = "idmp"):
|
|
99
|
+
self.r = redis_client
|
|
100
|
+
self.prefix = prefix
|
|
101
|
+
|
|
102
|
+
def _k(self, key: str) -> str:
|
|
103
|
+
return f"{self.prefix}:{key}"
|
|
104
|
+
|
|
105
|
+
def get(self, key: str) -> IdempotencyEntry | None:
|
|
106
|
+
raw = self.r.get(self._k(key))
|
|
107
|
+
if not raw:
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(raw)
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
entry = IdempotencyEntry(
|
|
114
|
+
req_hash=data.get("req_hash", ""),
|
|
115
|
+
exp=float(data.get("exp", 0)),
|
|
116
|
+
status=data.get("status"),
|
|
117
|
+
body_b64=data.get("body_b64"),
|
|
118
|
+
headers=data.get("headers"),
|
|
119
|
+
media_type=data.get("media_type"),
|
|
120
|
+
)
|
|
121
|
+
if entry.exp <= time.time():
|
|
122
|
+
try:
|
|
123
|
+
self.r.delete(self._k(key))
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return None
|
|
127
|
+
return entry
|
|
128
|
+
|
|
129
|
+
def set_initial(self, key: str, req_hash: str, exp: float) -> bool:
|
|
130
|
+
payload = json.dumps({"req_hash": req_hash, "exp": exp})
|
|
131
|
+
# Attempt NX set
|
|
132
|
+
ok = self.r.set(self._k(key), payload, nx=True)
|
|
133
|
+
# If set, also set TTL (expire at exp)
|
|
134
|
+
if ok:
|
|
135
|
+
ttl = max(1, int(exp - time.time()))
|
|
136
|
+
try:
|
|
137
|
+
self.r.expire(self._k(key), ttl)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return True
|
|
141
|
+
# If exists but expired, overwrite
|
|
142
|
+
entry = self.get(key)
|
|
143
|
+
if not entry:
|
|
144
|
+
self.r.set(self._k(key), payload)
|
|
145
|
+
ttl = max(1, int(exp - time.time()))
|
|
146
|
+
try:
|
|
147
|
+
self.r.expire(self._k(key), ttl)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
def set_response(
|
|
154
|
+
self,
|
|
155
|
+
key: str,
|
|
156
|
+
*,
|
|
157
|
+
status: int,
|
|
158
|
+
body: bytes,
|
|
159
|
+
headers: dict[str, str],
|
|
160
|
+
media_type: str | None,
|
|
161
|
+
) -> None:
|
|
162
|
+
entry = self.get(key)
|
|
163
|
+
if not entry:
|
|
164
|
+
# default short ttl if missing; caller should have set initial
|
|
165
|
+
entry = IdempotencyEntry(req_hash="", exp=time.time() + 60)
|
|
166
|
+
entry.status = status
|
|
167
|
+
entry.body_b64 = base64.b64encode(body).decode()
|
|
168
|
+
entry.headers = dict(headers)
|
|
169
|
+
entry.media_type = media_type
|
|
170
|
+
ttl = max(1, int(entry.exp - time.time()))
|
|
171
|
+
payload = json.dumps(
|
|
172
|
+
{
|
|
173
|
+
"req_hash": entry.req_hash,
|
|
174
|
+
"exp": entry.exp,
|
|
175
|
+
"status": entry.status,
|
|
176
|
+
"body_b64": entry.body_b64,
|
|
177
|
+
"headers": entry.headers,
|
|
178
|
+
"media_type": entry.media_type,
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
self.r.set(self._k(key), payload, ex=ttl)
|
|
182
|
+
|
|
183
|
+
def delete(self, key: str) -> None:
|
|
184
|
+
try:
|
|
185
|
+
self.r.delete(self._k(key))
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastapi import Header, HTTPException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def require_if_match(
|
|
10
|
+
version: Annotated[str | None, Header(alias="If-Match")] = None,
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Require If-Match header for optimistic locking on mutating operations.
|
|
13
|
+
|
|
14
|
+
Returns the header value. Raises 428 if missing.
|
|
15
|
+
"""
|
|
16
|
+
if not version:
|
|
17
|
+
raise HTTPException(
|
|
18
|
+
status_code=428, detail="Missing If-Match header for optimistic locking."
|
|
19
|
+
)
|
|
20
|
+
return version
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_version_or_409(get_current_version: Callable[[], Any], provided: str) -> None:
|
|
24
|
+
"""Compare provided version with current version; raise 409 on mismatch.
|
|
25
|
+
|
|
26
|
+
- get_current_version: callable returning the resource's current version (int/str)
|
|
27
|
+
- provided: header value; attempts to coerce to int if current is int
|
|
28
|
+
"""
|
|
29
|
+
current = get_current_version()
|
|
30
|
+
p: int | str
|
|
31
|
+
if isinstance(current, int):
|
|
32
|
+
try:
|
|
33
|
+
p = int(provided)
|
|
34
|
+
except Exception:
|
|
35
|
+
raise HTTPException(status_code=400, detail="Invalid If-Match value; expected integer.")
|
|
36
|
+
else:
|
|
37
|
+
p = provided
|
|
38
|
+
if p != current:
|
|
39
|
+
raise HTTPException(status_code=409, detail="Version mismatch (optimistic locking).")
|
|
@@ -1,61 +1,158 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import time
|
|
2
3
|
|
|
3
|
-
from
|
|
4
|
-
from starlette.
|
|
4
|
+
from fastapi import Request
|
|
5
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
5
6
|
|
|
6
7
|
from svc_infra.obs.metrics import emit_rate_limited
|
|
7
8
|
|
|
8
9
|
from .ratelimit_store import InMemoryRateLimitStore, RateLimitStore
|
|
9
10
|
|
|
11
|
+
try:
|
|
12
|
+
# Optional import: tenancy may not be enabled in all apps
|
|
13
|
+
from svc_infra.api.fastapi.tenancy.context import (
|
|
14
|
+
resolve_tenant_id as _resolve_tenant_id,
|
|
15
|
+
)
|
|
16
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
17
|
+
_resolve_tenant_id = None # type: ignore[assignment]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SimpleRateLimitMiddleware:
|
|
21
|
+
"""
|
|
22
|
+
Pure ASGI rate limiting middleware.
|
|
23
|
+
|
|
24
|
+
Applies per-key rate limits with configurable windows. Use skip_paths for
|
|
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".
|
|
29
|
+
"""
|
|
10
30
|
|
|
11
|
-
class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
|
|
12
31
|
def __init__(
|
|
13
32
|
self,
|
|
14
|
-
app,
|
|
33
|
+
app: ASGIApp,
|
|
15
34
|
limit: int = 120,
|
|
16
35
|
window: int = 60,
|
|
17
36
|
key_fn=None,
|
|
37
|
+
*,
|
|
38
|
+
# When provided, dynamically computes a limit for the current request (e.g. per-tenant quotas)
|
|
39
|
+
# Signature: (request: Request, tenant_id: Optional[str]) -> int | None
|
|
40
|
+
limit_resolver=None,
|
|
41
|
+
# If True, automatically scopes the bucket key by tenant id when available
|
|
42
|
+
scope_by_tenant: bool = False,
|
|
43
|
+
# When True, allows unresolved tenant IDs to fall back to an "X-Tenant-Id" header value.
|
|
44
|
+
# Disabled by default to avoid trusting arbitrary client-provided headers which could
|
|
45
|
+
# otherwise be used to evade per-tenant limits when authentication fails.
|
|
46
|
+
allow_untrusted_tenant_header: bool = False,
|
|
18
47
|
store: RateLimitStore | None = None,
|
|
48
|
+
skip_paths: list[str] | None = None,
|
|
19
49
|
):
|
|
20
|
-
|
|
50
|
+
self.app = app
|
|
21
51
|
self.limit, self.window = limit, window
|
|
22
|
-
self.key_fn = key_fn
|
|
52
|
+
self.key_fn = key_fn
|
|
53
|
+
self._limit_resolver = limit_resolver
|
|
54
|
+
self.scope_by_tenant = scope_by_tenant
|
|
55
|
+
self._allow_untrusted_tenant_header = allow_untrusted_tenant_header
|
|
23
56
|
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
57
|
+
self.skip_paths = skip_paths or []
|
|
58
|
+
|
|
59
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
60
|
+
if scope.get("type") != "http":
|
|
61
|
+
await self.app(scope, receive, send)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
path = scope.get("path", "")
|
|
65
|
+
|
|
66
|
+
# Skip specified paths using prefix matching
|
|
67
|
+
if any(path.startswith(skip) for skip in self.skip_paths):
|
|
68
|
+
await self.app(scope, receive, send)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Create a Request object for key extraction and tenant resolution
|
|
72
|
+
request = Request(scope, receive)
|
|
73
|
+
|
|
74
|
+
# Default key function
|
|
75
|
+
key_fn = self.key_fn or (
|
|
76
|
+
lambda r: r.headers.get("X-API-Key") or (r.client.host if r.client else "unknown")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Resolve tenant when possible
|
|
80
|
+
tenant_id = None
|
|
81
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
82
|
+
try:
|
|
83
|
+
if _resolve_tenant_id is not None:
|
|
84
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
85
|
+
except Exception:
|
|
86
|
+
tenant_id = None
|
|
87
|
+
# Fallback header behavior - ONLY if explicitly allowed
|
|
88
|
+
# Never trust untrusted headers by default to prevent rate limit evasion
|
|
89
|
+
if not tenant_id and self._allow_untrusted_tenant_header:
|
|
90
|
+
tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get("X-Tenant-ID")
|
|
91
|
+
|
|
92
|
+
key = key_fn(request)
|
|
93
|
+
if self.scope_by_tenant and tenant_id:
|
|
94
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
95
|
+
|
|
96
|
+
# Allow dynamic limit overrides
|
|
97
|
+
eff_limit = self.limit
|
|
98
|
+
if self._limit_resolver:
|
|
99
|
+
try:
|
|
100
|
+
v = self._limit_resolver(request, tenant_id)
|
|
101
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
102
|
+
except Exception:
|
|
103
|
+
eff_limit = self.limit
|
|
24
104
|
|
|
25
|
-
async def dispatch(self, request, call_next):
|
|
26
|
-
key = self.key_fn(request)
|
|
27
105
|
now = int(time.time())
|
|
28
|
-
|
|
29
|
-
|
|
106
|
+
count, _store_limit, reset = self.store.incr(str(key), self.window)
|
|
107
|
+
limit = eff_limit
|
|
30
108
|
remaining = max(0, limit - count)
|
|
31
109
|
|
|
32
|
-
if remaining < 0: # defensive clamp
|
|
33
|
-
remaining = 0
|
|
34
|
-
|
|
35
110
|
if count > limit:
|
|
111
|
+
# Rate limited - return 429
|
|
36
112
|
retry = max(0, reset - now)
|
|
37
113
|
try:
|
|
38
114
|
emit_rate_limited(str(key), limit, retry)
|
|
39
115
|
except Exception:
|
|
40
116
|
pass
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
117
|
+
|
|
118
|
+
body = json.dumps(
|
|
119
|
+
{
|
|
44
120
|
"title": "Too Many Requests",
|
|
45
121
|
"status": 429,
|
|
46
122
|
"detail": "Rate limit exceeded.",
|
|
47
123
|
"code": "RATE_LIMITED",
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
|
|
124
|
+
}
|
|
125
|
+
).encode("utf-8")
|
|
126
|
+
|
|
127
|
+
await send(
|
|
128
|
+
{
|
|
129
|
+
"type": "http.response.start",
|
|
130
|
+
"status": 429,
|
|
131
|
+
"headers": [
|
|
132
|
+
(b"content-type", b"application/json"),
|
|
133
|
+
(b"x-ratelimit-limit", str(limit).encode()),
|
|
134
|
+
(b"x-ratelimit-remaining", b"0"),
|
|
135
|
+
(b"x-ratelimit-reset", str(reset).encode()),
|
|
136
|
+
(b"retry-after", str(retry).encode()),
|
|
137
|
+
],
|
|
138
|
+
}
|
|
55
139
|
)
|
|
140
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# Not rate limited - add headers to response
|
|
144
|
+
async def send_with_headers(message):
|
|
145
|
+
if message["type"] == "http.response.start":
|
|
146
|
+
headers = list(message.get("headers", []))
|
|
147
|
+
# Add rate limit headers if not already present
|
|
148
|
+
header_names = {h[0].lower() for h in headers}
|
|
149
|
+
if b"x-ratelimit-limit" not in header_names:
|
|
150
|
+
headers.append((b"x-ratelimit-limit", str(limit).encode()))
|
|
151
|
+
if b"x-ratelimit-remaining" not in header_names:
|
|
152
|
+
headers.append((b"x-ratelimit-remaining", str(remaining).encode()))
|
|
153
|
+
if b"x-ratelimit-reset" not in header_names:
|
|
154
|
+
headers.append((b"x-ratelimit-reset", str(reset).encode()))
|
|
155
|
+
message = {**message, "headers": headers}
|
|
156
|
+
await send(message)
|
|
56
157
|
|
|
57
|
-
|
|
58
|
-
resp.headers.setdefault("X-RateLimit-Limit", str(limit))
|
|
59
|
-
resp.headers.setdefault("X-RateLimit-Remaining", str(remaining))
|
|
60
|
-
resp.headers.setdefault("X-RateLimit-Reset", str(reset))
|
|
61
|
-
return resp
|
|
158
|
+
await self.app(scope, receive, send_with_headers)
|