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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. 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