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,149 @@
|
|
|
1
|
+
"""Rate limiter: sorts requests into classes, takes from the principal's bucket, counts refusals.
|
|
2
|
+
|
|
3
|
+
A principal has one bucket per class (`reads`, `writes`, `searches`, `context`, `admin`), keyed
|
|
4
|
+
by the API key id or, for users, the principal id. A Domain that overrides the bucket of a
|
|
5
|
+
class gets a bucket of its own for it.
|
|
6
|
+
|
|
7
|
+
Failed authentications have buckets of their own (`auth_failures`), in a separate store so
|
|
8
|
+
that unauthenticated traffic cannot push principals' buckets out: one per client address (IPv6
|
|
9
|
+
by network) and one per presented API key prefix.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ipaddress
|
|
15
|
+
import time
|
|
16
|
+
from collections import OrderedDict, deque
|
|
17
|
+
from collections.abc import Callable, Collection, Sequence
|
|
18
|
+
|
|
19
|
+
from opentelemetry import metrics
|
|
20
|
+
from opentelemetry.metrics import MeterProvider
|
|
21
|
+
|
|
22
|
+
from lorebase_keeper.client_address import IPAddress
|
|
23
|
+
from lorebase_keeper.ratelimit.bucket import BucketSpec, Decision, InMemoryRateStore, RateStore
|
|
24
|
+
from lorebase_keeper.ratelimit.config import RateLimitSettings, RouteClass
|
|
25
|
+
|
|
26
|
+
AUTH_FAILURES = "auth_failures"
|
|
27
|
+
|
|
28
|
+
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateLimiter:
|
|
32
|
+
"""Applies `RateLimitSettings` on top of a `RateStore`."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
settings: RateLimitSettings,
|
|
37
|
+
store: RateStore,
|
|
38
|
+
*,
|
|
39
|
+
failure_store: RateStore | None = None,
|
|
40
|
+
meter_provider: MeterProvider | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._settings = settings
|
|
43
|
+
self._store = store
|
|
44
|
+
self._failure_store = failure_store or InMemoryRateStore(settings.max_buckets)
|
|
45
|
+
self._failure_spec = BucketSpec.from_settings(settings.auth_failures)
|
|
46
|
+
meter = (meter_provider or metrics.get_meter_provider()).get_meter("lorebase.keeper")
|
|
47
|
+
self._limited = meter.create_counter(
|
|
48
|
+
"lorebase.http.rate_limited",
|
|
49
|
+
unit="{request}",
|
|
50
|
+
description="Requests refused with 429 because a rate limit was reached.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def classify(self, method: str, route_path: str, tags: Collection[str] = ()) -> RouteClass:
|
|
54
|
+
"""The class of a request, from its method, route template and the route's tags."""
|
|
55
|
+
routes = self._settings.routes
|
|
56
|
+
if not set(tags).isdisjoint(routes.admin_tags):
|
|
57
|
+
return RouteClass.ADMIN
|
|
58
|
+
if method in SAFE_METHODS:
|
|
59
|
+
return RouteClass.READS
|
|
60
|
+
if method == "POST":
|
|
61
|
+
if route_path.endswith(routes.search_suffixes):
|
|
62
|
+
return RouteClass.SEARCHES
|
|
63
|
+
if route_path.endswith(routes.context_suffixes):
|
|
64
|
+
return RouteClass.CONTEXT
|
|
65
|
+
if route_path.endswith(routes.read_post_suffixes):
|
|
66
|
+
return RouteClass.READS
|
|
67
|
+
return RouteClass.WRITES
|
|
68
|
+
|
|
69
|
+
async def check(self, who: str, route_class: RouteClass, domain_id: str | None) -> Decision:
|
|
70
|
+
"""Take one token from the bucket of `who` for this class (and Domain override)."""
|
|
71
|
+
bucket = self._settings.bucket(route_class, domain_id)
|
|
72
|
+
key = f"{who}|{route_class.value}"
|
|
73
|
+
if self._settings.has_override(route_class, domain_id):
|
|
74
|
+
key += f"|domain:{domain_id}"
|
|
75
|
+
decision = await self._store.take(key, BucketSpec.from_settings(bucket))
|
|
76
|
+
if not decision.allowed:
|
|
77
|
+
self._limited.add(1, {"class": route_class.value})
|
|
78
|
+
return decision
|
|
79
|
+
|
|
80
|
+
def failure_keys(self, address: IPAddress | None, key_prefix: str | None) -> tuple[str, ...]:
|
|
81
|
+
"""The failed-authentication buckets of a request: its client address (an IPv6
|
|
82
|
+
address by its network) and, unless turned off, its API key prefix, each when known."""
|
|
83
|
+
keys: list[str] = []
|
|
84
|
+
if isinstance(address, ipaddress.IPv6Address):
|
|
85
|
+
length = self._settings.auth_failures.ipv6_prefix_length
|
|
86
|
+
keys.append(f"addr:{ipaddress.ip_network(f'{address}/{length}', strict=False)}")
|
|
87
|
+
elif address is not None:
|
|
88
|
+
keys.append(f"addr:{address}")
|
|
89
|
+
if key_prefix is not None and self._settings.auth_failures.per_key_prefix_enabled:
|
|
90
|
+
keys.append(f"prefix:{key_prefix}")
|
|
91
|
+
return tuple(f"{AUTH_FAILURES}|{key}" for key in keys)
|
|
92
|
+
|
|
93
|
+
async def failures_exhausted(self, keys: Sequence[str]) -> Decision | None:
|
|
94
|
+
"""The refusal (the longest wait) when one of the buckets has no failure left, else
|
|
95
|
+
None. Takes nothing."""
|
|
96
|
+
refused: Decision | None = None
|
|
97
|
+
for key in keys:
|
|
98
|
+
decision = await self._failure_store.peek(key, self._failure_spec)
|
|
99
|
+
if not decision.allowed and (
|
|
100
|
+
refused is None or decision.retry_after_s > refused.retry_after_s
|
|
101
|
+
):
|
|
102
|
+
refused = decision
|
|
103
|
+
if refused is not None:
|
|
104
|
+
self._limited.add(1, {"class": AUTH_FAILURES})
|
|
105
|
+
return refused
|
|
106
|
+
|
|
107
|
+
async def record_failure(self, keys: Sequence[str]) -> None:
|
|
108
|
+
"""Take one token from each bucket for a failed authentication."""
|
|
109
|
+
for key in keys:
|
|
110
|
+
await self._failure_store.take(key, self._failure_spec)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ThrottleWatch:
|
|
114
|
+
"""Counts the refusals of each principal in a sliding window.
|
|
115
|
+
|
|
116
|
+
`record` returns the count when it reaches the threshold and starts counting again, so a
|
|
117
|
+
principal that keeps hammering the API produces one report per `threshold` refusals.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
threshold: int,
|
|
123
|
+
window_s: float,
|
|
124
|
+
max_tracked: int,
|
|
125
|
+
*,
|
|
126
|
+
clock: Callable[[], float] = time.monotonic,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._threshold = threshold
|
|
129
|
+
self._window_s = window_s
|
|
130
|
+
self._max = max_tracked
|
|
131
|
+
self._clock = clock
|
|
132
|
+
self._seen: OrderedDict[str, deque[float]] = OrderedDict()
|
|
133
|
+
|
|
134
|
+
def record(self, who: str) -> int | None:
|
|
135
|
+
"""Note a refusal of `who`; the number of refusals in the window when it is time to
|
|
136
|
+
report them."""
|
|
137
|
+
if self._threshold <= 0:
|
|
138
|
+
return None
|
|
139
|
+
now = self._clock()
|
|
140
|
+
times = self._seen.pop(who, None) or deque()
|
|
141
|
+
while times and now - times[0] > self._window_s:
|
|
142
|
+
times.popleft()
|
|
143
|
+
times.append(now)
|
|
144
|
+
if len(times) >= self._threshold:
|
|
145
|
+
return len(times)
|
|
146
|
+
self._seen[who] = times
|
|
147
|
+
while len(self._seen) > self._max:
|
|
148
|
+
self._seen.popitem(last=False)
|
|
149
|
+
return None
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Daily quotas of API keys: requests and context-pack tokens per UTC day.
|
|
2
|
+
|
|
3
|
+
Counting is done in memory and written to `api_key_usage` at most every
|
|
4
|
+
`quota.flush_interval_s` per key. A write adds this process's use to the stored row and returns
|
|
5
|
+
the total of every process, so several Keeper processes converge on the same figure and a quota
|
|
6
|
+
holds across restarts. Between writes a process enforces the quota on what it knows (the
|
|
7
|
+
stored total as of its last write, plus its own use since), which lets a key exceed a quota by
|
|
8
|
+
about one interval of traffic. A store failure never refuses a request: the quota is enforced
|
|
9
|
+
on what is known and the write is retried.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import UTC, date, datetime, timedelta
|
|
19
|
+
from datetime import time as day_time
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
from opentelemetry import metrics
|
|
23
|
+
from opentelemetry.metrics import MeterProvider
|
|
24
|
+
|
|
25
|
+
from lorebase_keeper.auth.ports import AuthStore
|
|
26
|
+
from lorebase_keeper.ratelimit.config import QuotaSettings
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("lorebase.keeper.quota")
|
|
29
|
+
|
|
30
|
+
QuotaKind = Literal["requests", "context_tokens"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class QuotaSnapshot:
|
|
35
|
+
"""What a key has used today and may still use. A limit of `None` is no limit."""
|
|
36
|
+
|
|
37
|
+
day: date
|
|
38
|
+
resets_at: datetime
|
|
39
|
+
requests_used: int
|
|
40
|
+
requests_limit: int | None
|
|
41
|
+
tokens_used: int
|
|
42
|
+
tokens_limit: int | None
|
|
43
|
+
|
|
44
|
+
def exceeded(self) -> QuotaKind | None:
|
|
45
|
+
"""The quota that is used up, if any."""
|
|
46
|
+
if self.requests_limit is not None and self.requests_used >= self.requests_limit:
|
|
47
|
+
return "requests"
|
|
48
|
+
if self.tokens_limit is not None and self.tokens_used >= self.tokens_limit:
|
|
49
|
+
return "context_tokens"
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _Entry:
|
|
55
|
+
day: date
|
|
56
|
+
stored_requests: int = 0
|
|
57
|
+
stored_tokens: int = 0
|
|
58
|
+
pending_requests: int = 0
|
|
59
|
+
pending_tokens: int = 0
|
|
60
|
+
loaded: bool = False
|
|
61
|
+
flushing: bool = False
|
|
62
|
+
last_flush: float = field(default=float("-inf"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class QuotaTracker:
|
|
66
|
+
"""Counts the use of API keys against `QuotaSettings`."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
settings: QuotaSettings,
|
|
71
|
+
store: AuthStore,
|
|
72
|
+
*,
|
|
73
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
74
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
75
|
+
meter_provider: MeterProvider | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._settings = settings
|
|
78
|
+
self._store = store
|
|
79
|
+
self._clock = clock
|
|
80
|
+
self._monotonic = monotonic
|
|
81
|
+
self._entries: dict[str, _Entry] = {}
|
|
82
|
+
meter = (meter_provider or metrics.get_meter_provider()).get_meter("lorebase.keeper")
|
|
83
|
+
self._exceeded = meter.create_counter(
|
|
84
|
+
"lorebase.http.quota_exceeded",
|
|
85
|
+
unit="{request}",
|
|
86
|
+
description="Requests refused with 429 because a daily quota was used up.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Use --------------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async def admit(self, key_id: str) -> tuple[QuotaKind | None, QuotaSnapshot]:
|
|
92
|
+
"""Count one request of the key unless a quota is used up.
|
|
93
|
+
|
|
94
|
+
Returns the quota that refuses the request (nothing is counted then) and the state of
|
|
95
|
+
the key.
|
|
96
|
+
"""
|
|
97
|
+
entry = await self._entry(key_id)
|
|
98
|
+
snapshot = self._snapshot(key_id, entry)
|
|
99
|
+
refused = snapshot.exceeded()
|
|
100
|
+
if refused is not None:
|
|
101
|
+
self._exceeded.add(1, {"quota": refused})
|
|
102
|
+
return refused, snapshot
|
|
103
|
+
entry.pending_requests += 1
|
|
104
|
+
await self._flush_when_due(key_id, entry)
|
|
105
|
+
return None, self._snapshot(key_id, entry)
|
|
106
|
+
|
|
107
|
+
async def check_tokens(self, key_id: str) -> QuotaSnapshot | None:
|
|
108
|
+
"""The key's state when its token quota is used up, otherwise `None`."""
|
|
109
|
+
entry = await self._entry(key_id)
|
|
110
|
+
snapshot = self._snapshot(key_id, entry)
|
|
111
|
+
if snapshot.tokens_limit is not None and snapshot.tokens_used >= snapshot.tokens_limit:
|
|
112
|
+
self._exceeded.add(1, {"quota": "context_tokens"})
|
|
113
|
+
return snapshot
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
async def charge_tokens(self, key_id: str, tokens: int) -> None:
|
|
117
|
+
"""Count context-pack tokens delivered to the key."""
|
|
118
|
+
if tokens <= 0:
|
|
119
|
+
return
|
|
120
|
+
entry = await self._entry(key_id)
|
|
121
|
+
entry.pending_tokens += tokens
|
|
122
|
+
await self._flush_when_due(key_id, entry)
|
|
123
|
+
|
|
124
|
+
async def snapshot(self, key_id: str) -> QuotaSnapshot:
|
|
125
|
+
"""The key's use today, current across processes."""
|
|
126
|
+
entry = await self._entry(key_id)
|
|
127
|
+
await self._flush(key_id, entry)
|
|
128
|
+
return self._snapshot(key_id, entry)
|
|
129
|
+
|
|
130
|
+
async def aclose(self) -> None:
|
|
131
|
+
"""Write what has not been written yet."""
|
|
132
|
+
for key_id, entry in list(self._entries.items()):
|
|
133
|
+
await self._flush(key_id, entry)
|
|
134
|
+
|
|
135
|
+
# Internals --------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def _today(self) -> date:
|
|
138
|
+
return self._clock().astimezone(UTC).date()
|
|
139
|
+
|
|
140
|
+
def _snapshot(self, key_id: str, entry: _Entry) -> QuotaSnapshot:
|
|
141
|
+
return QuotaSnapshot(
|
|
142
|
+
day=entry.day,
|
|
143
|
+
resets_at=datetime.combine(entry.day + timedelta(days=1), day_time.min, tzinfo=UTC),
|
|
144
|
+
requests_used=entry.stored_requests + entry.pending_requests,
|
|
145
|
+
requests_limit=self._settings.requests_limit(key_id),
|
|
146
|
+
tokens_used=entry.stored_tokens + entry.pending_tokens,
|
|
147
|
+
tokens_limit=self._settings.tokens_limit(key_id),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def _entry(self, key_id: str) -> _Entry:
|
|
151
|
+
"""The key's counters for today, read from the store the first time."""
|
|
152
|
+
today = self._today()
|
|
153
|
+
entry = self._entries.get(key_id)
|
|
154
|
+
if entry is not None and entry.day != today:
|
|
155
|
+
await self._flush(key_id, entry) # what the old day counted goes to the old day
|
|
156
|
+
entry = None
|
|
157
|
+
if entry is None:
|
|
158
|
+
entry = self._entries[key_id] = _Entry(day=today)
|
|
159
|
+
if not entry.loaded:
|
|
160
|
+
await self._refresh(key_id, entry)
|
|
161
|
+
return entry
|
|
162
|
+
|
|
163
|
+
async def _refresh(self, key_id: str, entry: _Entry) -> None:
|
|
164
|
+
try:
|
|
165
|
+
async with self._store.unit() as unit:
|
|
166
|
+
usage = await unit.usage.get(key_id, entry.day)
|
|
167
|
+
except Exception:
|
|
168
|
+
logger.warning("cannot read the usage of an api key", exc_info=True)
|
|
169
|
+
return
|
|
170
|
+
entry.stored_requests, entry.stored_tokens = usage.requests, usage.context_tokens
|
|
171
|
+
entry.loaded = True
|
|
172
|
+
|
|
173
|
+
async def _flush_when_due(self, key_id: str, entry: _Entry) -> None:
|
|
174
|
+
if self._monotonic() - entry.last_flush >= self._settings.flush_interval_s:
|
|
175
|
+
await self._flush(key_id, entry)
|
|
176
|
+
|
|
177
|
+
async def _flush(self, key_id: str, entry: _Entry) -> None:
|
|
178
|
+
"""Add the pending use to the stored row and learn the total of all processes."""
|
|
179
|
+
if entry.flushing:
|
|
180
|
+
return
|
|
181
|
+
entry.flushing = True
|
|
182
|
+
entry.last_flush = self._monotonic()
|
|
183
|
+
requests, tokens = entry.pending_requests, entry.pending_tokens
|
|
184
|
+
try:
|
|
185
|
+
async with self._store.unit() as unit:
|
|
186
|
+
if requests or tokens:
|
|
187
|
+
total = await unit.usage.add(
|
|
188
|
+
key_id,
|
|
189
|
+
entry.day,
|
|
190
|
+
requests=requests,
|
|
191
|
+
context_tokens=tokens,
|
|
192
|
+
at=self._clock(),
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
total = await unit.usage.get(key_id, entry.day)
|
|
196
|
+
except Exception:
|
|
197
|
+
logger.warning("cannot write the usage of an api key", exc_info=True)
|
|
198
|
+
return
|
|
199
|
+
finally:
|
|
200
|
+
entry.flushing = False
|
|
201
|
+
entry.pending_requests -= requests
|
|
202
|
+
entry.pending_tokens -= tokens
|
|
203
|
+
entry.stored_requests, entry.stored_tokens = total.requests, total.context_tokens
|
|
204
|
+
entry.loaded = True
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Rate limit buckets shared by every Keeper, in Postgres (`keeper.limits.rate.backend:
|
|
2
|
+
postgres`).
|
|
3
|
+
|
|
4
|
+
`PostgresRateStore` implements `RateStore` over `lorebase_store.ratelimit`. Requests are served
|
|
5
|
+
from tokens leased from the shared bucket (`postgres.lease_tokens`, at most a quarter of the
|
|
6
|
+
bucket), so most requests cost no query; a request that finds its lease used up or expired
|
|
7
|
+
(`postgres.lease_ttl_s`) takes a new one, one query per bucket at a time. Leases come out of
|
|
8
|
+
the shared bucket, so the Keepers together never exceed a limit; tokens of an expired lease
|
|
9
|
+
are lost, which only makes it stricter.
|
|
10
|
+
|
|
11
|
+
Failed-authentication buckets lease one token per failure and are checked before every
|
|
12
|
+
authentication from a reading cached for `postgres.check_cache_s`. They fail closed: when the
|
|
13
|
+
database cannot be read in `postgres.auth_failures_timeout_s`, a request presenting a
|
|
14
|
+
credential is answered 429, unless a reading of its bucket at most `postgres.stale_reading_s`
|
|
15
|
+
old still allows it (so an outage refuses unknown clients, not every client). Other buckets
|
|
16
|
+
follow `postgres.on_error`: `local` (the default) enforces the limits in this process until
|
|
17
|
+
the database answers again, `allow` lets requests through, `deny` refuses them.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import contextlib
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
import time
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
|
|
31
|
+
from opentelemetry import metrics
|
|
32
|
+
from opentelemetry.metrics import MeterProvider
|
|
33
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
34
|
+
|
|
35
|
+
from lorebase_keeper.ratelimit.bucket import BucketSpec, BucketState, Decision, RateStore, take
|
|
36
|
+
from lorebase_keeper.ratelimit.config import PostgresRateSettings
|
|
37
|
+
from lorebase_store import ratelimit as buckets
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("lorebase.keeper.limits")
|
|
40
|
+
|
|
41
|
+
LEASE_SHARE = 4
|
|
42
|
+
"""A lease is at most 1/LEASE_SHARE of its bucket, so several Keepers can hold one at once."""
|
|
43
|
+
PRUNE_ROUNDS = 10
|
|
44
|
+
"""Batches one prune deletes at most."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class _Local:
|
|
49
|
+
"""What this process knows of one shared bucket."""
|
|
50
|
+
|
|
51
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
52
|
+
leased: float = 0.0
|
|
53
|
+
expires: float = float("-inf")
|
|
54
|
+
shared: float = 0.0
|
|
55
|
+
"""Tokens left in the shared bucket at the last lease."""
|
|
56
|
+
reading: buckets.BucketReading | None = None
|
|
57
|
+
read_at: float = float("-inf")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _decision(spec: BucketSpec, tokens: float, allowed: bool, cost: float) -> Decision:
|
|
61
|
+
"""A decision with headers computed from the tokens known to be available."""
|
|
62
|
+
tokens = max(0.0, min(float(spec.capacity), tokens))
|
|
63
|
+
retry = 0 if allowed else max(1, math.ceil((cost - tokens) / spec.refill_per_s))
|
|
64
|
+
return Decision(
|
|
65
|
+
allowed=allowed,
|
|
66
|
+
limit=spec.capacity,
|
|
67
|
+
remaining=math.floor(tokens),
|
|
68
|
+
reset_s=math.ceil((spec.capacity - tokens) / spec.refill_per_s),
|
|
69
|
+
retry_after_s=retry,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PostgresRateStore:
|
|
74
|
+
"""`RateStore` over the shared `rate_limit_buckets` table (see the module doc).
|
|
75
|
+
|
|
76
|
+
`fail_closed` refuses when the database fails (failed-authentication buckets); otherwise
|
|
77
|
+
`settings.on_error` decides, with `fallback` the store of `local`. `lease_tokens` overrides
|
|
78
|
+
the lease size (1 for failed-authentication buckets). `prune` makes this store delete the
|
|
79
|
+
rows of full buckets now and then.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
85
|
+
settings: PostgresRateSettings,
|
|
86
|
+
*,
|
|
87
|
+
max_buckets: int,
|
|
88
|
+
fallback: RateStore | None = None,
|
|
89
|
+
fail_closed: bool = False,
|
|
90
|
+
lease_tokens: int | None = None,
|
|
91
|
+
prune: bool = False,
|
|
92
|
+
label: str = "principals",
|
|
93
|
+
clock: Callable[[], float] = time.monotonic,
|
|
94
|
+
meter_provider: MeterProvider | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
if not fail_closed and settings.on_error == "local" and fallback is None:
|
|
97
|
+
raise ValueError("on_error local needs a fallback store")
|
|
98
|
+
self._sessions = sessions
|
|
99
|
+
self._settings = settings
|
|
100
|
+
self._max = max_buckets
|
|
101
|
+
self._fallback = fallback
|
|
102
|
+
self._fail_closed = fail_closed
|
|
103
|
+
self._timeout = settings.auth_failures_timeout_s if fail_closed else settings.timeout_s
|
|
104
|
+
self._lease_tokens = lease_tokens or settings.lease_tokens
|
|
105
|
+
self._prune = prune
|
|
106
|
+
self._label = label
|
|
107
|
+
self._clock = clock
|
|
108
|
+
self._local: OrderedDict[str, _Local] = OrderedDict()
|
|
109
|
+
self._last_prune = clock()
|
|
110
|
+
self._pruning: asyncio.Task[None] | None = None
|
|
111
|
+
meter = (meter_provider or metrics.get_meter_provider()).get_meter("lorebase.keeper")
|
|
112
|
+
self._queries = meter.create_counter(
|
|
113
|
+
"lorebase.ratelimit.shared.queries",
|
|
114
|
+
unit="{query}",
|
|
115
|
+
description="Queries to the shared rate limit buckets, by store and outcome.",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _entry(self, key: str) -> _Local:
|
|
119
|
+
entry = self._local.get(key)
|
|
120
|
+
if entry is None:
|
|
121
|
+
entry = self._local[key] = _Local()
|
|
122
|
+
self._local.move_to_end(key)
|
|
123
|
+
while len(self._local) > self._max:
|
|
124
|
+
self._local.popitem(last=False)
|
|
125
|
+
return entry
|
|
126
|
+
|
|
127
|
+
def _lease_size(self, spec: BucketSpec) -> int:
|
|
128
|
+
return max(1, min(self._lease_tokens, spec.capacity // LEASE_SHARE))
|
|
129
|
+
|
|
130
|
+
def _from_lease(self, entry: _Local, cost: float) -> bool:
|
|
131
|
+
if entry.expires > self._clock() and entry.leased >= cost:
|
|
132
|
+
entry.leased -= cost
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
async def take(self, key: str, spec: BucketSpec, cost: float = 1.0) -> Decision:
|
|
137
|
+
entry = self._entry(key)
|
|
138
|
+
if self._from_lease(entry, cost):
|
|
139
|
+
return _decision(spec, entry.leased + entry.shared, True, cost)
|
|
140
|
+
async with entry.lock:
|
|
141
|
+
if self._from_lease(entry, cost): # another request leased meanwhile
|
|
142
|
+
return _decision(spec, entry.leased + entry.shared, True, cost)
|
|
143
|
+
self._maybe_prune()
|
|
144
|
+
try:
|
|
145
|
+
async with asyncio.timeout(self._timeout):
|
|
146
|
+
async with self._sessions() as session, session.begin():
|
|
147
|
+
lease = await buckets.lease(
|
|
148
|
+
session,
|
|
149
|
+
key,
|
|
150
|
+
capacity=spec.capacity,
|
|
151
|
+
refill_per_s=spec.refill_per_s,
|
|
152
|
+
lease=self._lease_size(spec),
|
|
153
|
+
cost=cost,
|
|
154
|
+
)
|
|
155
|
+
except Exception as error:
|
|
156
|
+
return await self._failed(error, "take", key, spec, cost)
|
|
157
|
+
self._queries.add(1, {"store": self._label, "outcome": "ok"})
|
|
158
|
+
entry.shared = lease.remaining
|
|
159
|
+
entry.reading = buckets.BucketReading(lease.remaining, lease.now, lease.now)
|
|
160
|
+
entry.read_at = self._clock()
|
|
161
|
+
if lease.granted < cost:
|
|
162
|
+
entry.leased, entry.expires = 0.0, float("-inf")
|
|
163
|
+
return _decision(spec, lease.remaining, False, cost)
|
|
164
|
+
entry.leased = lease.granted - cost
|
|
165
|
+
entry.expires = self._clock() + self._settings.lease_ttl_s
|
|
166
|
+
return _decision(spec, entry.leased + entry.shared, True, cost)
|
|
167
|
+
|
|
168
|
+
async def peek(self, key: str, spec: BucketSpec, cost: float = 1.0) -> Decision:
|
|
169
|
+
entry = self._entry(key)
|
|
170
|
+
now = self._clock()
|
|
171
|
+
if entry.reading is None or now - entry.read_at > self._settings.check_cache_s:
|
|
172
|
+
try:
|
|
173
|
+
async with asyncio.timeout(self._timeout):
|
|
174
|
+
async with self._sessions() as session, session.begin():
|
|
175
|
+
reading = await buckets.read(session, key)
|
|
176
|
+
except Exception as error:
|
|
177
|
+
stale = entry.reading is not None and (
|
|
178
|
+
now - entry.read_at <= self._settings.stale_reading_s
|
|
179
|
+
)
|
|
180
|
+
if not stale:
|
|
181
|
+
return await self._failed(error, "peek", key, spec, cost)
|
|
182
|
+
# A recent reading of this bucket still decides: only clients the database
|
|
183
|
+
# never saw recently are refused while it does not answer.
|
|
184
|
+
self._queries.add(1, {"store": self._label, "outcome": "stale"})
|
|
185
|
+
else:
|
|
186
|
+
self._queries.add(1, {"store": self._label, "outcome": "ok"})
|
|
187
|
+
entry.reading, entry.read_at = reading, self._clock()
|
|
188
|
+
known = entry.reading
|
|
189
|
+
assert known is not None
|
|
190
|
+
state = None if known.tokens is None else BucketState(known.tokens, known.updated)
|
|
191
|
+
_, decision = take(state, spec, known.now + (self._clock() - entry.read_at), cost)
|
|
192
|
+
return decision
|
|
193
|
+
|
|
194
|
+
async def _failed(
|
|
195
|
+
self, error: Exception, operation: str, key: str, spec: BucketSpec, cost: float
|
|
196
|
+
) -> Decision:
|
|
197
|
+
"""The decision when the shared buckets cannot be reached."""
|
|
198
|
+
reason = "timeout" if isinstance(error, TimeoutError) else type(error).__name__
|
|
199
|
+
self._queries.add(1, {"store": self._label, "outcome": reason})
|
|
200
|
+
logger.warning(
|
|
201
|
+
"shared rate limit buckets unavailable",
|
|
202
|
+
exc_info=not isinstance(error, TimeoutError),
|
|
203
|
+
extra={"store": self._label, "reason": reason},
|
|
204
|
+
)
|
|
205
|
+
mode = "deny" if self._fail_closed else self._settings.on_error
|
|
206
|
+
if mode == "local" and self._fallback is not None:
|
|
207
|
+
if operation == "take":
|
|
208
|
+
return await self._fallback.take(key, spec, cost)
|
|
209
|
+
return await self._fallback.peek(key, spec, cost)
|
|
210
|
+
if mode == "allow":
|
|
211
|
+
return _decision(spec, float(spec.capacity), True, cost)
|
|
212
|
+
retry = self._settings.error_retry_after_s
|
|
213
|
+
return Decision(False, spec.capacity, 0, retry, retry)
|
|
214
|
+
|
|
215
|
+
def _maybe_prune(self) -> None:
|
|
216
|
+
if not self._prune or self._pruning is not None:
|
|
217
|
+
return
|
|
218
|
+
if self._clock() - self._last_prune < self._settings.prune_interval_s:
|
|
219
|
+
return
|
|
220
|
+
self._last_prune = self._clock()
|
|
221
|
+
self._pruning = asyncio.create_task(self._prune_once(), name="ratelimit-prune")
|
|
222
|
+
|
|
223
|
+
async def _prune_once(self) -> None:
|
|
224
|
+
try:
|
|
225
|
+
batch = self._settings.prune_batch
|
|
226
|
+
async with asyncio.timeout(max(self._settings.timeout_s, 5.0) * PRUNE_ROUNDS):
|
|
227
|
+
for _ in range(PRUNE_ROUNDS): # until a batch comes back short
|
|
228
|
+
async with self._sessions() as session, session.begin():
|
|
229
|
+
if await buckets.prune(session, limit=batch) < batch:
|
|
230
|
+
break
|
|
231
|
+
except Exception:
|
|
232
|
+
logger.warning("cannot prune the shared rate limit buckets", exc_info=True)
|
|
233
|
+
finally:
|
|
234
|
+
self._pruning = None
|
|
235
|
+
|
|
236
|
+
async def aclose(self) -> None:
|
|
237
|
+
"""Stop a running prune."""
|
|
238
|
+
task, self._pruning = self._pruning, None
|
|
239
|
+
if task is not None:
|
|
240
|
+
task.cancel()
|
|
241
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
242
|
+
await task
|