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,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