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,283 @@
1
+ """Settings of rate limiting and daily quotas (`keeper.limits.rate`, `keeper.limits.quota`).
2
+
3
+ Every value has a documented default; deployments override it in the YAML file or with
4
+ `LOREBASE__KEEPER__LIMITS__RATE__...` variables. See `config/lorebase.example.yaml`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import StrEnum
10
+ from typing import Literal
11
+
12
+ from pydantic import Field
13
+
14
+ from lorebase_platform.settings import SectionModel
15
+
16
+
17
+ class RouteClass(StrEnum):
18
+ """What a request costs, as far as limits are concerned."""
19
+
20
+ READS = "reads"
21
+ WRITES = "writes"
22
+ SEARCHES = "searches"
23
+ CONTEXT = "context"
24
+ ADMIN = "admin"
25
+
26
+
27
+ class BucketSettings(SectionModel):
28
+ """One token bucket: it refills at `requests_per_minute` and holds at most `burst` tokens."""
29
+
30
+ requests_per_minute: float = Field(gt=0, description="Sustained rate: tokens added per minute.")
31
+ burst: int = Field(ge=1, description="Bucket size: requests allowed back to back.")
32
+
33
+
34
+ def _bucket(requests_per_minute: float, burst: int) -> BucketSettings:
35
+ return BucketSettings(requests_per_minute=requests_per_minute, burst=burst)
36
+
37
+
38
+ class ClassOverrides(SectionModel):
39
+ """Buckets of one Domain that replace the installation-wide ones; unset classes keep them."""
40
+
41
+ reads: BucketSettings | None = None
42
+ writes: BucketSettings | None = None
43
+ searches: BucketSettings | None = None
44
+ context: BucketSettings | None = None
45
+ admin: BucketSettings | None = None
46
+
47
+ def get(self, route_class: RouteClass) -> BucketSettings | None:
48
+ """The override of a class, if any."""
49
+ found: BucketSettings | None = getattr(self, route_class.value)
50
+ return found
51
+
52
+
53
+ class RouteClassing(SectionModel):
54
+ """How requests are sorted into classes. Anything not matched is `reads` for GET, HEAD and
55
+ OPTIONS and `writes` for every other method."""
56
+
57
+ admin_tags: tuple[str, ...] = Field(
58
+ default=("admin",), description="Routes with one of these OpenAPI tags are `admin`."
59
+ )
60
+ search_suffixes: tuple[str, ...] = Field(
61
+ default=("/search",), description="POST routes ending in one of these are `searches`."
62
+ )
63
+ context_suffixes: tuple[str, ...] = Field(
64
+ default=("/context",), description="POST routes ending in one of these are `context`."
65
+ )
66
+ read_post_suffixes: tuple[str, ...] = Field(
67
+ default=("/query", "/traverse", "/neighborhood"),
68
+ description="POST routes ending in one of these only read: they are `reads`.",
69
+ )
70
+
71
+ def only_reads(self, method: str, route_path: str) -> bool:
72
+ """Whether a request only reads: GET, HEAD, or a POST search, context or read route
73
+ (the reads recorded by `keeper.auth.audit.reads`)."""
74
+ if method in ("GET", "HEAD"):
75
+ return True
76
+ return method == "POST" and route_path.endswith(
77
+ (*self.search_suffixes, *self.context_suffixes, *self.read_post_suffixes)
78
+ )
79
+
80
+
81
+ class AuthFailureSettings(BucketSettings):
82
+ """Buckets of failed authentications: one per client address and, unless
83
+ `per_key_prefix_enabled` is off, one per API key prefix.
84
+
85
+ Only requests answered 401 after presenting a credential take a token. While either bucket
86
+ of a request is empty it is answered 429 before its credential is checked.
87
+ """
88
+
89
+ requests_per_minute: float = Field(
90
+ default=30, gt=0, description="Failed attempts regained per minute."
91
+ )
92
+ burst: int = Field(default=30, ge=1, description="Failed attempts allowed back to back.")
93
+ ipv6_prefix_length: int = Field(
94
+ default=64,
95
+ ge=1,
96
+ le=128,
97
+ description="IPv6 clients share the bucket of their network of this length, since one "
98
+ "client usually holds a whole /64.",
99
+ )
100
+ per_key_prefix_enabled: bool = Field(
101
+ default=True,
102
+ description="Keep a bucket per presented API key prefix. Prefixes are not secret, so "
103
+ "whoever knows one can keep that key throttled; turn this off to count failures by "
104
+ "client address only (the per-address bucket is always on).",
105
+ )
106
+
107
+
108
+ class ThrottleAuditSettings(SectionModel):
109
+ """Audit entry for a principal that keeps being throttled."""
110
+
111
+ threshold: int = Field(
112
+ default=20,
113
+ ge=0,
114
+ description="Refusals within `window_s` that write one audit entry (`rate_limit."
115
+ "throttled`); the count then starts again. 0 disables.",
116
+ )
117
+ window_s: float = Field(default=300, gt=0, description="Length of the counting window.")
118
+ max_tracked: int = Field(default=10_000, ge=1, description="Principals counted at once.")
119
+
120
+
121
+ class PostgresRateSettings(SectionModel):
122
+ """The shared backend (`backend: postgres`): buckets in the UNLOGGED `rate_limit_buckets`
123
+ table of the application database, leased to each Keeper a few tokens at a time."""
124
+
125
+ lease_tokens: int = Field(
126
+ default=10,
127
+ ge=1,
128
+ description="Tokens a Keeper takes from a shared bucket per round trip and then serves "
129
+ "from memory; at most a quarter of the bucket (at least 1). Larger leases mean fewer "
130
+ "queries; unused leased tokens are lost when the lease expires, which only makes the "
131
+ "limit stricter. Failed-authentication buckets always take one token at a time.",
132
+ )
133
+ lease_ttl_s: float = Field(
134
+ default=2.0,
135
+ gt=0,
136
+ description="Leased tokens not used within this time are dropped, so an idle Keeper "
137
+ "does not hold a principal's tokens back.",
138
+ )
139
+ timeout_s: float = Field(
140
+ default=0.25,
141
+ gt=0,
142
+ description="Time budget of one query to the shared buckets; slower counts as failed.",
143
+ )
144
+ on_error: Literal["local", "allow", "deny"] = Field(
145
+ default="local",
146
+ description="When the database fails or is slow: `local` enforces the limits in this "
147
+ "process meanwhile (as backend local), `allow` lets the request through, `deny` "
148
+ "answers 429. Failed-authentication buckets always fail closed (429).",
149
+ )
150
+ error_retry_after_s: int = Field(
151
+ default=1,
152
+ ge=1,
153
+ description="Retry-After of a request refused because the shared buckets could not be "
154
+ "read (`deny`, and failed-authentication buckets).",
155
+ )
156
+ check_cache_s: float = Field(
157
+ default=0.5,
158
+ ge=0,
159
+ description="How long a reading of a failed-authentication bucket serves the checks "
160
+ "made before each authentication (which take nothing). Failures themselves are always "
161
+ "written at once; with N Keepers a client may get up to (N-1) x this much of the "
162
+ "failure rate beyond the limit.",
163
+ )
164
+ auth_failures_timeout_s: float = Field(
165
+ default=1.0,
166
+ gt=0,
167
+ description="Time budget of a query of a failed-authentication bucket, waiting for a "
168
+ "pooled connection included. Past it the bucket fails closed.",
169
+ )
170
+ stale_reading_s: float = Field(
171
+ default=30.0,
172
+ ge=0,
173
+ description="While failed-authentication buckets cannot be read, a reading of the same "
174
+ "bucket at most this old still decides; only clients without one are refused (429).",
175
+ )
176
+ prune_interval_s: float = Field(
177
+ default=60.0,
178
+ gt=0,
179
+ description="How often each Keeper deletes rows of buckets that are full again.",
180
+ )
181
+ prune_batch: int = Field(default=1000, ge=1, description="Rows deleted per prune.")
182
+
183
+
184
+ class RateLimitSettings(SectionModel):
185
+ """Token buckets per principal and request class (design 13.6)."""
186
+
187
+ enabled: bool = Field(default=True, description="Apply rate limits.")
188
+ backend: Literal["local", "memory", "postgres"] = Field(
189
+ default="local",
190
+ description="Where bucket state lives. `local` (`memory` is an alias) is per process: "
191
+ "with N Keeper processes a principal may use up to N times the limits. `postgres` "
192
+ "shares the buckets of every Keeper through the application database (see "
193
+ "`postgres`); use it with more than one Keeper.",
194
+ )
195
+ postgres: PostgresRateSettings = Field(default_factory=PostgresRateSettings)
196
+ max_buckets: int = Field(
197
+ default=100_000, ge=1, description="Buckets kept in memory; the least recently used go."
198
+ )
199
+ exempt_paths: tuple[str, ...] = Field(
200
+ default=("/healthz", "/readyz"),
201
+ description="Route paths (without the API prefix) that are never limited or counted.",
202
+ )
203
+ reads: BucketSettings = Field(default_factory=lambda: _bucket(1200, 200))
204
+ writes: BucketSettings = Field(default_factory=lambda: _bucket(300, 60))
205
+ searches: BucketSettings = Field(default_factory=lambda: _bucket(300, 60))
206
+ context: BucketSettings = Field(default_factory=lambda: _bucket(120, 30))
207
+ admin: BucketSettings = Field(default_factory=lambda: _bucket(120, 30))
208
+ auth_failures: AuthFailureSettings = Field(
209
+ default_factory=AuthFailureSettings,
210
+ description="Failed authentications per client address and per API key prefix.",
211
+ )
212
+ domains: dict[str, ClassOverrides] = Field(
213
+ default_factory=dict,
214
+ description="Per-Domain overrides by Domain id; requests in these Domains use their own "
215
+ "buckets (not shared with the principal's other Domains).",
216
+ )
217
+ routes: RouteClassing = Field(default_factory=RouteClassing)
218
+ throttle_audit: ThrottleAuditSettings = Field(default_factory=ThrottleAuditSettings)
219
+
220
+ def bucket(self, route_class: RouteClass, domain_id: str | None = None) -> BucketSettings:
221
+ """The bucket for a class, from the Domain's overrides when it has one."""
222
+ override = self.domains.get(domain_id) if domain_id else None
223
+ found = override.get(route_class) if override else None
224
+ if found is not None:
225
+ return found
226
+ installation: BucketSettings = getattr(self, route_class.value)
227
+ return installation
228
+
229
+ def has_override(self, route_class: RouteClass, domain_id: str | None) -> bool:
230
+ """Whether the Domain overrides the bucket of this class."""
231
+ override = self.domains.get(domain_id) if domain_id else None
232
+ return override is not None and override.get(route_class) is not None
233
+
234
+
235
+ class KeyQuota(SectionModel):
236
+ """Quota of one API key; a member left out keeps the installation-wide value, `null`
237
+ removes the limit."""
238
+
239
+ requests_per_day: int | None = Field(default=None, ge=1)
240
+ context_tokens_per_day: int | None = Field(default=None, ge=1)
241
+
242
+
243
+ class QuotaSettings(SectionModel):
244
+ """Daily quotas per API key, counted in UTC days (design 13.6)."""
245
+
246
+ enabled: bool = Field(
247
+ default=True, description="Count the use of API keys. Needs a database; users are exempt."
248
+ )
249
+ requests_per_day: int | None = Field(
250
+ default=100_000, ge=1, description="Requests per key and day; null: unlimited."
251
+ )
252
+ context_tokens_per_day: int | None = Field(
253
+ default=2_000_000,
254
+ ge=1,
255
+ description="Estimated tokens of context packs per key and day; null: unlimited. A pack "
256
+ "that starts under the limit is delivered whole, so a day may end slightly above it.",
257
+ )
258
+ flush_interval_s: float = Field(
259
+ default=5,
260
+ gt=0,
261
+ description="How often a process writes the use it has counted. Several processes "
262
+ "enforce a quota on what they know, so a key may exceed it by the use of one interval.",
263
+ )
264
+ keys: dict[str, KeyQuota] = Field(
265
+ default_factory=dict,
266
+ description="Overrides by API key id, as listed by the Domain's API key endpoint.",
267
+ )
268
+
269
+ def requests_limit(self, key_id: str) -> int | None:
270
+ """Requests per day allowed to a key."""
271
+ return self._limit(key_id, "requests_per_day")
272
+
273
+ def tokens_limit(self, key_id: str) -> int | None:
274
+ """Context-pack tokens per day allowed to a key."""
275
+ return self._limit(key_id, "context_tokens_per_day")
276
+
277
+ def _limit(self, key_id: str, member: str) -> int | None:
278
+ override = self.keys.get(key_id)
279
+ if override is not None and member in override.model_fields_set:
280
+ value: int | None = getattr(override, member)
281
+ return value
282
+ default: int | None = getattr(self, member)
283
+ return default
@@ -0,0 +1,393 @@
1
+ """Enforcement of rate limits and quotas on requests.
2
+
3
+ `enforce_limits` is an application-wide dependency: for every route that is not exempt it
4
+ authenticates the caller (refusing clients whose failed authentications used up their
5
+ `auth_failures` buckets before their credential is checked), takes a token from the caller's
6
+ bucket for the request's class and, for API keys, counts the request against the daily quota.
7
+ Refusals are 429 problem details with `Retry-After`. `RateLimitHeadersMiddleware` adds the
8
+ `RateLimit-*` headers of the bucket to every response of a limited route, errors included.
9
+ Context-pack tokens are counted by the route that builds the pack (`check_context_quota`,
10
+ `charge_context_tokens`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import math
17
+ from dataclasses import dataclass
18
+ from typing import Any, cast
19
+
20
+ from fastapi import Request, Response
21
+ from opentelemetry.metrics import MeterProvider
22
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
23
+ from starlette.datastructures import MutableHeaders
24
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
25
+
26
+ from lorebase_keeper.auth.audit import suppress
27
+ from lorebase_keeper.auth.deps import current_principal, get_auth
28
+ from lorebase_keeper.auth.principals import Principal
29
+ from lorebase_keeper.auth.runtime import AuthRuntime
30
+ from lorebase_keeper.auth.sessions import PUBLIC_PATHS
31
+ from lorebase_keeper.client_address import client_address
32
+ from lorebase_keeper.errors import RATE_LIMITED, UNAUTHORIZED, ProblemError, ProblemType
33
+ from lorebase_keeper.ratelimit.bucket import Decision, InMemoryRateStore
34
+ from lorebase_keeper.ratelimit.config import QuotaSettings, RateLimitSettings, RouteClass
35
+ from lorebase_keeper.ratelimit.limiter import AUTH_FAILURES, RateLimiter, ThrottleWatch
36
+ from lorebase_keeper.ratelimit.quotas import QuotaKind, QuotaSnapshot, QuotaTracker
37
+ from lorebase_keeper.ratelimit.shared import PostgresRateStore
38
+ from lorebase_platform.settings import ConfigError
39
+ from lorebase_store.auth import NewAuditEntry
40
+
41
+ logger = logging.getLogger("lorebase.keeper.limits")
42
+
43
+ QUOTA_EXCEEDED = ProblemType("quota-exceeded", 429, "Daily quota exceeded")
44
+ STATE_KEY = "rate_limit"
45
+ THROTTLED_ACTION = "rate_limit.throttled"
46
+ LIMIT_HEADER = "RateLimit-Limit"
47
+ REMAINING_HEADER = "RateLimit-Remaining"
48
+ RESET_HEADER = "RateLimit-Reset"
49
+ RETRY_HEADER = "Retry-After"
50
+ EXPOSED_HEADERS = (LIMIT_HEADER, REMAINING_HEADER, RESET_HEADER, RETRY_HEADER)
51
+
52
+
53
+ @dataclass
54
+ class LimitRuntime:
55
+ """The limiting components of one application."""
56
+
57
+ rate: RateLimitSettings
58
+ quota: QuotaSettings
59
+ limiter: RateLimiter | None
60
+ quotas: QuotaTracker | None
61
+ watch: ThrottleWatch | None
62
+ auth: AuthRuntime
63
+ stores: tuple[PostgresRateStore, ...] = ()
64
+
65
+ def exempt(self, route_path: str) -> bool:
66
+ """Whether the route (path template without the API prefix) is neither limited nor
67
+ counted."""
68
+ return route_path in self.rate.exempt_paths
69
+
70
+ async def aclose(self) -> None:
71
+ """Write the counted use that is still in memory."""
72
+ if self.quotas is not None:
73
+ await self.quotas.aclose()
74
+ for store in self.stores:
75
+ await store.aclose()
76
+
77
+
78
+ def _shared_stores(
79
+ rate: RateLimitSettings,
80
+ sessions: async_sessionmaker[AsyncSession],
81
+ meter_provider: MeterProvider | None,
82
+ ) -> tuple[PostgresRateStore, PostgresRateStore]:
83
+ """The shared stores of principals' buckets and of failed authentications."""
84
+ principals = PostgresRateStore(
85
+ sessions,
86
+ rate.postgres,
87
+ max_buckets=rate.max_buckets,
88
+ fallback=InMemoryRateStore(rate.max_buckets),
89
+ prune=True,
90
+ label="principals",
91
+ meter_provider=meter_provider,
92
+ )
93
+ failures = PostgresRateStore(
94
+ sessions,
95
+ rate.postgres,
96
+ max_buckets=rate.max_buckets,
97
+ fail_closed=True,
98
+ lease_tokens=1,
99
+ label=AUTH_FAILURES,
100
+ meter_provider=meter_provider,
101
+ )
102
+ return principals, failures
103
+
104
+
105
+ def build_limits(
106
+ rate: RateLimitSettings,
107
+ quota: QuotaSettings,
108
+ auth: AuthRuntime,
109
+ *,
110
+ sessions: async_sessionmaker[AsyncSession] | None = None,
111
+ meter_provider: MeterProvider | None = None,
112
+ ) -> LimitRuntime | None:
113
+ """The components for the settings; `None` when neither limit applies. The `postgres`
114
+ backend needs `sessions` on the application database."""
115
+ limiter: RateLimiter | None = None
116
+ stores: tuple[PostgresRateStore, ...] = ()
117
+ if rate.enabled and rate.backend == "postgres":
118
+ if sessions is None:
119
+ raise ConfigError(
120
+ [
121
+ "keeper.limits.rate.backend: postgres needs a database; configure the database "
122
+ "section or use backend local"
123
+ ]
124
+ )
125
+ stores = _shared_stores(rate, sessions, meter_provider)
126
+ limiter = RateLimiter(
127
+ rate, stores[0], failure_store=stores[1], meter_provider=meter_provider
128
+ )
129
+ elif rate.enabled:
130
+ limiter = RateLimiter(
131
+ rate, InMemoryRateStore(rate.max_buckets), meter_provider=meter_provider
132
+ )
133
+ quotas = (
134
+ QuotaTracker(quota, auth.store, clock=auth.clock, meter_provider=meter_provider)
135
+ if quota.enabled and auth.store is not None and auth.settings.uses_api_keys
136
+ else None
137
+ )
138
+ if limiter is None and quotas is None:
139
+ return None
140
+ audit = rate.throttle_audit
141
+ watch = (
142
+ ThrottleWatch(audit.threshold, audit.window_s, audit.max_tracked)
143
+ if audit.threshold > 0
144
+ else None
145
+ )
146
+ return LimitRuntime(rate, quota, limiter, quotas, watch, auth, stores)
147
+
148
+
149
+ def limits_of(request: Request) -> LimitRuntime | None:
150
+ """The application's limiting components, if any limit is on."""
151
+ return cast("LimitRuntime | None", getattr(request.app.state, "limits", None))
152
+
153
+
154
+ def principal_label(principal: Principal) -> str:
155
+ """The key of a principal's buckets: the API key id, else the principal id."""
156
+ return f"key:{principal.key_id}" if principal.key_id else f"sub:{principal.id}"
157
+
158
+
159
+ def route_path(request: Request) -> str:
160
+ """The route template of the request without the API prefix, whether the router reports
161
+ it with the prefix or not (as `exempt_paths` and `PUBLIC_PATHS` name routes)."""
162
+ template = getattr(request.scope.get("route"), "path", None)
163
+ if not isinstance(template, str):
164
+ template = str(request.scope["path"])
165
+ keeper = getattr(request.app.state, "keeper_settings", None)
166
+ prefix = str(getattr(keeper, "api_prefix", "") or "")
167
+ if prefix and (template == prefix or template.startswith(prefix + "/")):
168
+ return template[len(prefix) :] or "/"
169
+ return template
170
+
171
+
172
+ async def _limit_by_address(request: Request, runtime: LimitRuntime, path: str) -> None:
173
+ """Sign-in routes work without a credential: their buckets are per client address."""
174
+ if runtime.limiter is None:
175
+ return
176
+ address = client_address(request)
177
+ route_class = runtime.limiter.classify(request.method, path)
178
+ decision = await runtime.limiter.check(f"addr:{address or 'unknown'}", route_class, None)
179
+ request.scope.setdefault("state", {})[STATE_KEY] = decision
180
+ if not decision.allowed:
181
+ suppress(request)
182
+ raise ProblemError(
183
+ RATE_LIMITED,
184
+ f"Rate limit reached for {route_class.value} requests. "
185
+ f"Retry in {decision.retry_after_s} s.",
186
+ extensions={"class": route_class.value, "retry_after_s": decision.retry_after_s},
187
+ headers={RETRY_HEADER: str(decision.retry_after_s)},
188
+ )
189
+
190
+
191
+ async def enforce_limits(request: Request) -> None:
192
+ """Application-wide dependency; see the module documentation."""
193
+ runtime = limits_of(request)
194
+ if runtime is None:
195
+ return
196
+ route = request.scope.get("route")
197
+ path = route_path(request)
198
+ if runtime.exempt(path):
199
+ return
200
+ if path in PUBLIC_PATHS:
201
+ await _limit_by_address(request, runtime, path)
202
+ return
203
+ principal = await _authenticate(request, runtime)
204
+ if runtime.limiter is not None:
205
+ route_class = runtime.limiter.classify(
206
+ request.method, path, getattr(route, "tags", None) or ()
207
+ )
208
+ domain_id = request.path_params.get("domain_id")
209
+ decision = await runtime.limiter.check(principal_label(principal), route_class, domain_id)
210
+ request.scope.setdefault("state", {})[STATE_KEY] = decision
211
+ if not decision.allowed:
212
+ await _refuse(
213
+ request,
214
+ runtime,
215
+ principal,
216
+ reason=route_class.value,
217
+ problem=ProblemError(
218
+ RATE_LIMITED,
219
+ f"Rate limit reached for {route_class.value} requests. "
220
+ f"Retry in {decision.retry_after_s} s.",
221
+ extensions={
222
+ "class": route_class.value,
223
+ "retry_after_s": decision.retry_after_s,
224
+ },
225
+ headers={RETRY_HEADER: str(decision.retry_after_s)},
226
+ ),
227
+ )
228
+ if runtime.quotas is not None and principal.key_id is not None:
229
+ refused, snapshot = await runtime.quotas.admit(principal.key_id)
230
+ if refused is not None:
231
+ await _refuse(
232
+ request,
233
+ runtime,
234
+ principal,
235
+ reason=f"quota:{refused}",
236
+ problem=quota_problem(refused, snapshot, runtime.auth),
237
+ )
238
+
239
+
240
+ async def _authenticate(request: Request, runtime: LimitRuntime) -> Principal:
241
+ """Authenticate the request, unless its client address or API key prefix has used up its
242
+ failed attempts: then 429, before any lookup or signature check. A 401 takes one failed
243
+ attempt from both buckets; success takes nothing."""
244
+ auth = get_auth(request)
245
+ limiter = runtime.limiter
246
+ if limiter is None or not auth.authenticator.presents_credentials(request.headers):
247
+ return await current_principal(request, auth)
248
+ address = client_address(request)
249
+ prefix = auth.authenticator.key_prefix(request.headers)
250
+ keys = limiter.failure_keys(address, prefix)
251
+ refused = await limiter.failures_exhausted(keys)
252
+ if refused is not None:
253
+ suppress(request)
254
+ logger.debug(
255
+ "authentication throttled",
256
+ extra={"client_address": str(address) if address else None, "key_prefix": prefix},
257
+ )
258
+ raise ProblemError(
259
+ RATE_LIMITED,
260
+ f"Too many failed authentication attempts. Retry in {refused.retry_after_s} s.",
261
+ extensions={"class": AUTH_FAILURES, "retry_after_s": refused.retry_after_s},
262
+ headers={RETRY_HEADER: str(refused.retry_after_s)},
263
+ )
264
+ try:
265
+ return await current_principal(request, auth)
266
+ except ProblemError as exc:
267
+ if exc.problem is UNAUTHORIZED:
268
+ await limiter.record_failure(keys)
269
+ raise
270
+
271
+
272
+ def quota_problem(kind: QuotaKind, snapshot: QuotaSnapshot, auth: AuthRuntime) -> ProblemError:
273
+ """429 for a used-up daily quota, with the time until it resets."""
274
+ wait = max(1, math.ceil((snapshot.resets_at - auth.clock()).total_seconds()))
275
+ limit = snapshot.requests_limit if kind == "requests" else snapshot.tokens_limit
276
+ return ProblemError(
277
+ QUOTA_EXCEEDED,
278
+ f"The daily quota of {limit} {kind.replace('_', ' ')} is used up. "
279
+ f"It resets at {snapshot.resets_at.isoformat()}.",
280
+ extensions={
281
+ "quota": kind,
282
+ "limit": limit,
283
+ "resets_at": snapshot.resets_at.isoformat(),
284
+ "retry_after_s": wait,
285
+ },
286
+ headers={RETRY_HEADER: str(wait)},
287
+ )
288
+
289
+
290
+ async def _refuse(
291
+ request: Request,
292
+ runtime: LimitRuntime,
293
+ principal: Principal,
294
+ *,
295
+ reason: str,
296
+ problem: ProblemError,
297
+ ) -> None:
298
+ """Report the refusal for the audit log and raise it."""
299
+ suppress(request)
300
+ if runtime.watch is not None and (count := runtime.watch.record(principal_label(principal))):
301
+ details: dict[str, Any] = {
302
+ "reason": reason,
303
+ "refusals": count,
304
+ "window_s": runtime.rate.throttle_audit.window_s,
305
+ "method": request.method,
306
+ }
307
+ if principal.key_id:
308
+ details["api_key_id"] = principal.key_id
309
+ domain_id = request.path_params.get("domain_id")
310
+ logger.warning("principal throttled repeatedly", extra={"principal": principal.id})
311
+ writer = runtime.auth.audit_writer
312
+ if writer is not None:
313
+ await writer.write(
314
+ NewAuditEntry(
315
+ actor=principal.id,
316
+ action=THROTTLED_ACTION,
317
+ domain_id=str(domain_id) if domain_id else None,
318
+ target=principal.key_id,
319
+ details=details,
320
+ )
321
+ )
322
+ raise problem
323
+
324
+
325
+ async def check_context_quota(request: Request, principal: Principal) -> None:
326
+ """429 when the API key has used up its daily context-pack tokens."""
327
+ runtime = limits_of(request)
328
+ if runtime is None or runtime.quotas is None or principal.key_id is None:
329
+ return
330
+ snapshot = await runtime.quotas.check_tokens(principal.key_id)
331
+ if snapshot is not None:
332
+ await _refuse(
333
+ request,
334
+ runtime,
335
+ principal,
336
+ reason="quota:context_tokens",
337
+ problem=quota_problem("context_tokens", snapshot, runtime.auth),
338
+ )
339
+
340
+
341
+ async def charge_context_tokens(
342
+ request: Request, principal: Principal, response: Response, chars_per_token: float
343
+ ) -> None:
344
+ """Count the tokens of a delivered context pack against the API key's daily quota."""
345
+ runtime = limits_of(request)
346
+ if runtime is None or runtime.quotas is None or principal.key_id is None:
347
+ return
348
+ if response.status_code != 200:
349
+ return
350
+ tokens = math.ceil(len(response.body) / chars_per_token)
351
+ await runtime.quotas.charge_tokens(principal.key_id, tokens)
352
+
353
+
354
+ class RateLimitHeadersMiddleware:
355
+ """Adds `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds until the bucket
356
+ is full) to the responses of limited routes."""
357
+
358
+ def __init__(self, app: ASGIApp) -> None:
359
+ self.app = app
360
+
361
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
362
+ if scope["type"] != "http":
363
+ await self.app(scope, receive, send)
364
+ return
365
+
366
+ async def send_with_headers(message: Message) -> None:
367
+ if message["type"] == "http.response.start":
368
+ decision = (scope.get("state") or {}).get(STATE_KEY)
369
+ if isinstance(decision, Decision):
370
+ headers = MutableHeaders(scope=message)
371
+ headers[LIMIT_HEADER] = str(decision.limit)
372
+ headers[REMAINING_HEADER] = str(decision.remaining)
373
+ headers[RESET_HEADER] = str(decision.reset_s)
374
+ if not decision.allowed:
375
+ headers[RETRY_HEADER] = str(decision.retry_after_s)
376
+ await send(message)
377
+
378
+ await self.app(scope, receive, send_with_headers)
379
+
380
+
381
+ __all__ = [
382
+ "EXPOSED_HEADERS",
383
+ "QUOTA_EXCEEDED",
384
+ "THROTTLED_ACTION",
385
+ "LimitRuntime",
386
+ "RateLimitHeadersMiddleware",
387
+ "RouteClass",
388
+ "build_limits",
389
+ "charge_context_tokens",
390
+ "check_context_quota",
391
+ "enforce_limits",
392
+ "limits_of",
393
+ ]