svc-infra 0.1.600__py3-none-any.whl → 0.1.640__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/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/db/sql/add.py +32 -13
- svc_infra/api/fastapi/db/sql/crud_router.py +178 -16
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- 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/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +11 -1
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -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 +28 -8
- svc_infra/cli/cmds/__init__.py +8 -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 +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- 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/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/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +9 -1
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -2
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- 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/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/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/permissions.py +1 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/METADATA +40 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/RECORD +118 -44
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.640.dist-info}/entry_points.txt +0 -0
|
@@ -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)
|
|
@@ -1102,6 +1102,117 @@ def ensure_success_examples_mutator():
|
|
|
1102
1102
|
return m
|
|
1103
1103
|
|
|
1104
1104
|
|
|
1105
|
+
# --- NEW: attach minimal x-codeSamples for common operations ---
|
|
1106
|
+
def attach_code_samples_mutator():
|
|
1107
|
+
"""Attach minimal curl/httpie x-codeSamples for each operation if missing.
|
|
1108
|
+
|
|
1109
|
+
We avoid templating parameters; samples illustrate method and path only.
|
|
1110
|
+
"""
|
|
1111
|
+
|
|
1112
|
+
def m(schema: dict) -> dict:
|
|
1113
|
+
schema = dict(schema)
|
|
1114
|
+
servers = schema.get("servers") or [{"url": ""}]
|
|
1115
|
+
base = servers[0].get("url") or ""
|
|
1116
|
+
|
|
1117
|
+
for path, method, op in _iter_ops(schema):
|
|
1118
|
+
# Don't override existing samples
|
|
1119
|
+
if isinstance(op.get("x-codeSamples"), list) and op["x-codeSamples"]:
|
|
1120
|
+
continue
|
|
1121
|
+
url = f"{base}{path}"
|
|
1122
|
+
method_up = method.upper()
|
|
1123
|
+
samples = [
|
|
1124
|
+
{
|
|
1125
|
+
"lang": "bash",
|
|
1126
|
+
"label": "curl",
|
|
1127
|
+
"source": f"curl -X {method_up} '{url}'",
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
"lang": "bash",
|
|
1131
|
+
"label": "httpie",
|
|
1132
|
+
"source": f"http {method_up} '{url}'",
|
|
1133
|
+
},
|
|
1134
|
+
]
|
|
1135
|
+
op["x-codeSamples"] = samples
|
|
1136
|
+
return schema
|
|
1137
|
+
|
|
1138
|
+
return m
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
# --- NEW: ensure Problem+JSON examples exist for standard error responses ---
|
|
1142
|
+
def ensure_problem_examples_mutator():
|
|
1143
|
+
"""Add example objects for 4xx/5xx responses using Problem schema if absent."""
|
|
1144
|
+
|
|
1145
|
+
try:
|
|
1146
|
+
# Internal helper with sensible defaults
|
|
1147
|
+
from .conventions import _problem_example # type: ignore
|
|
1148
|
+
except Exception: # pragma: no cover - fallback
|
|
1149
|
+
|
|
1150
|
+
def _problem_example(**kw): # type: ignore
|
|
1151
|
+
base = {
|
|
1152
|
+
"type": "about:blank",
|
|
1153
|
+
"title": "Error",
|
|
1154
|
+
"status": 500,
|
|
1155
|
+
"detail": "An error occurred.",
|
|
1156
|
+
"instance": "/request/trace",
|
|
1157
|
+
"code": "INTERNAL_ERROR",
|
|
1158
|
+
}
|
|
1159
|
+
base.update(kw)
|
|
1160
|
+
return base
|
|
1161
|
+
|
|
1162
|
+
def m(schema: dict) -> dict:
|
|
1163
|
+
schema = dict(schema)
|
|
1164
|
+
for _, _, op in _iter_ops(schema):
|
|
1165
|
+
resps = op.get("responses") or {}
|
|
1166
|
+
for code, resp in resps.items():
|
|
1167
|
+
if not isinstance(resp, dict):
|
|
1168
|
+
continue
|
|
1169
|
+
try:
|
|
1170
|
+
ic = int(code)
|
|
1171
|
+
except Exception:
|
|
1172
|
+
continue
|
|
1173
|
+
if ic < 400:
|
|
1174
|
+
continue
|
|
1175
|
+
# Do not add content if response is a $ref; avoid creating siblings
|
|
1176
|
+
if "$ref" in resp:
|
|
1177
|
+
continue
|
|
1178
|
+
content = resp.setdefault("content", {})
|
|
1179
|
+
# prefer problem+json but also set application/json if present
|
|
1180
|
+
for mt in ("application/problem+json", "application/json"):
|
|
1181
|
+
mt_obj = content.get(mt)
|
|
1182
|
+
if mt_obj is None:
|
|
1183
|
+
# Create a basic media type referencing Problem schema when appropriate
|
|
1184
|
+
if mt == "application/problem+json":
|
|
1185
|
+
mt_obj = {"schema": {"$ref": "#/components/schemas/Problem"}}
|
|
1186
|
+
content[mt] = mt_obj
|
|
1187
|
+
else:
|
|
1188
|
+
continue
|
|
1189
|
+
if not isinstance(mt_obj, dict):
|
|
1190
|
+
continue
|
|
1191
|
+
if "example" in mt_obj or "examples" in mt_obj:
|
|
1192
|
+
continue
|
|
1193
|
+
mt_obj["example"] = _problem_example(status=ic)
|
|
1194
|
+
return schema
|
|
1195
|
+
|
|
1196
|
+
return m
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
# --- NEW: attach default tags from first path segment when missing ---
|
|
1200
|
+
def attach_default_tags_mutator():
|
|
1201
|
+
"""If an operation has no tags, tag it by its first path segment."""
|
|
1202
|
+
|
|
1203
|
+
def m(schema: dict) -> dict:
|
|
1204
|
+
schema = dict(schema)
|
|
1205
|
+
for path, _method, op in _iter_ops(schema):
|
|
1206
|
+
tags = op.get("tags")
|
|
1207
|
+
if tags:
|
|
1208
|
+
continue
|
|
1209
|
+
seg = path.strip("/").split("/", 1)[0] or "root"
|
|
1210
|
+
op["tags"] = [seg]
|
|
1211
|
+
return schema
|
|
1212
|
+
|
|
1213
|
+
return m
|
|
1214
|
+
|
|
1215
|
+
|
|
1105
1216
|
def dedupe_tags_mutator():
|
|
1106
1217
|
def m(schema: dict) -> dict:
|
|
1107
1218
|
schema = dict(schema)
|
|
@@ -1429,6 +1540,9 @@ def setup_mutators(
|
|
|
1429
1540
|
ensure_media_type_schemas_mutator(),
|
|
1430
1541
|
ensure_examples_for_json_mutator(),
|
|
1431
1542
|
ensure_success_examples_mutator(),
|
|
1543
|
+
attach_default_tags_mutator(),
|
|
1544
|
+
attach_code_samples_mutator(),
|
|
1545
|
+
ensure_problem_examples_mutator(),
|
|
1432
1546
|
ensure_media_examples_mutator(),
|
|
1433
1547
|
scrub_invalid_object_examples_mutator(),
|
|
1434
1548
|
normalize_no_content_204_mutator(),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_probes(
|
|
11
|
+
app: FastAPI,
|
|
12
|
+
*,
|
|
13
|
+
prefix: str = "/_ops",
|
|
14
|
+
include_in_schema: bool = False,
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Mount basic liveness/readiness/startup probes under prefix."""
|
|
17
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
18
|
+
|
|
19
|
+
router = public_router(prefix=prefix, tags=["ops"], include_in_schema=include_in_schema)
|
|
20
|
+
|
|
21
|
+
@router.get("/live")
|
|
22
|
+
async def live() -> JSONResponse: # noqa: D401, ANN201
|
|
23
|
+
return JSONResponse({"status": "ok"})
|
|
24
|
+
|
|
25
|
+
@router.get("/ready")
|
|
26
|
+
async def ready() -> JSONResponse: # noqa: D401, ANN201
|
|
27
|
+
# In the future, add checks (DB ping, cache ping) via DI hooks.
|
|
28
|
+
return JSONResponse({"status": "ok"})
|
|
29
|
+
|
|
30
|
+
@router.get("/startup")
|
|
31
|
+
async def startup_probe() -> JSONResponse: # noqa: D401, ANN201
|
|
32
|
+
return JSONResponse({"status": "ok"})
|
|
33
|
+
|
|
34
|
+
app.include_router(router)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add_maintenance_mode(
|
|
38
|
+
app: FastAPI,
|
|
39
|
+
*,
|
|
40
|
+
env_var: str = "MAINTENANCE_MODE",
|
|
41
|
+
exempt_prefixes: tuple[str, ...] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Enable a simple maintenance gate controlled by an env var.
|
|
44
|
+
|
|
45
|
+
When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@app.middleware("http")
|
|
49
|
+
async def _maintenance_gate(request: Request, call_next): # noqa: ANN001, ANN202
|
|
50
|
+
flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
|
|
51
|
+
if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
52
|
+
path = request.scope.get("path", "")
|
|
53
|
+
if exempt_prefixes and any(path.startswith(p) for p in exempt_prefixes):
|
|
54
|
+
return await call_next(request)
|
|
55
|
+
return JSONResponse({"detail": "maintenance"}, status_code=503)
|
|
56
|
+
return await call_next(request)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
|
|
60
|
+
"""Return a dependency that can trip rejective errors based on external metrics.
|
|
61
|
+
|
|
62
|
+
This is a placeholder; callers can swap with a provider that tracks failures and opens the
|
|
63
|
+
breaker. Here, we read an env var to simulate an open breaker.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
async def _dep(_: Request) -> None: # noqa: D401, ANN202
|
|
67
|
+
if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
|
|
68
|
+
raise HTTPException(status_code=503, detail="circuit open")
|
|
69
|
+
|
|
70
|
+
return _dep
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]
|
|
@@ -89,7 +89,9 @@ class PaginationContext(Generic[T]):
|
|
|
89
89
|
|
|
90
90
|
@property
|
|
91
91
|
def limit(self) -> int:
|
|
92
|
-
|
|
92
|
+
# For cursor-based pagination, always honor the requested limit, even on the first page
|
|
93
|
+
# (cursor may be None for the first page).
|
|
94
|
+
if self.allow_cursor and self.cursor_params:
|
|
93
95
|
return self.cursor_params.limit
|
|
94
96
|
if self.allow_page and self.page_params:
|
|
95
97
|
return self.limit_override or self.page_params.page_size
|
|
@@ -14,6 +14,7 @@ router = public_router(tags=["Health Check"])
|
|
|
14
14
|
PING_PATH,
|
|
15
15
|
status_code=status.HTTP_200_OK,
|
|
16
16
|
description="Operation to check if the service is up and running",
|
|
17
|
+
operation_id="health_ping_get",
|
|
17
18
|
)
|
|
18
19
|
def ping():
|
|
19
20
|
logging.info("Health check: /ping endpoint accessed. Service is responsive.")
|
svc_infra/api/fastapi/setup.py
CHANGED
|
@@ -14,9 +14,14 @@ from svc_infra.api.fastapi.docs.landing import CardSpec, DocTargets, render_inde
|
|
|
14
14
|
from svc_infra.api.fastapi.docs.scoped import DOC_SCOPES
|
|
15
15
|
from svc_infra.api.fastapi.middleware.errors.catchall import CatchAllExceptionMiddleware
|
|
16
16
|
from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
|
|
17
|
+
from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
|
|
17
18
|
from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
|
|
18
19
|
from svc_infra.api.fastapi.middleware.ratelimit import SimpleRateLimitMiddleware
|
|
19
20
|
from svc_infra.api.fastapi.middleware.request_id import RequestIdMiddleware
|
|
21
|
+
from svc_infra.api.fastapi.middleware.timeout import (
|
|
22
|
+
BodyReadTimeoutMiddleware,
|
|
23
|
+
HandlerTimeoutMiddleware,
|
|
24
|
+
)
|
|
20
25
|
from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
21
26
|
from svc_infra.api.fastapi.openapi.mutators import setup_mutators
|
|
22
27
|
from svc_infra.api.fastapi.openapi.pipeline import apply_mutators
|
|
@@ -79,11 +84,16 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
|
|
|
79
84
|
|
|
80
85
|
def _setup_middlewares(app: FastAPI):
|
|
81
86
|
app.add_middleware(RequestIdMiddleware)
|
|
87
|
+
# Timeouts: enforce body read timeout first, then total handler timeout
|
|
88
|
+
app.add_middleware(BodyReadTimeoutMiddleware)
|
|
89
|
+
app.add_middleware(HandlerTimeoutMiddleware)
|
|
82
90
|
app.add_middleware(CatchAllExceptionMiddleware)
|
|
83
91
|
app.add_middleware(IdempotencyMiddleware)
|
|
84
92
|
app.add_middleware(SimpleRateLimitMiddleware)
|
|
85
93
|
register_error_handlers(app)
|
|
86
94
|
_add_route_logger(app)
|
|
95
|
+
# Graceful shutdown: track in-flight and wait on shutdown
|
|
96
|
+
install_graceful_shutdown(app)
|
|
87
97
|
|
|
88
98
|
|
|
89
99
|
def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
|
|
@@ -232,7 +242,7 @@ def setup_service_api(
|
|
|
232
242
|
mount_path = f"/{spec.tag.strip('/')}"
|
|
233
243
|
parent.mount(mount_path, child, name=spec.tag.strip("/"))
|
|
234
244
|
|
|
235
|
-
@parent.get("/", include_in_schema=False)
|
|
245
|
+
@parent.get("/", include_in_schema=False, response_class=HTMLResponse)
|
|
236
246
|
def index():
|
|
237
247
|
cards: list[CardSpec] = []
|
|
238
248
|
is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .context import set_tenant_resolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
|
|
11
|
+
"""Wire tenancy resolver for the application.
|
|
12
|
+
|
|
13
|
+
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
14
|
+
the default resolution. Pass None to clear a previous override.
|
|
15
|
+
"""
|
|
16
|
+
set_tenant_resolver(resolver)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["add_tenancy"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
try: # optional import; auth may not be used by all consumers
|
|
8
|
+
from svc_infra.api.fastapi.auth.security import OptionalIdentity
|
|
9
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
10
|
+
OptionalIdentity = None # type: ignore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_tenant_resolver: Optional[Callable[..., Any]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_tenant_resolver(
|
|
17
|
+
fn: Optional[Callable[..., Any]],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Set or clear a global override hook for tenant resolution.
|
|
20
|
+
|
|
21
|
+
The function receives (request, identity, tenant_header) and should return a tenant id
|
|
22
|
+
string or None to fall back to default logic.
|
|
23
|
+
"""
|
|
24
|
+
global _tenant_resolver
|
|
25
|
+
_tenant_resolver = fn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _maybe_await(x):
|
|
29
|
+
if callable(getattr(x, "__await__", None)):
|
|
30
|
+
return await x # type: ignore[misc]
|
|
31
|
+
return x
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def resolve_tenant_id(
|
|
35
|
+
request: Request,
|
|
36
|
+
tenant_header: Optional[str] = None,
|
|
37
|
+
identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
|
|
38
|
+
) -> Optional[str]:
|
|
39
|
+
"""Resolve tenant id from override, identity, header, or request.state.
|
|
40
|
+
|
|
41
|
+
Order:
|
|
42
|
+
1) Global override hook (set_tenant_resolver)
|
|
43
|
+
2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
|
|
44
|
+
3) X-Tenant-Id header
|
|
45
|
+
4) request.state.tenant_id
|
|
46
|
+
"""
|
|
47
|
+
# read header value if not provided directly (supports direct calls without DI)
|
|
48
|
+
if tenant_header is None:
|
|
49
|
+
try:
|
|
50
|
+
tenant_header = request.headers.get("X-Tenant-Id") # type: ignore[assignment]
|
|
51
|
+
except Exception:
|
|
52
|
+
tenant_header = None
|
|
53
|
+
|
|
54
|
+
# 1) global override
|
|
55
|
+
if _tenant_resolver is not None:
|
|
56
|
+
try:
|
|
57
|
+
v = _tenant_resolver(request, identity, tenant_header)
|
|
58
|
+
v2 = await _maybe_await(v)
|
|
59
|
+
if v2:
|
|
60
|
+
return str(v2)
|
|
61
|
+
except Exception:
|
|
62
|
+
# fall through to defaults
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# 2) from identity
|
|
66
|
+
try:
|
|
67
|
+
if identity and getattr(identity, "user", None) is not None:
|
|
68
|
+
tid = getattr(identity.user, "tenant_id", None)
|
|
69
|
+
if tid:
|
|
70
|
+
return str(tid)
|
|
71
|
+
if identity and getattr(identity, "api_key", None) is not None:
|
|
72
|
+
tid = getattr(identity.api_key, "tenant_id", None)
|
|
73
|
+
if tid:
|
|
74
|
+
return str(tid)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# 3) from header
|
|
79
|
+
if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
|
|
80
|
+
return tenant_header.strip()
|
|
81
|
+
|
|
82
|
+
# 4) request.state
|
|
83
|
+
try:
|
|
84
|
+
st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
85
|
+
if st_tid:
|
|
86
|
+
return str(st_tid)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def require_tenant_id(
|
|
94
|
+
tenant_id: Optional[str] = Depends(resolve_tenant_id),
|
|
95
|
+
) -> str:
|
|
96
|
+
if not tenant_id:
|
|
97
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
98
|
+
return tenant_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# DX aliases
|
|
102
|
+
TenantId = Annotated[str, Depends(require_tenant_id)]
|
|
103
|
+
OptionalTenantId = Annotated[Optional[str], Depends(resolve_tenant_id)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"TenantId",
|
|
108
|
+
"OptionalTenantId",
|
|
109
|
+
"resolve_tenant_id",
|
|
110
|
+
"require_tenant_id",
|
|
111
|
+
"set_tenant_resolver",
|
|
112
|
+
]
|
svc_infra/app/README.md
CHANGED
|
@@ -14,9 +14,8 @@ This README shows:
|
|
|
14
14
|
|
|
15
15
|
```python
|
|
16
16
|
# main.py (or wherever your app starts)
|
|
17
|
-
from svc_infra.
|
|
17
|
+
from svc_infra.app.logging import setup_logging, LogLevelOptions
|
|
18
18
|
from svc_infra.app.env import pick
|
|
19
|
-
from svc_infra.logging.logging import LogLevelOptions
|
|
20
19
|
```
|
|
21
20
|
|
|
22
21
|
---
|
|
@@ -39,7 +38,8 @@ What you get by default:
|
|
|
39
38
|
Set via code:
|
|
40
39
|
|
|
41
40
|
```python
|
|
42
|
-
from svc_infra.logging.
|
|
41
|
+
from svc_infra.app.logging.formats import LogFormatOptions
|
|
42
|
+
from svc_infra.app.logging import LogLevelOptions
|
|
43
43
|
|
|
44
44
|
setup_logging(
|
|
45
45
|
level=LogLevelOptions.INFO, # or "INFO"
|
|
@@ -119,7 +119,7 @@ Old (pre-filter) example:
|
|
|
119
119
|
|
|
120
120
|
```python
|
|
121
121
|
from svc_infra.app.env import pick
|
|
122
|
-
from svc_infra.
|
|
122
|
+
from svc_infra.app.logging import setup_logging, LogLevelOptions
|
|
123
123
|
|
|
124
124
|
setup_logging(
|
|
125
125
|
level=pick(
|
|
@@ -183,7 +183,7 @@ LOG_DROP_PATHS=/metrics,/health,/healthz
|
|
|
183
183
|
## 7) One-liner quickstart
|
|
184
184
|
|
|
185
185
|
```python
|
|
186
|
-
from svc_infra.logging import setup_logging
|
|
186
|
+
from svc_infra.app.logging import setup_logging
|
|
187
187
|
setup_logging() # done: sensible defaults + filters in prod/test
|
|
188
188
|
```
|
|
189
189
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .models import (
|
|
2
|
+
Invoice,
|
|
3
|
+
InvoiceLine,
|
|
4
|
+
Plan,
|
|
5
|
+
PlanEntitlement,
|
|
6
|
+
Price,
|
|
7
|
+
Subscription,
|
|
8
|
+
UsageAggregate,
|
|
9
|
+
UsageEvent,
|
|
10
|
+
)
|
|
11
|
+
from .service import BillingService
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"UsageEvent",
|
|
15
|
+
"UsageAggregate",
|
|
16
|
+
"Plan",
|
|
17
|
+
"PlanEntitlement",
|
|
18
|
+
"Subscription",
|
|
19
|
+
"Price",
|
|
20
|
+
"Invoice",
|
|
21
|
+
"InvoiceLine",
|
|
22
|
+
"BillingService",
|
|
23
|
+
]
|