svc-infra 0.1.506__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +14 -2
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +116 -0
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -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 +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -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 +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- svc_infra-0.1.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -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 Dict, Optional, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class IdempotencyEntry:
|
|
12
|
+
req_hash: str
|
|
13
|
+
exp: float
|
|
14
|
+
# Optional response fields when available
|
|
15
|
+
status: Optional[int] = None
|
|
16
|
+
body_b64: Optional[str] = None
|
|
17
|
+
headers: Optional[Dict[str, str]] = None
|
|
18
|
+
media_type: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdempotencyStore(Protocol):
|
|
22
|
+
def get(self, key: str) -> Optional[IdempotencyEntry]:
|
|
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: Optional[str],
|
|
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) -> Optional[IdempotencyEntry]:
|
|
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: Optional[str],
|
|
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) -> Optional[IdempotencyEntry]:
|
|
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: Optional[str],
|
|
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,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import Header, HTTPException
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def require_if_match(
|
|
9
|
+
version: Annotated[Optional[str], Header(alias="If-Match")] = None
|
|
10
|
+
) -> str:
|
|
11
|
+
"""Require If-Match header for optimistic locking on mutating operations.
|
|
12
|
+
|
|
13
|
+
Returns the header value. Raises 428 if missing.
|
|
14
|
+
"""
|
|
15
|
+
if not version:
|
|
16
|
+
raise HTTPException(
|
|
17
|
+
status_code=428, detail="Missing If-Match header for optimistic locking."
|
|
18
|
+
)
|
|
19
|
+
return version
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_version_or_409(get_current_version: Callable[[], Any], provided: str) -> None:
|
|
23
|
+
"""Compare provided version with current version; raise 409 on mismatch.
|
|
24
|
+
|
|
25
|
+
- get_current_version: callable returning the resource's current version (int/str)
|
|
26
|
+
- provided: header value; attempts to coerce to int if current is int
|
|
27
|
+
"""
|
|
28
|
+
current = get_current_version()
|
|
29
|
+
if isinstance(current, int):
|
|
30
|
+
try:
|
|
31
|
+
p = int(provided)
|
|
32
|
+
except Exception:
|
|
33
|
+
raise HTTPException(status_code=400, detail="Invalid If-Match value; expected integer.")
|
|
34
|
+
else:
|
|
35
|
+
p = provided
|
|
36
|
+
if p != current:
|
|
37
|
+
raise HTTPException(status_code=409, detail="Version mismatch (optimistic locking).")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
4
|
+
from starlette.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
from svc_infra.obs.metrics import emit_rate_limited
|
|
7
|
+
|
|
8
|
+
from .ratelimit_store import InMemoryRateLimitStore, RateLimitStore
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
# Optional import: tenancy may not be enabled in all apps
|
|
12
|
+
from svc_infra.api.fastapi.tenancy.context import resolve_tenant_id as _resolve_tenant_id
|
|
13
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
14
|
+
_resolve_tenant_id = None # type: ignore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SimpleRateLimitMiddleware(BaseHTTPMiddleware):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
app,
|
|
21
|
+
limit: int = 120,
|
|
22
|
+
window: int = 60,
|
|
23
|
+
key_fn=None,
|
|
24
|
+
*,
|
|
25
|
+
# When provided, dynamically computes a limit for the current request (e.g. per-tenant quotas)
|
|
26
|
+
# Signature: (request: Request, tenant_id: Optional[str]) -> int | None
|
|
27
|
+
limit_resolver=None,
|
|
28
|
+
# If True, automatically scopes the bucket key by tenant id when available
|
|
29
|
+
scope_by_tenant: bool = False,
|
|
30
|
+
# When True, allows unresolved tenant IDs to fall back to an "X-Tenant-Id" header value.
|
|
31
|
+
# Disabled by default to avoid trusting arbitrary client-provided headers which could
|
|
32
|
+
# otherwise be used to evade per-tenant limits when authentication fails.
|
|
33
|
+
allow_untrusted_tenant_header: bool = False,
|
|
34
|
+
store: RateLimitStore | None = None,
|
|
35
|
+
):
|
|
36
|
+
super().__init__(app)
|
|
37
|
+
self.limit, self.window = limit, window
|
|
38
|
+
self.key_fn = key_fn or (lambda r: r.headers.get("X-API-Key") or r.client.host)
|
|
39
|
+
self._limit_resolver = limit_resolver
|
|
40
|
+
self.scope_by_tenant = scope_by_tenant
|
|
41
|
+
self._allow_untrusted_tenant_header = allow_untrusted_tenant_header
|
|
42
|
+
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
43
|
+
|
|
44
|
+
async def dispatch(self, request, call_next):
|
|
45
|
+
# Resolve tenant when possible
|
|
46
|
+
tenant_id = None
|
|
47
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
48
|
+
try:
|
|
49
|
+
if _resolve_tenant_id is not None:
|
|
50
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
51
|
+
except Exception:
|
|
52
|
+
tenant_id = None
|
|
53
|
+
# Fallback header behavior:
|
|
54
|
+
# - If tenancy context is unavailable (minimal builds), accept header by default so
|
|
55
|
+
# unit/integration tests can exercise per-tenant scoping without full auth state.
|
|
56
|
+
# - If tenancy is available, only trust the header when explicitly allowed.
|
|
57
|
+
if not tenant_id:
|
|
58
|
+
if _resolve_tenant_id is None:
|
|
59
|
+
tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get(
|
|
60
|
+
"X-Tenant-ID"
|
|
61
|
+
)
|
|
62
|
+
elif self._allow_untrusted_tenant_header:
|
|
63
|
+
tenant_id = request.headers.get("X-Tenant-Id") or request.headers.get(
|
|
64
|
+
"X-Tenant-ID"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
key = self.key_fn(request)
|
|
68
|
+
if self.scope_by_tenant and tenant_id:
|
|
69
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
70
|
+
|
|
71
|
+
# Allow dynamic limit overrides
|
|
72
|
+
eff_limit = self.limit
|
|
73
|
+
if self._limit_resolver:
|
|
74
|
+
try:
|
|
75
|
+
v = self._limit_resolver(request, tenant_id)
|
|
76
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
77
|
+
except Exception:
|
|
78
|
+
eff_limit = self.limit
|
|
79
|
+
|
|
80
|
+
now = int(time.time())
|
|
81
|
+
# Increment counter in store
|
|
82
|
+
# Update store limit if it differs; stores capture configured limit internally
|
|
83
|
+
# For in-memory store, we can temporarily adjust per-request by swapping a new store instance
|
|
84
|
+
# but to keep API simple, we reuse store and clamp by eff_limit below.
|
|
85
|
+
count, store_limit, reset = self.store.incr(str(key), self.window)
|
|
86
|
+
# Enforce the effective limit selected for this request
|
|
87
|
+
limit = eff_limit
|
|
88
|
+
remaining = max(0, limit - count)
|
|
89
|
+
|
|
90
|
+
if remaining < 0: # defensive clamp
|
|
91
|
+
remaining = 0
|
|
92
|
+
|
|
93
|
+
if count > limit:
|
|
94
|
+
retry = max(0, reset - now)
|
|
95
|
+
try:
|
|
96
|
+
emit_rate_limited(str(key), limit, retry)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return JSONResponse(
|
|
100
|
+
status_code=429,
|
|
101
|
+
content={
|
|
102
|
+
"title": "Too Many Requests",
|
|
103
|
+
"status": 429,
|
|
104
|
+
"detail": "Rate limit exceeded.",
|
|
105
|
+
"code": "RATE_LIMITED",
|
|
106
|
+
},
|
|
107
|
+
headers={
|
|
108
|
+
"X-RateLimit-Limit": str(limit),
|
|
109
|
+
"X-RateLimit-Remaining": "0",
|
|
110
|
+
"X-RateLimit-Reset": str(reset),
|
|
111
|
+
"Retry-After": str(retry),
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
resp = await call_next(request)
|
|
116
|
+
resp.headers.setdefault("X-RateLimit-Limit", str(limit))
|
|
117
|
+
resp.headers.setdefault("X-RateLimit-Remaining", str(remaining))
|
|
118
|
+
resp.headers.setdefault("X-RateLimit-Reset", str(reset))
|
|
119
|
+
return resp
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional, Protocol, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RateLimitStore(Protocol):
|
|
8
|
+
def incr(self, key: str, window: int) -> Tuple[int, int, int]:
|
|
9
|
+
"""Increment and return (count, limit, resetEpoch).
|
|
10
|
+
|
|
11
|
+
Implementations should manage per-window buckets. The 'limit' is stored configuration.
|
|
12
|
+
"""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InMemoryRateLimitStore:
|
|
17
|
+
def __init__(self, limit: int = 120):
|
|
18
|
+
self.limit = limit
|
|
19
|
+
# Track per-key rolling windows: key -> (count, window_start_epoch)
|
|
20
|
+
self._state: dict[str, tuple[int, float]] = {}
|
|
21
|
+
|
|
22
|
+
def incr(self, key: str, window: int) -> Tuple[int, int, int]:
|
|
23
|
+
now = time.time()
|
|
24
|
+
count, window_start = self._state.get(key, (0, now))
|
|
25
|
+
# If outside the rolling window, reset
|
|
26
|
+
if now >= window_start + window:
|
|
27
|
+
count = 1
|
|
28
|
+
window_start = now
|
|
29
|
+
else:
|
|
30
|
+
count += 1
|
|
31
|
+
self._state[key] = (count, window_start)
|
|
32
|
+
reset = int(window_start + window)
|
|
33
|
+
return count, self.limit, reset
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RedisRateLimitStore:
|
|
37
|
+
"""Fixed-window counter store using Redis.
|
|
38
|
+
|
|
39
|
+
Keys are of the form: {prefix}:{key}:{windowStart}
|
|
40
|
+
Values are incremented and expire automatically at window end.
|
|
41
|
+
|
|
42
|
+
This implementation uses atomic INCR and EXPIRE semantics. To avoid race conditions
|
|
43
|
+
on first-set expiry, we set expiry when the counter is created.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
redis_client,
|
|
49
|
+
*,
|
|
50
|
+
limit: int = 120,
|
|
51
|
+
prefix: str = "ratelimit",
|
|
52
|
+
clock: Optional[callable] = None,
|
|
53
|
+
):
|
|
54
|
+
self.redis = redis_client
|
|
55
|
+
self.limit = limit
|
|
56
|
+
self.prefix = prefix
|
|
57
|
+
self._clock = clock or time.time
|
|
58
|
+
|
|
59
|
+
def _window_key(self, key: str, window: int) -> tuple[str, int, str]:
|
|
60
|
+
now = int(self._clock())
|
|
61
|
+
win = now - (now % window)
|
|
62
|
+
redis_key = f"{self.prefix}:{key}:{win}"
|
|
63
|
+
return redis_key, win, now
|
|
64
|
+
|
|
65
|
+
def incr(self, key: str, window: int) -> Tuple[int, int, int]:
|
|
66
|
+
rkey, win, now = self._window_key(key, window)
|
|
67
|
+
# Increment; if this is the first time we've seen this window key, set expiry to window end
|
|
68
|
+
pipe = self.redis.pipeline()
|
|
69
|
+
pipe.incr(rkey)
|
|
70
|
+
pipe.ttl(rkey)
|
|
71
|
+
count, ttl = pipe.execute()
|
|
72
|
+
if ttl == -1: # key exists without expire or just created; set expire to end of window
|
|
73
|
+
expire_sec = (win + window) - now
|
|
74
|
+
if expire_sec <= 0:
|
|
75
|
+
expire_sec = window
|
|
76
|
+
try:
|
|
77
|
+
self.redis.expire(rkey, expire_sec)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
reset = win + window
|
|
81
|
+
return int(count), self.limit, reset
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["RateLimitStore", "InMemoryRateLimitStore", "RedisRateLimitStore"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
|
|
4
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
5
|
+
from starlette.types import ASGIApp
|
|
6
|
+
|
|
7
|
+
request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RequestIdMiddleware(BaseHTTPMiddleware):
|
|
11
|
+
def __init__(self, app: ASGIApp, header_name: str = "X-Request-Id"):
|
|
12
|
+
super().__init__(app)
|
|
13
|
+
self.header_name = header_name
|
|
14
|
+
|
|
15
|
+
async def dispatch(self, request, call_next):
|
|
16
|
+
rid = request.headers.get(self.header_name) or uuid4().hex
|
|
17
|
+
token = request_id_ctx.set(rid)
|
|
18
|
+
try:
|
|
19
|
+
resp = await call_next(request)
|
|
20
|
+
resp.headers[self.header_name] = rid
|
|
21
|
+
return resp
|
|
22
|
+
finally:
|
|
23
|
+
request_id_ctx.reset(token)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
4
|
+
from starlette.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
from svc_infra.obs.metrics import emit_suspect_payload
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
|
10
|
+
def __init__(self, app, max_bytes: int = 1_000_000):
|
|
11
|
+
super().__init__(app)
|
|
12
|
+
self.max_bytes = max_bytes
|
|
13
|
+
|
|
14
|
+
async def dispatch(self, request, call_next):
|
|
15
|
+
length = request.headers.get("content-length")
|
|
16
|
+
try:
|
|
17
|
+
size = int(length) if length is not None else None
|
|
18
|
+
except Exception:
|
|
19
|
+
size = None
|
|
20
|
+
if size is not None and size > self.max_bytes:
|
|
21
|
+
try:
|
|
22
|
+
emit_suspect_payload(
|
|
23
|
+
getattr(request, "url", None).path if hasattr(request, "url") else None, size
|
|
24
|
+
)
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
return JSONResponse(
|
|
28
|
+
status_code=413,
|
|
29
|
+
content={
|
|
30
|
+
"title": "Payload Too Large",
|
|
31
|
+
"status": 413,
|
|
32
|
+
"detail": "Request body exceeds allowed size.",
|
|
33
|
+
"code": "PAYLOAD_TOO_LARGE",
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
return await call_next(request)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from fastapi import Request
|
|
7
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
8
|
+
|
|
9
|
+
from svc_infra.api.fastapi.middleware.errors.handlers import problem_response
|
|
10
|
+
from svc_infra.app.env import pick
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _env_int(name: str, default: int) -> int:
|
|
14
|
+
v = os.getenv(name)
|
|
15
|
+
if v is None:
|
|
16
|
+
return default
|
|
17
|
+
try:
|
|
18
|
+
return int(v)
|
|
19
|
+
except Exception:
|
|
20
|
+
return default
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
REQUEST_BODY_TIMEOUT_SECONDS: int = pick(
|
|
24
|
+
prod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 15),
|
|
25
|
+
nonprod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 30),
|
|
26
|
+
)
|
|
27
|
+
REQUEST_TIMEOUT_SECONDS: int = pick(
|
|
28
|
+
prod=_env_int("REQUEST_TIMEOUT_SECONDS", 30),
|
|
29
|
+
nonprod=_env_int("REQUEST_TIMEOUT_SECONDS", 15),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HandlerTimeoutMiddleware:
|
|
34
|
+
"""
|
|
35
|
+
Caps total handler execution time. If exceeded, returns 504 Problem+JSON.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
|
|
39
|
+
self.app = app
|
|
40
|
+
self.timeout_seconds = (
|
|
41
|
+
timeout_seconds if timeout_seconds is not None else REQUEST_TIMEOUT_SECONDS
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
45
|
+
if scope.get("type") != "http":
|
|
46
|
+
await self.app(scope, receive, send)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
async def _call_next() -> None:
|
|
50
|
+
await self.app(scope, receive, send)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
await asyncio.wait_for(_call_next(), timeout=self.timeout_seconds)
|
|
54
|
+
except asyncio.TimeoutError:
|
|
55
|
+
# Build a minimal Request to extract headers and URL for trace info
|
|
56
|
+
request = Request(scope, receive=receive)
|
|
57
|
+
trace_id = None
|
|
58
|
+
for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
|
|
59
|
+
v = request.headers.get(h)
|
|
60
|
+
if v:
|
|
61
|
+
trace_id = v
|
|
62
|
+
break
|
|
63
|
+
resp = problem_response(
|
|
64
|
+
status=504,
|
|
65
|
+
title="Gateway Timeout",
|
|
66
|
+
detail="The request took too long to complete.",
|
|
67
|
+
code="GATEWAY_TIMEOUT",
|
|
68
|
+
instance=str(request.url),
|
|
69
|
+
trace_id=trace_id,
|
|
70
|
+
)
|
|
71
|
+
await resp(scope, receive, send)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BodyReadTimeoutMiddleware:
|
|
75
|
+
"""
|
|
76
|
+
Enforces a timeout while reading the request body to mitigate slowloris.
|
|
77
|
+
If body read does not make progress within the timeout, returns 408 Problem+JSON.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
|
|
81
|
+
self.app = app
|
|
82
|
+
self.timeout_seconds = (
|
|
83
|
+
timeout_seconds if timeout_seconds is not None else REQUEST_BODY_TIMEOUT_SECONDS
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
87
|
+
if scope.get("type") != "http":
|
|
88
|
+
await self.app(scope, receive, send)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# Strategy: greedily drain the incoming request body here while enforcing
|
|
92
|
+
# per-receive timeout, then replay it to the downstream app from a buffer.
|
|
93
|
+
# This ensures we can detect slowloris-style uploads even if the app only
|
|
94
|
+
# reads the body later (after the server has finished buffering).
|
|
95
|
+
buffered = bytearray()
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
while True:
|
|
99
|
+
message = await asyncio.wait_for(receive(), timeout=self.timeout_seconds)
|
|
100
|
+
|
|
101
|
+
mtype = message.get("type")
|
|
102
|
+
if mtype == "http.request":
|
|
103
|
+
chunk = message.get("body", b"") or b""
|
|
104
|
+
if chunk:
|
|
105
|
+
buffered.extend(chunk)
|
|
106
|
+
# Stop when server indicates no more body
|
|
107
|
+
if not message.get("more_body", False):
|
|
108
|
+
break
|
|
109
|
+
# else: continue reading remaining chunks with timeout
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if mtype == "http.disconnect": # client disconnected mid-upload
|
|
113
|
+
# Treat as end of body for the purposes of replay; downstream
|
|
114
|
+
# will see an empty body. No timeout response needed here.
|
|
115
|
+
break
|
|
116
|
+
# Ignore other message types and continue
|
|
117
|
+
except asyncio.TimeoutError:
|
|
118
|
+
# Timed out while waiting for the next body chunk → return 408
|
|
119
|
+
request = Request(scope, receive=receive)
|
|
120
|
+
trace_id = None
|
|
121
|
+
for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
|
|
122
|
+
v = request.headers.get(h)
|
|
123
|
+
if v:
|
|
124
|
+
trace_id = v
|
|
125
|
+
break
|
|
126
|
+
resp = problem_response(
|
|
127
|
+
status=408,
|
|
128
|
+
title="Request Timeout",
|
|
129
|
+
detail="Timed out while reading request body.",
|
|
130
|
+
code="REQUEST_TIMEOUT",
|
|
131
|
+
instance=str(request.url),
|
|
132
|
+
trace_id=trace_id,
|
|
133
|
+
)
|
|
134
|
+
await resp(scope, receive, send)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# Replay the drained body to the app as a single http.request message.
|
|
138
|
+
sent = False
|
|
139
|
+
|
|
140
|
+
async def _replay_receive() -> dict:
|
|
141
|
+
nonlocal sent
|
|
142
|
+
if not sent:
|
|
143
|
+
sent = True
|
|
144
|
+
return {"type": "http.request", "body": bytes(buffered), "more_body": False}
|
|
145
|
+
# Subsequent calls return an empty terminal body event
|
|
146
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
147
|
+
|
|
148
|
+
await self.app(scope, _replay_receive, send)
|