lorebase-keeper 0.2.3__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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""ASGI middleware: request context (id, tracing, timing, access log), body size limit and
|
|
2
|
+
security headers.
|
|
3
|
+
|
|
4
|
+
All are plain ASGI classes so that they see every request, including those rejected by the
|
|
5
|
+
framework, and so that streaming bodies are never buffered.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from opentelemetry import metrics, propagate, trace
|
|
18
|
+
from opentelemetry.metrics import MeterProvider
|
|
19
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode, TracerProvider
|
|
20
|
+
from starlette.datastructures import Headers, MutableHeaders
|
|
21
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
22
|
+
|
|
23
|
+
from lorebase_keeper.client_address import ClientAddresses, parse_address
|
|
24
|
+
from lorebase_keeper.errors import (
|
|
25
|
+
INTERNAL_ERROR,
|
|
26
|
+
PAYLOAD_TOO_LARGE,
|
|
27
|
+
ProblemError,
|
|
28
|
+
ProblemRenderer,
|
|
29
|
+
request_id_of,
|
|
30
|
+
)
|
|
31
|
+
from lorebase_keeper.settings import SecurityHeadersSettings
|
|
32
|
+
from lorebase_platform.logging import request_context
|
|
33
|
+
from lorebase_platform.telemetry import LATENCY_BUCKETS_S
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("lorebase.keeper")
|
|
36
|
+
access_logger = logging.getLogger("lorebase.keeper.access")
|
|
37
|
+
|
|
38
|
+
_SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
|
39
|
+
UNMATCHED_ROUTE = "unmatched"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def new_request_id() -> str:
|
|
43
|
+
"""A fresh request id."""
|
|
44
|
+
return uuid.uuid4().hex
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RequestContextMiddleware:
|
|
48
|
+
"""Assign the request id, open the server span, time the request and log it.
|
|
49
|
+
|
|
50
|
+
The request id comes from the configured header when it is well formed, otherwise a new
|
|
51
|
+
one is generated; it is echoed in the response header, bound to every log record of the
|
|
52
|
+
request and attached to the span. Incoming W3C trace context is continued. Unhandled
|
|
53
|
+
exceptions are logged with their stack trace and answered with a generic problem detail.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
app: ASGIApp,
|
|
59
|
+
*,
|
|
60
|
+
header: str,
|
|
61
|
+
renderer: ProblemRenderer,
|
|
62
|
+
tracer_provider: TracerProvider | None = None,
|
|
63
|
+
meter_provider: MeterProvider | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.app = app
|
|
66
|
+
self._header = header
|
|
67
|
+
self._renderer = renderer
|
|
68
|
+
self._tracer = (tracer_provider or trace.get_tracer_provider()).get_tracer(
|
|
69
|
+
"lorebase.keeper"
|
|
70
|
+
)
|
|
71
|
+
self._duration = (
|
|
72
|
+
(meter_provider or metrics.get_meter_provider())
|
|
73
|
+
.get_meter("lorebase.keeper")
|
|
74
|
+
.create_histogram(
|
|
75
|
+
"http.server.request.duration",
|
|
76
|
+
unit="s",
|
|
77
|
+
description="Duration of HTTP requests.",
|
|
78
|
+
explicit_bucket_boundaries_advisory=LATENCY_BUCKETS_S,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
83
|
+
if scope["type"] != "http":
|
|
84
|
+
await self.app(scope, receive, send)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
headers = Headers(scope=scope)
|
|
88
|
+
candidate = headers.get(self._header)
|
|
89
|
+
request_id = candidate if candidate and _SAFE_REQUEST_ID.match(candidate) else None
|
|
90
|
+
request_id = request_id or new_request_id()
|
|
91
|
+
scope.setdefault("state", {})["request_id"] = request_id
|
|
92
|
+
method = scope["method"]
|
|
93
|
+
status = 500
|
|
94
|
+
started = False
|
|
95
|
+
|
|
96
|
+
async def send_with_id(message: Message) -> None:
|
|
97
|
+
nonlocal status, started
|
|
98
|
+
if message["type"] == "http.response.start":
|
|
99
|
+
started = True
|
|
100
|
+
status = message["status"]
|
|
101
|
+
MutableHeaders(scope=message)[self._header] = request_id
|
|
102
|
+
await send(message)
|
|
103
|
+
|
|
104
|
+
begin = time.perf_counter()
|
|
105
|
+
with (
|
|
106
|
+
request_context(request_id),
|
|
107
|
+
self._tracer.start_as_current_span(
|
|
108
|
+
method,
|
|
109
|
+
context=propagate.extract(dict(headers)),
|
|
110
|
+
kind=SpanKind.SERVER,
|
|
111
|
+
attributes={"http.request.method": method, "url.path": scope["path"]},
|
|
112
|
+
) as span,
|
|
113
|
+
):
|
|
114
|
+
span.set_attribute("lorebase.request_id", request_id)
|
|
115
|
+
try:
|
|
116
|
+
await self.app(scope, receive, send_with_id)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
logger.exception("unhandled error while serving request", extra={"method": method})
|
|
119
|
+
span.record_exception(exc)
|
|
120
|
+
if not started:
|
|
121
|
+
response = self._renderer.response(
|
|
122
|
+
INTERNAL_ERROR,
|
|
123
|
+
detail=f"Unexpected error. Quote request id {request_id} when reporting.",
|
|
124
|
+
instance=scope["path"],
|
|
125
|
+
request_id=request_id,
|
|
126
|
+
)
|
|
127
|
+
await response(scope, receive, send_with_id)
|
|
128
|
+
else:
|
|
129
|
+
raise # the status line is gone: let the server abort the connection
|
|
130
|
+
finally:
|
|
131
|
+
elapsed = time.perf_counter() - begin
|
|
132
|
+
route = _route_template(scope)
|
|
133
|
+
span.update_name(f"{method} {route}")
|
|
134
|
+
span.set_attribute("http.route", route)
|
|
135
|
+
span.set_attribute("http.response.status_code", status)
|
|
136
|
+
if status >= 500:
|
|
137
|
+
span.set_status(Status(StatusCode.ERROR))
|
|
138
|
+
self._duration.record(
|
|
139
|
+
elapsed,
|
|
140
|
+
{
|
|
141
|
+
"http.request.method": method,
|
|
142
|
+
"http.route": route,
|
|
143
|
+
"http.response.status_code": status,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
access_logger.info(
|
|
147
|
+
"request completed",
|
|
148
|
+
extra={
|
|
149
|
+
"method": method,
|
|
150
|
+
"route": route,
|
|
151
|
+
"status": status,
|
|
152
|
+
"duration_ms": round(elapsed * 1000, 3),
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _route_template(scope: Scope) -> str:
|
|
158
|
+
route: Any = scope.get("route")
|
|
159
|
+
path = getattr(route, "path", None)
|
|
160
|
+
return path if isinstance(path, str) else UNMATCHED_ROUTE
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class BodyLimitMiddleware:
|
|
164
|
+
"""Reject request bodies above the configured size with 413.
|
|
165
|
+
|
|
166
|
+
A declared `Content-Length` over the limit is refused before the handler runs; chunked
|
|
167
|
+
bodies are counted as they are read. `overrides` give other limits to the requests whose
|
|
168
|
+
method and path match (for example document uploads); the first match wins.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
app: ASGIApp,
|
|
174
|
+
*,
|
|
175
|
+
max_bytes: int,
|
|
176
|
+
renderer: ProblemRenderer,
|
|
177
|
+
overrides: Sequence[tuple[str, re.Pattern[str], int]] = (),
|
|
178
|
+
) -> None:
|
|
179
|
+
self.app = app
|
|
180
|
+
self._max_bytes = max_bytes
|
|
181
|
+
self._renderer = renderer
|
|
182
|
+
self._overrides = tuple(overrides)
|
|
183
|
+
|
|
184
|
+
def _limit(self, scope: Scope) -> int:
|
|
185
|
+
for method, path, limit in self._overrides:
|
|
186
|
+
if scope.get("method") == method and path.fullmatch(scope["path"]):
|
|
187
|
+
return limit
|
|
188
|
+
return self._max_bytes
|
|
189
|
+
|
|
190
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
191
|
+
if scope["type"] != "http":
|
|
192
|
+
await self.app(scope, receive, send)
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
limit = self._limit(scope)
|
|
196
|
+
declared = Headers(scope=scope).get("content-length")
|
|
197
|
+
if declared is not None and declared.isdecimal() and int(declared) > limit:
|
|
198
|
+
response = self._renderer.response(
|
|
199
|
+
PAYLOAD_TOO_LARGE,
|
|
200
|
+
detail=self._detail(limit),
|
|
201
|
+
instance=scope["path"],
|
|
202
|
+
request_id=request_id_of(scope.get("state")),
|
|
203
|
+
)
|
|
204
|
+
await response(scope, receive, send)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
received = 0
|
|
208
|
+
|
|
209
|
+
async def counted_receive() -> Message:
|
|
210
|
+
nonlocal received
|
|
211
|
+
message = await receive()
|
|
212
|
+
if message["type"] == "http.request":
|
|
213
|
+
received += len(message.get("body", b""))
|
|
214
|
+
if received > limit:
|
|
215
|
+
raise ProblemError(PAYLOAD_TOO_LARGE, self._detail(limit))
|
|
216
|
+
return message
|
|
217
|
+
|
|
218
|
+
await self.app(scope, counted_receive, send)
|
|
219
|
+
|
|
220
|
+
def _detail(self, limit: int) -> str:
|
|
221
|
+
return f"The request body exceeds {limit} bytes."
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SecurityHeadersMiddleware:
|
|
225
|
+
"""Add the configured security headers to every response, keeping those a route set.
|
|
226
|
+
|
|
227
|
+
`Strict-Transport-Security` follows `hsts.enabled`: in `auto` it is sent when the request
|
|
228
|
+
came in over https, either directly or through a trusted proxy (`keeper.trusted_proxies`)
|
|
229
|
+
whose last `X-Forwarded-Proto` value is https.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
app: ASGIApp,
|
|
235
|
+
*,
|
|
236
|
+
settings: SecurityHeadersSettings,
|
|
237
|
+
proxies: ClientAddresses,
|
|
238
|
+
) -> None:
|
|
239
|
+
self.app = app
|
|
240
|
+
self._hsts = settings.hsts
|
|
241
|
+
self._proxies = proxies
|
|
242
|
+
static: list[tuple[str, str]] = []
|
|
243
|
+
if settings.content_type_nosniff:
|
|
244
|
+
static.append(("X-Content-Type-Options", "nosniff"))
|
|
245
|
+
if settings.frame_options != "off":
|
|
246
|
+
ancestors = "'none'" if settings.frame_options == "DENY" else "'self'"
|
|
247
|
+
static.append(("X-Frame-Options", settings.frame_options))
|
|
248
|
+
static.append(("Content-Security-Policy", f"frame-ancestors {ancestors}"))
|
|
249
|
+
if settings.referrer_policy:
|
|
250
|
+
static.append(("Referrer-Policy", settings.referrer_policy))
|
|
251
|
+
self._static = tuple(static)
|
|
252
|
+
|
|
253
|
+
def _secure(self, scope: Scope) -> bool:
|
|
254
|
+
if scope["scheme"] in ("https", "wss"):
|
|
255
|
+
return True
|
|
256
|
+
client = scope.get("client")
|
|
257
|
+
peer = parse_address(str(client[0])) if client else None
|
|
258
|
+
if peer is None or not self._proxies.trusted(peer):
|
|
259
|
+
return False
|
|
260
|
+
forwarded = Headers(scope=scope).getlist("x-forwarded-proto")
|
|
261
|
+
hops = [hop.strip().lower() for value in forwarded for hop in value.split(",")]
|
|
262
|
+
return bool(hops) and hops[-1] == "https"
|
|
263
|
+
|
|
264
|
+
def _send_hsts(self, scope: Scope) -> bool:
|
|
265
|
+
mode = self._hsts.enabled
|
|
266
|
+
return mode == "always" or (mode == "auto" and self._secure(scope))
|
|
267
|
+
|
|
268
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
269
|
+
if scope["type"] != "http":
|
|
270
|
+
await self.app(scope, receive, send)
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
added = list(self._static)
|
|
274
|
+
if self._send_hsts(scope):
|
|
275
|
+
added.append(("Strict-Transport-Security", self._hsts.header_value))
|
|
276
|
+
|
|
277
|
+
async def send_with_headers(message: Message) -> None:
|
|
278
|
+
if message["type"] == "http.response.start":
|
|
279
|
+
headers = MutableHeaders(scope=message)
|
|
280
|
+
for name, value in added:
|
|
281
|
+
if name not in headers:
|
|
282
|
+
headers[name] = value
|
|
283
|
+
await send(message)
|
|
284
|
+
|
|
285
|
+
await self.app(scope, receive, send_with_headers)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Cursor pagination shared by every list endpoint.
|
|
2
|
+
|
|
3
|
+
A list request takes `limit` (bounded by settings) and an opaque `cursor`; the response is a
|
|
4
|
+
`Page` with `items` and `next_cursor` (null on the last page). Cursors are base64url JSON so
|
|
5
|
+
that routes can keep a keyset position in them; clients must treat them as opaque.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Annotated, Any
|
|
16
|
+
|
|
17
|
+
from fastapi import Depends, Query
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from lorebase_keeper.deps import KeeperSettingsDep
|
|
21
|
+
from lorebase_keeper.errors import VALIDATION_FAILED, ProblemError
|
|
22
|
+
|
|
23
|
+
MAX_CURSOR_LENGTH = 2048
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Page[T](BaseModel):
|
|
27
|
+
"""One page of results."""
|
|
28
|
+
|
|
29
|
+
items: list[T]
|
|
30
|
+
next_cursor: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encode_cursor(position: Mapping[str, Any]) -> str:
|
|
34
|
+
"""Opaque cursor carrying a keyset position."""
|
|
35
|
+
raw = json.dumps(position, separators=(",", ":"), sort_keys=True).encode()
|
|
36
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_cursor(cursor: str) -> dict[str, Any]:
|
|
40
|
+
"""Position stored in a cursor; `ValueError` when the cursor is not one we issued."""
|
|
41
|
+
try:
|
|
42
|
+
decoded = json.loads(base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)))
|
|
43
|
+
except (binascii.Error, ValueError):
|
|
44
|
+
raise ValueError("malformed cursor") from None
|
|
45
|
+
if not isinstance(decoded, dict):
|
|
46
|
+
raise ValueError("malformed cursor")
|
|
47
|
+
return decoded
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class PageParams:
|
|
52
|
+
"""Validated pagination request: effective limit and decoded cursor position."""
|
|
53
|
+
|
|
54
|
+
limit: int
|
|
55
|
+
position: dict[str, Any] | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _invalid(pointer: str, message: str) -> ProblemError:
|
|
59
|
+
return ProblemError(
|
|
60
|
+
VALIDATION_FAILED,
|
|
61
|
+
"One or more request members are invalid.",
|
|
62
|
+
extensions={"errors": [{"pointer": pointer, "message": message, "code": "invalid"}]},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_page_params(
|
|
67
|
+
keeper: KeeperSettingsDep,
|
|
68
|
+
limit: Annotated[int | None, Query(ge=1, description="Page size.")] = None,
|
|
69
|
+
cursor: Annotated[
|
|
70
|
+
str | None, Query(max_length=MAX_CURSOR_LENGTH, description="Opaque page cursor.")
|
|
71
|
+
] = None,
|
|
72
|
+
) -> PageParams:
|
|
73
|
+
"""Dependency resolving `limit` and `cursor` against the configured bounds."""
|
|
74
|
+
bounds = keeper.pagination
|
|
75
|
+
effective = bounds.default_limit if limit is None else limit
|
|
76
|
+
if effective > bounds.max_limit:
|
|
77
|
+
raise _invalid("/query/limit", f"must not exceed {bounds.max_limit}")
|
|
78
|
+
position = None
|
|
79
|
+
if cursor:
|
|
80
|
+
try:
|
|
81
|
+
position = decode_cursor(cursor)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise _invalid("/query/cursor", "is not a valid cursor") from None
|
|
84
|
+
return PageParams(limit=effective, position=position)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
PageParamsDep = Annotated[PageParams, Depends(get_page_params)]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Rate limits and daily quotas (design 13.6).
|
|
2
|
+
|
|
3
|
+
`config` holds the settings, `bucket` the token bucket math and the `RateStore` protocol,
|
|
4
|
+
`limiter` the per-principal limiter, `quotas` the daily counters of API keys and `enforce` the
|
|
5
|
+
dependency, middleware and helpers that apply them to requests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from lorebase_keeper.ratelimit.bucket import BucketSpec, Decision, InMemoryRateStore, RateStore
|
|
9
|
+
|
|
10
|
+
__all__ = ["BucketSpec", "Decision", "InMemoryRateStore", "RateStore"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Token bucket arithmetic and the storage protocol behind it.
|
|
2
|
+
|
|
3
|
+
A bucket holds up to `capacity` tokens and gains `refill_per_s` tokens each second; a request
|
|
4
|
+
takes one. Time comes from the caller, so the math is deterministic and a shared store (Valkey,
|
|
5
|
+
Redis) can run the same function server side.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import time
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from lorebase_keeper.ratelimit.config import BucketSettings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class BucketSpec:
|
|
22
|
+
"""Size and refill rate of a bucket."""
|
|
23
|
+
|
|
24
|
+
capacity: int
|
|
25
|
+
refill_per_s: float
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_settings(cls, settings: BucketSettings) -> BucketSpec:
|
|
29
|
+
return cls(capacity=settings.burst, refill_per_s=settings.requests_per_minute / 60)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class BucketState:
|
|
34
|
+
"""Tokens a bucket held at `updated` (a reading of the store's clock, in seconds)."""
|
|
35
|
+
|
|
36
|
+
tokens: float
|
|
37
|
+
updated: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Decision:
|
|
42
|
+
"""The outcome of taking from a bucket, in the terms of the `RateLimit-*` headers.
|
|
43
|
+
|
|
44
|
+
`reset_s` is the time until the bucket is full again; `retry_after_s` the time until the
|
|
45
|
+
request would be allowed (0 when it was).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
allowed: bool
|
|
49
|
+
limit: int
|
|
50
|
+
remaining: int
|
|
51
|
+
reset_s: int
|
|
52
|
+
retry_after_s: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def take(
|
|
56
|
+
state: BucketState | None, spec: BucketSpec, now: float, cost: float = 1.0
|
|
57
|
+
) -> tuple[BucketState, Decision]:
|
|
58
|
+
"""Refill the bucket to `now`, then take `cost` tokens if there are enough.
|
|
59
|
+
|
|
60
|
+
A bucket that was never used is full. Tokens are only taken from a bucket that holds at
|
|
61
|
+
least `cost`, so a refused request costs nothing.
|
|
62
|
+
"""
|
|
63
|
+
if state is None:
|
|
64
|
+
tokens = float(spec.capacity)
|
|
65
|
+
else:
|
|
66
|
+
elapsed = max(0.0, now - state.updated)
|
|
67
|
+
tokens = min(float(spec.capacity), state.tokens + elapsed * spec.refill_per_s)
|
|
68
|
+
allowed = tokens >= cost
|
|
69
|
+
if allowed:
|
|
70
|
+
tokens -= cost
|
|
71
|
+
reset = math.ceil((spec.capacity - tokens) / spec.refill_per_s)
|
|
72
|
+
retry = 0 if allowed else max(1, math.ceil((cost - tokens) / spec.refill_per_s))
|
|
73
|
+
decision = Decision(
|
|
74
|
+
allowed=allowed,
|
|
75
|
+
limit=spec.capacity,
|
|
76
|
+
remaining=max(0, math.floor(tokens)),
|
|
77
|
+
reset_s=reset,
|
|
78
|
+
retry_after_s=retry,
|
|
79
|
+
)
|
|
80
|
+
return BucketState(tokens, now), decision
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RateStore(Protocol):
|
|
84
|
+
"""Keeps bucket state. `take` is atomic per key: concurrent requests of one principal see
|
|
85
|
+
each other's tokens."""
|
|
86
|
+
|
|
87
|
+
async def take(self, key: str, spec: BucketSpec, cost: float = 1.0) -> Decision: ...
|
|
88
|
+
|
|
89
|
+
async def peek(self, key: str, spec: BucketSpec, cost: float = 1.0) -> Decision:
|
|
90
|
+
"""Whether `take` would allow `cost` now, without taking anything."""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class InMemoryRateStore:
|
|
95
|
+
"""Buckets in this process, the least recently used dropped beyond `max_buckets`.
|
|
96
|
+
|
|
97
|
+
Nothing awaits between reading and writing a bucket, so on one event loop `take` is atomic.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, max_buckets: int, *, clock: Callable[[], float] = time.monotonic) -> None:
|
|
101
|
+
self._max = max_buckets
|
|
102
|
+
self._clock = clock
|
|
103
|
+
self._buckets: OrderedDict[str, BucketState] = OrderedDict()
|
|
104
|
+
|
|
105
|
+
def __len__(self) -> int:
|
|
106
|
+
return len(self._buckets)
|
|
107
|
+
|
|
108
|
+
async def take(self, key: str, spec: BucketSpec, cost: float = 1.0) -> Decision:
|
|
109
|
+
state, decision = take(self._buckets.get(key), spec, self._clock(), cost)
|
|
110
|
+
self._buckets[key] = state
|
|
111
|
+
self._buckets.move_to_end(key)
|
|
112
|
+
while len(self._buckets) > self._max:
|
|
113
|
+
self._buckets.popitem(last=False)
|
|
114
|
+
return decision
|
|
115
|
+
|
|
116
|
+
async def peek(self, key: str, spec: BucketSpec, cost: float = 1.0) -> Decision:
|
|
117
|
+
_, decision = take(self._buckets.get(key), spec, self._clock(), cost)
|
|
118
|
+
return decision
|