omnilimit 0.1.0__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.
omnilimit/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """Rate limiting that behaves the same across stacks, and proves it. See spec/SPEC.md."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from omnilimit._generated import SPEC_VERSION
6
+ from omnilimit._headers import build_headers
7
+ from omnilimit._limiter import AsyncLimiter, Limiter
8
+ from omnilimit._types import (
9
+ AsyncStore,
10
+ Decision,
11
+ HeaderInput,
12
+ HeaderProfile,
13
+ HitParams,
14
+ LimiterEvent,
15
+ OnStoreError,
16
+ RawHit,
17
+ Reason,
18
+ Store,
19
+ Tier,
20
+ )
21
+ from omnilimit.stores.memory import MemoryStore
22
+
23
+ try:
24
+ __version__ = version("omnilimit")
25
+ except PackageNotFoundError: # pragma: no cover
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "SPEC_VERSION",
30
+ "AsyncLimiter",
31
+ "AsyncStore",
32
+ "Decision",
33
+ "HeaderInput",
34
+ "HeaderProfile",
35
+ "HitParams",
36
+ "Limiter",
37
+ "LimiterEvent",
38
+ "MemoryStore",
39
+ "OnStoreError",
40
+ "RawHit",
41
+ "Reason",
42
+ "Store",
43
+ "Tier",
44
+ "__version__",
45
+ "build_headers",
46
+ ]
omnilimit/_gcra.py ADDED
@@ -0,0 +1,96 @@
1
+ """The algorithm, on integers only. Every division in this port lives in this file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from omnilimit._types import HitParams, RawHit
9
+
10
+ MIN_WINDOW_MS = 1000
11
+ MAX_SAFE_INTEGER = 2**53 - 1
12
+ _SCOPE = re.compile(r"[A-Za-z0-9._-]{1,64}")
13
+
14
+
15
+ def ceil_div(a: int, b: int) -> int:
16
+ """`ceil(a / b)` for non-negative integers."""
17
+ return (a + b - 1) // b
18
+
19
+
20
+ def _is_positive_int(value: object) -> bool:
21
+ # bool is an int in Python, but `limit=True` is a bug rather than a limit of one.
22
+ return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= MAX_SAFE_INTEGER
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ResolvedPolicy(HitParams):
27
+ burst: int
28
+
29
+
30
+ def resolve_policy(limit: int, window_ms: int, burst: int | None = None) -> ResolvedPolicy:
31
+ """Validate a policy and compute the integers shared with every store."""
32
+ resolved_burst = limit if burst is None else burst
33
+ if not _is_positive_int(limit):
34
+ raise ValueError("omnilimit: limit must be a positive integer")
35
+ if not _is_positive_int(resolved_burst):
36
+ raise ValueError("omnilimit: burst must be a positive integer")
37
+ if not _is_positive_int(window_ms) or window_ms < MIN_WINDOW_MS:
38
+ raise ValueError(f"omnilimit: window_ms must be an integer of at least {MIN_WINDOW_MS}")
39
+ # Python would not overflow, but the same policy must be valid in every language.
40
+ if 2 * resolved_burst * window_ms + limit > MAX_SAFE_INTEGER:
41
+ raise ValueError(
42
+ "omnilimit: policy too large for exact integer arithmetic (2*burst*window_ms + limit)"
43
+ )
44
+ capacity = resolved_burst * window_ms
45
+ return ResolvedPolicy(
46
+ limit=limit,
47
+ window_ms=window_ms,
48
+ capacity=capacity,
49
+ drain_ms=ceil_div(capacity, limit),
50
+ burst=resolved_burst,
51
+ )
52
+
53
+
54
+ def assert_scope(scope: str) -> None:
55
+ if not isinstance(scope, str) or _SCOPE.fullmatch(scope) is None:
56
+ raise ValueError("omnilimit: scope must match ^[A-Za-z0-9._-]{1,64}$")
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class MeterState:
61
+ level: int
62
+ ts: int
63
+
64
+
65
+ def apply_hit(
66
+ state: MeterState | None, params: HitParams, now_ms: int
67
+ ) -> tuple[RawHit, MeterState | None]:
68
+ """Return the hit and the state to persist, which is `None` on denial: denials never write."""
69
+ ts = state.ts if state is not None else now_ms
70
+ level = state.level if state is not None else 0
71
+ t = max(now_ms, ts)
72
+ elapsed = min(t - ts, params.drain_ms)
73
+ drained = max(0, level - elapsed * params.limit)
74
+
75
+ if drained + params.window_ms > params.capacity:
76
+ return RawHit(allowed=False, level=drained, ts=ts), None
77
+ new_level = drained + params.window_ms
78
+ return RawHit(allowed=True, level=new_level, ts=t), MeterState(level=new_level, ts=t)
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class MeterView:
83
+ remaining: int
84
+ reset_ms: int
85
+ retry_after_ms: int | None
86
+ effective_ms: int
87
+
88
+
89
+ def view_of(allowed: bool, level: int, params: HitParams) -> MeterView:
90
+ """Derive the public numbers from a level."""
91
+ remaining = (params.capacity - level) // params.window_ms
92
+ reset_ms = ceil_div(level, params.limit)
93
+ next_unit_ms = ceil_div(max(0, level + params.window_ms - params.capacity), params.limit)
94
+ if not allowed:
95
+ return MeterView(remaining, reset_ms, next_unit_ms, next_unit_ms)
96
+ return MeterView(remaining, reset_ms, None, next_unit_ms if remaining == 0 else reset_ms)
@@ -0,0 +1,5 @@
1
+ # GENERATED by tools/sync_assets.py from spec/scripts. Do not edit.
2
+ SPEC_VERSION = "1"
3
+ REDIS_HIT_SHA1 = "f525d75f3e6a26f742e789522c9055c49bad3cce"
4
+ REDIS_HIT_LUA = "-- omnilimit state format 1. KEYS[1] = bucket key.\n-- ARGV: limit, window_ms, capacity, drain_ms, now_ms ('' = use the server clock).\n-- The script has no division on policy values: every port computes capacity and\n-- drain_ms once, so it behaves identically regardless of the calling language.\nlocal limit = tonumber(ARGV[1])\nlocal window_ms = tonumber(ARGV[2])\nlocal capacity = tonumber(ARGV[3])\nlocal drain_ms = tonumber(ARGV[4])\n\nlocal now\nlocal injected = ARGV[5] ~= nil and ARGV[5] ~= ''\nif injected then\n now = tonumber(ARGV[5])\nelse\n local clock = redis.call('TIME')\n now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)\nend\n\nlocal stored = redis.call('HMGET', KEYS[1], 'l', 't')\nlocal level = tonumber(stored[1]) or 0\nlocal ts = tonumber(stored[2]) or now\n\nlocal t = math.max(now, ts)\nlocal elapsed = math.min(t - ts, drain_ms)\nlocal drained = math.max(0, level - elapsed * limit)\n\nif drained + window_ms > capacity then\n -- Denied requests never write, so a retry storm cannot extend the wait.\n return {0, string.format('%.0f', drained), string.format('%.0f', ts)}\nend\n\nlocal new_level = drained + window_ms\n-- tostring() would render large doubles in scientific notation and lose digits.\nredis.call('HSET', KEYS[1], 'l', string.format('%.0f', new_level), 't', string.format('%.0f', t))\n-- A TTL runs on real time, so it cannot be mixed with an injected clock (conformance vectors only).\nif not injected then\n redis.call('PEXPIRE', KEYS[1], drain_ms)\nend\nreturn {1, string.format('%.0f', new_level), string.format('%.0f', t)}\n"
5
+ POSTGRES_MIGRATION_SQL = "-- omnilimit state format 1. Apply once with your migration tool; omnilimit never runs it for you.\nCREATE TABLE IF NOT EXISTS omnilimit_state (\n key text PRIMARY KEY,\n level bigint NOT NULL,\n ts_ms bigint NOT NULL,\n expires_at_ms bigint NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS omnilimit_state_expires_idx ON omnilimit_state (expires_at_ms);\n\nCREATE OR REPLACE FUNCTION omnilimit_hit_v1(\n p_key text,\n p_limit bigint,\n p_window_ms bigint,\n p_capacity bigint,\n p_drain_ms bigint,\n p_now_ms bigint DEFAULT NULL\n) RETURNS TABLE (allowed boolean, new_level bigint, new_ts bigint)\nLANGUAGE plpgsql\nAS $$\nDECLARE\n v_level bigint;\n v_ts bigint;\n v_now bigint;\n v_t bigint;\n v_drained bigint;\nBEGIN\n -- The placeholder (level 0, ts 0) behaves exactly like a missing row.\n INSERT INTO omnilimit_state AS s (key, level, ts_ms, expires_at_ms)\n VALUES (p_key, 0, 0, 0)\n ON CONFLICT (key) DO NOTHING;\n\n SELECT s.level, s.ts_ms INTO v_level, v_ts\n FROM omnilimit_state AS s\n WHERE s.key = p_key\n FOR UPDATE;\n\n -- Read the clock after taking the lock: waiters must not act on a stale time.\n v_now := COALESCE(p_now_ms, floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint);\n v_t := GREATEST(v_now, v_ts);\n v_drained := GREATEST(0, v_level - LEAST(v_t - v_ts, p_drain_ms) * p_limit);\n\n IF v_drained + p_window_ms > p_capacity THEN\n RETURN QUERY SELECT false, v_drained, v_ts;\n RETURN;\n END IF;\n\n UPDATE omnilimit_state AS s\n SET level = v_drained + p_window_ms,\n ts_ms = v_t,\n expires_at_ms = v_t + p_drain_ms\n WHERE s.key = p_key;\n\n RETURN QUERY SELECT true, v_drained + p_window_ms, v_t;\nEND;\n$$;\n\nCREATE OR REPLACE FUNCTION omnilimit_cleanup_v1(p_batch integer DEFAULT 1000, p_now_ms bigint DEFAULT NULL)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nDECLARE\n v_deleted integer;\n v_now bigint := COALESCE(p_now_ms, floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint);\nBEGIN\n DELETE FROM omnilimit_state\n WHERE key IN (\n SELECT s.key FROM omnilimit_state AS s\n WHERE s.expires_at_ms < v_now\n ORDER BY s.expires_at_ms\n LIMIT p_batch\n FOR UPDATE SKIP LOCKED\n );\n GET DIAGNOSTICS v_deleted = ROW_COUNT;\n RETURN v_deleted;\nEND;\n$$;\n"
omnilimit/_headers.py ADDED
@@ -0,0 +1,27 @@
1
+ """Response headers. `draft-11` is pinned: a newer draft becomes a new profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from omnilimit._gcra import ceil_div
6
+ from omnilimit._types import HeaderInput, HeaderProfile
7
+
8
+
9
+ def _seconds(ms: int) -> int:
10
+ return ceil_div(ms, 1000)
11
+
12
+
13
+ def build_headers(profile: HeaderProfile, data: HeaderInput) -> dict[str, str]:
14
+ decision = data.decision
15
+ out: dict[str, str] = {}
16
+ if decision.remaining is not None:
17
+ t = _seconds(data.effective_ms)
18
+ if profile == "draft-11":
19
+ out["ratelimit-policy"] = f'"{data.scope}";q={data.limit};w={_seconds(data.window_ms)}'
20
+ out["ratelimit"] = f'"{data.scope}";r={decision.remaining};t={t}'
21
+ elif profile == "legacy":
22
+ out["x-ratelimit-limit"] = str(data.limit)
23
+ out["x-ratelimit-remaining"] = str(decision.remaining)
24
+ out["x-ratelimit-reset"] = str(t)
25
+ if not decision.allowed and decision.retry_after_ms is not None:
26
+ out["retry-after"] = str(_seconds(decision.retry_after_ms))
27
+ return out
omnilimit/_key.py ADDED
@@ -0,0 +1,26 @@
1
+ """Bucket keys. The id is hashed: keys stay short and stores never hold raw IPs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ from omnilimit._gcra import ResolvedPolicy
8
+
9
+ _HASH_HEX_LENGTH = 32
10
+ _STATE_FORMAT = 1
11
+
12
+
13
+ def _utf8(text: str) -> bytes:
14
+ """Encode like JavaScript's TextEncoder, where a lone surrogate becomes U+FFFD."""
15
+ try:
16
+ return text.encode("utf-8")
17
+ except UnicodeEncodeError:
18
+ repaired = text.encode("utf-16", "surrogatepass").decode("utf-16", "replace")
19
+ return repaired.encode("utf-8")
20
+
21
+
22
+ def derive_key(scope: str, policy: ResolvedPolicy, salt: str, id_value: str) -> str:
23
+ """Build the bucket key. The policy is part of it, since levels are measured in windows."""
24
+ digest = hashlib.sha256(_utf8(salt) + b"\x00" + _utf8(id_value)).hexdigest()
25
+ shape = f"{policy.limit}-{policy.window_ms}-{policy.burst}"
26
+ return f"ol:{_STATE_FORMAT}:{scope}:{shape}:{digest[:_HASH_HEX_LENGTH]}"
omnilimit/_limiter.py ADDED
@@ -0,0 +1,291 @@
1
+ """`Limiter` and `AsyncLimiter`. The decision logic is shared; only store calls differ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import inspect
8
+ import threading
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import replace
12
+ from typing import Any
13
+
14
+ from omnilimit._gcra import assert_scope, resolve_policy, view_of
15
+ from omnilimit._headers import build_headers
16
+ from omnilimit._key import derive_key
17
+ from omnilimit._types import (
18
+ AsyncStore,
19
+ Decision,
20
+ EventHook,
21
+ HeaderInput,
22
+ HeaderOption,
23
+ LimiterEvent,
24
+ OnStoreError,
25
+ RawHit,
26
+ Store,
27
+ Tier,
28
+ )
29
+
30
+ _DEFAULT_STORE_TIMEOUT_MS = 1000
31
+ _DEFAULT_MAX_STORE_THREADS = 32
32
+ _STORE_ERROR_RETRY_MS = 1000
33
+
34
+
35
+ class _Core:
36
+ """Everything that does no I/O: validation, keys and turning hits into decisions."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ tier: Tier,
42
+ scope: str,
43
+ limit: int,
44
+ window_ms: int,
45
+ burst: int | None,
46
+ on_store_error: OnStoreError,
47
+ store_timeout_ms: int,
48
+ headers: HeaderOption,
49
+ salt: str,
50
+ on_event: EventHook | None,
51
+ ) -> None:
52
+ self.policy = resolve_policy(limit, window_ms, burst)
53
+ assert_scope(scope)
54
+ if on_store_error not in ("open", "closed"):
55
+ raise ValueError("omnilimit: on_store_error is required: 'open' or 'closed'")
56
+ if store_timeout_ms <= 0:
57
+ raise ValueError("omnilimit: store_timeout_ms must be positive")
58
+ self.tier: Tier = tier
59
+ self.scope = scope
60
+ self.on_store_error: OnStoreError = on_store_error
61
+ self.timeout_s = store_timeout_ms / 1000
62
+ self.headers = headers
63
+ self.salt = salt
64
+ self.on_event = on_event
65
+
66
+ def key(self, id_value: str) -> str:
67
+ return derive_key(self.scope, self.policy, self.salt, id_value)
68
+
69
+ def _emit(self, event: LimiterEvent) -> None:
70
+ if self.on_event is None:
71
+ return
72
+ # An observability hook must never change a rate limit decision.
73
+ with contextlib.suppress(Exception):
74
+ self.on_event(event)
75
+
76
+ def _finish(self, decision: Decision, effective_ms: int) -> Decision:
77
+ data = HeaderInput(
78
+ scope=self.scope,
79
+ limit=self.policy.limit,
80
+ window_ms=self.policy.window_ms,
81
+ decision=decision,
82
+ effective_ms=effective_ms,
83
+ )
84
+ built = self.headers(data) if callable(self.headers) else build_headers(self.headers, data)
85
+ return replace(decision, headers=built)
86
+
87
+ def from_hit(self, hit: RawHit) -> Decision:
88
+ self._emit(LimiterEvent("allowed" if hit.allowed else "limited", self.scope, self.tier))
89
+ base = Decision(
90
+ allowed=hit.allowed,
91
+ reason="ok" if hit.allowed else "limited",
92
+ status=None if hit.allowed else 429,
93
+ remaining=None,
94
+ reset_ms=None,
95
+ retry_after_ms=None,
96
+ tier=self.tier,
97
+ )
98
+ if hit.level is None:
99
+ retry_after_ms = None
100
+ if not hit.allowed:
101
+ retry_after_ms = (
102
+ hit.retry_after_ms if hit.retry_after_ms is not None else self.policy.window_ms
103
+ )
104
+ return self._finish(replace(base, retry_after_ms=retry_after_ms), retry_after_ms or 0)
105
+ view = view_of(hit.allowed, hit.level, self.policy)
106
+ decision = replace(
107
+ base,
108
+ remaining=view.remaining,
109
+ reset_ms=view.reset_ms,
110
+ retry_after_ms=view.retry_after_ms,
111
+ )
112
+ return self._finish(decision, view.effective_ms)
113
+
114
+ def from_failure(self, error: Exception) -> Decision:
115
+ self._emit(LimiterEvent("store_error", self.scope, self.tier, error))
116
+ # Reports nothing about quota: the store could not say anything about it.
117
+ allowed = self.on_store_error == "open"
118
+ decision = Decision(
119
+ allowed=allowed,
120
+ reason="store_error",
121
+ status=None if allowed else 503,
122
+ remaining=None,
123
+ reset_ms=None,
124
+ retry_after_ms=None if allowed else _STORE_ERROR_RETRY_MS,
125
+ tier=self.tier,
126
+ )
127
+ return self._finish(decision, 0)
128
+
129
+
130
+ def _run_with_timeout(
131
+ call: Callable[[], RawHit], timeout_s: float, slots: threading.BoundedSemaphore
132
+ ) -> RawHit:
133
+ """Run a blocking call, giving up after `timeout_s`.
134
+
135
+ Python cannot abandon a blocking call, so it runs in its own daemon thread: if it hangs,
136
+ the request moves on and interpreter exit is not held up. `slots` caps those threads,
137
+ so a store that hangs turns into timeouts instead of an unbounded pile of threads.
138
+ """
139
+ deadline = time.monotonic() + timeout_s
140
+ if not slots.acquire(timeout=timeout_s):
141
+ raise TimeoutError("omnilimit: too many store calls in flight")
142
+
143
+ outcome: list[RawHit | Exception] = []
144
+ finished = threading.Event()
145
+
146
+ def work() -> None:
147
+ try:
148
+ outcome.append(call())
149
+ except Exception as error:
150
+ outcome.append(error)
151
+ finally:
152
+ slots.release()
153
+ finished.set()
154
+
155
+ try:
156
+ threading.Thread(target=work, name="omnilimit-store", daemon=True).start()
157
+ except RuntimeError:
158
+ slots.release()
159
+ raise
160
+ if not finished.wait(max(0.0, deadline - time.monotonic())):
161
+ raise TimeoutError(f"omnilimit: store timed out after {timeout_s}s")
162
+ result = outcome[0]
163
+ if isinstance(result, Exception):
164
+ raise result
165
+ return result
166
+
167
+
168
+ class Limiter:
169
+ """A limiter for blocking code: Flask, Django, `def` dependencies in FastAPI.
170
+
171
+ `on_store_error` is required on purpose: a login route usually wants `closed`, a public
172
+ read route usually wants `open`. Configure socket timeouts on your store's client too.
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ *,
178
+ store: Store,
179
+ scope: str,
180
+ limit: int,
181
+ window_ms: int,
182
+ on_store_error: OnStoreError,
183
+ burst: int | None = None,
184
+ store_timeout_ms: int = _DEFAULT_STORE_TIMEOUT_MS,
185
+ max_store_threads: int = _DEFAULT_MAX_STORE_THREADS,
186
+ headers: HeaderOption = "draft-11",
187
+ salt: str = "",
188
+ on_event: EventHook | None = None,
189
+ ) -> None:
190
+ if inspect.iscoroutinefunction(store.hit):
191
+ raise TypeError("omnilimit: this store is async: use AsyncLimiter")
192
+ if max_store_threads < 1:
193
+ raise ValueError("omnilimit: max_store_threads must be positive")
194
+ self._store = store
195
+ self._core = _Core(
196
+ tier=store.tier,
197
+ scope=scope,
198
+ limit=limit,
199
+ window_ms=window_ms,
200
+ burst=burst,
201
+ on_store_error=on_store_error,
202
+ store_timeout_ms=store_timeout_ms,
203
+ headers=headers,
204
+ salt=salt,
205
+ on_event=on_event,
206
+ )
207
+ self._slots = threading.BoundedSemaphore(max_store_threads)
208
+
209
+ @property
210
+ def scope(self) -> str:
211
+ return self._core.scope
212
+
213
+ @property
214
+ def tier(self) -> Tier:
215
+ return self._core.tier
216
+
217
+ def check(self, id_value: str, *, now_ms: int | None = None) -> Decision:
218
+ """Count one request for `id_value`. `now_ms` exists for conformance vectors only."""
219
+ core = self._core
220
+ key = core.key(id_value)
221
+
222
+ def call() -> RawHit:
223
+ return self._store.hit(key, core.policy, now_ms)
224
+
225
+ try:
226
+ # A local store has no I/O, so it cannot hang and does not need a thread.
227
+ hit = (
228
+ call()
229
+ if core.tier == "local"
230
+ else _run_with_timeout(call, core.timeout_s, self._slots)
231
+ )
232
+ except Exception as error:
233
+ return core.from_failure(error)
234
+ return core.from_hit(hit)
235
+
236
+
237
+ class AsyncLimiter:
238
+ """A limiter for event loops. Accepts async stores and `MemoryStore`, which never blocks."""
239
+
240
+ def __init__(
241
+ self,
242
+ *,
243
+ store: AsyncStore | Store,
244
+ scope: str,
245
+ limit: int,
246
+ window_ms: int,
247
+ on_store_error: OnStoreError,
248
+ burst: int | None = None,
249
+ store_timeout_ms: int = _DEFAULT_STORE_TIMEOUT_MS,
250
+ headers: HeaderOption = "draft-11",
251
+ salt: str = "",
252
+ on_event: EventHook | None = None,
253
+ ) -> None:
254
+ if not inspect.iscoroutinefunction(store.hit) and store.tier != "local":
255
+ raise TypeError("omnilimit: this store would block the event loop: use its async form")
256
+ self._store: Any = store
257
+ self._core = _Core(
258
+ tier=store.tier,
259
+ scope=scope,
260
+ limit=limit,
261
+ window_ms=window_ms,
262
+ burst=burst,
263
+ on_store_error=on_store_error,
264
+ store_timeout_ms=store_timeout_ms,
265
+ headers=headers,
266
+ salt=salt,
267
+ on_event=on_event,
268
+ )
269
+
270
+ @property
271
+ def scope(self) -> str:
272
+ return self._core.scope
273
+
274
+ @property
275
+ def tier(self) -> Tier:
276
+ return self._core.tier
277
+
278
+ async def check(self, id_value: str, *, now_ms: int | None = None) -> Decision:
279
+ """Count one request for `id_value`. `now_ms` exists for conformance vectors only."""
280
+ core = self._core
281
+ key = core.key(id_value)
282
+ try:
283
+ result = self._store.hit(key, core.policy, now_ms)
284
+ hit: RawHit = (
285
+ await asyncio.wait_for(result, core.timeout_s)
286
+ if inspect.isawaitable(result)
287
+ else result
288
+ )
289
+ except Exception as error:
290
+ return core.from_failure(error)
291
+ return core.from_hit(hit)
omnilimit/_types.py ADDED
@@ -0,0 +1,89 @@
1
+ """Public data types. See spec/SPEC.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Literal, Protocol, runtime_checkable
8
+
9
+ Tier = Literal["exact", "local", "approximate"]
10
+ Reason = Literal["ok", "limited", "store_error"]
11
+ OnStoreError = Literal["open", "closed"]
12
+ HeaderProfile = Literal["draft-11", "legacy", "none"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class HitParams:
17
+ """Integers computed once by the core, so stores never divide."""
18
+
19
+ limit: int
20
+ window_ms: int
21
+ capacity: int
22
+ drain_ms: int
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class RawHit:
27
+ """What a store answers. Approximate stores leave `level` and `ts` as `None`."""
28
+
29
+ allowed: bool
30
+ level: int | None
31
+ ts: int | None
32
+ retry_after_ms: int | None = None
33
+
34
+
35
+ @runtime_checkable
36
+ class Store(Protocol):
37
+ """A blocking store. It must apply the hit atomically and use its own clock."""
38
+
39
+ tier: Tier
40
+
41
+ def hit(self, key: str, params: HitParams, now_ms: int | None = None) -> RawHit: ...
42
+
43
+
44
+ @runtime_checkable
45
+ class AsyncStore(Protocol):
46
+ """Same contract as `Store`, for event loops."""
47
+
48
+ tier: Tier
49
+
50
+ async def hit(self, key: str, params: HitParams, now_ms: int | None = None) -> RawHit: ...
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class Decision:
55
+ """The outcome of one check. `status` is what to answer with when denied."""
56
+
57
+ allowed: bool
58
+ reason: Reason
59
+ status: Literal[429, 503] | None
60
+ remaining: int | None
61
+ reset_ms: int | None
62
+ retry_after_ms: int | None
63
+ tier: Tier
64
+ headers: dict[str, str] = field(default_factory=dict)
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class HeaderInput:
69
+ """What a custom header function receives. `decision.headers` is still empty."""
70
+
71
+ scope: str
72
+ limit: int
73
+ window_ms: int
74
+ decision: Decision
75
+ effective_ms: int
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class LimiterEvent:
80
+ """Observability event. `error` is only set for `store_error`."""
81
+
82
+ type: Literal["allowed", "limited", "store_error"]
83
+ scope: str
84
+ tier: Tier
85
+ error: Exception | None = None
86
+
87
+
88
+ HeaderOption = HeaderProfile | Callable[[HeaderInput], dict[str, str]]
89
+ EventHook = Callable[[LimiterEvent], None]
omnilimit/fastapi.py ADDED
@@ -0,0 +1,118 @@
1
+ """FastAPI dependency.
2
+
3
+ `rate_limit` raises `HTTPException` on denial, so your own error handlers format the response.
4
+ When the request passes, headers go on the injected `Response`: FastAPI does not copy them if
5
+ the route returns a `Response` object of its own, as a file download usually does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from collections.abc import Awaitable, Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any, Literal
14
+
15
+ from fastapi import HTTPException, Request, Response
16
+
17
+ from omnilimit._limiter import AsyncLimiter, Limiter
18
+ from omnilimit._types import Decision
19
+ from omnilimit.ip import IpPreset, mask_ip, resolve_client_ip
20
+
21
+ MissingKeyPolicy = Literal["shared", "skip", "reject"]
22
+ """`shared`: everyone without a key shares a bucket. `skip`: no limit. `reject`: answer 400."""
23
+
24
+ _SHARED_ID = "__omnilimit_shared__"
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class KeyContext:
29
+ request: Request
30
+ headers: Any
31
+ peer: str | None
32
+
33
+
34
+ KeyFn = Callable[[KeyContext], "Awaitable[str | None] | str | None"]
35
+ """Returns the bucket id, or `None` if it cannot be determined."""
36
+
37
+
38
+ def ip_key(
39
+ *,
40
+ preset: IpPreset | None = None,
41
+ xff_hops: int | None = None,
42
+ header: str | None = None,
43
+ use_peer: bool = False,
44
+ ipv6_prefix: int = 64,
45
+ ) -> Callable[[KeyContext], str | None]:
46
+ """Key requests by trusted client IP. See `omnilimit.ip.resolve_client_ip`."""
47
+
48
+ def key(context: KeyContext) -> str | None:
49
+ ip = resolve_client_ip(
50
+ context.headers,
51
+ context.peer,
52
+ preset=preset,
53
+ xff_hops=xff_hops,
54
+ header=header,
55
+ use_peer=use_peer,
56
+ )
57
+ return None if ip is None else mask_ip(ip, ipv6_prefix)
58
+
59
+ return key
60
+
61
+
62
+ def _context(request: Request) -> KeyContext:
63
+ peer = request.client.host if request.client is not None else None
64
+ return KeyContext(request=request, headers=request.headers, peer=peer)
65
+
66
+
67
+ def _missing(on_missing_key: MissingKeyPolicy) -> str | None:
68
+ """The id to count a keyless request under, or `None` to skip the limit."""
69
+ if on_missing_key == "skip":
70
+ return None
71
+ if on_missing_key == "reject":
72
+ raise HTTPException(status_code=400, detail="client_not_identified")
73
+ return _SHARED_ID
74
+
75
+
76
+ def _apply(decision: Decision, response: Response) -> None:
77
+ if not decision.allowed:
78
+ detail = "rate_limiter_unavailable" if decision.reason == "store_error" else "rate_limited"
79
+ raise HTTPException(
80
+ status_code=decision.status or 429, detail=detail, headers=decision.headers
81
+ )
82
+ for name, value in decision.headers.items():
83
+ response.headers[name] = value
84
+
85
+
86
+ def rate_limit(
87
+ limiter: Limiter | AsyncLimiter, *, key: KeyFn, on_missing_key: MissingKeyPolicy
88
+ ) -> Callable[..., Any]:
89
+ """Build a dependency: `Depends(rate_limit(limiter, key=..., on_missing_key=...))`.
90
+
91
+ `on_missing_key` is required so a misconfigured proxy cannot pass unnoticed.
92
+ """
93
+ if on_missing_key not in ("shared", "skip", "reject"):
94
+ raise ValueError("omnilimit: on_missing_key must be 'shared', 'skip' or 'reject'")
95
+
96
+ if isinstance(limiter, AsyncLimiter):
97
+
98
+ async def async_dependency(request: Request, response: Response) -> None:
99
+ result = key(_context(request))
100
+ id_value = await result if inspect.isawaitable(result) else result
101
+ if id_value is None:
102
+ id_value = _missing(on_missing_key)
103
+ if id_value is not None:
104
+ _apply(await limiter.check(id_value), response)
105
+
106
+ return async_dependency
107
+
108
+ # A plain `def`: FastAPI runs it in its thread pool, so a blocking store is fine.
109
+ def dependency(request: Request, response: Response) -> None:
110
+ result: Any = key(_context(request))
111
+ if inspect.iscoroutine(result):
112
+ result.close()
113
+ raise TypeError("omnilimit: an async key function needs an AsyncLimiter")
114
+ id_value = _missing(on_missing_key) if result is None else result
115
+ if id_value is not None:
116
+ _apply(limiter.check(id_value), response)
117
+
118
+ return dependency
omnilimit/ip.py ADDED
@@ -0,0 +1,131 @@
1
+ """Trusted client IPs, using only the standard library.
2
+
3
+ This module depends on nothing else in omnilimit, so it also works next to `slowapi` or anything
4
+ else needing a client IP. It returns `None` rather than guessing when the configured source is
5
+ missing or malformed, and it never falls back to the first value of `X-Forwarded-For`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ import re
12
+ from typing import Any, Literal
13
+
14
+ IpPreset = Literal["cloudflare", "cloud-run", "cloud-run-lb", "vercel", "direct"]
15
+
16
+ _IPV4_OCTET = re.compile(r"0|[1-9][0-9]{0,2}")
17
+
18
+ # preset: (header to read, X-Forwarded-For hops); neither means the TCP peer
19
+ _PRESETS: dict[str, tuple[str | None, int | None]] = {
20
+ "cloudflare": ("cf-connecting-ip", None),
21
+ "cloud-run": (None, 0),
22
+ "cloud-run-lb": (None, 1),
23
+ "vercel": ("x-vercel-forwarded-for", None),
24
+ "direct": (None, None),
25
+ }
26
+
27
+
28
+ def _read_header(headers: Any, name: str) -> str | None:
29
+ # A header can arrive on several lines, and `.get` on Starlette or Werkzeug headers only
30
+ # returns the first one: a client-supplied line could then pass for the trusted value.
31
+ getlist = getattr(headers, "getlist", None)
32
+ value = getlist(name) if callable(getlist) else headers.get(name)
33
+ if value is None:
34
+ return None
35
+ if isinstance(value, list | tuple):
36
+ return ",".join(value) if value else None
37
+ return str(value)
38
+
39
+
40
+ def _canonical_ipv4(raw: str) -> str | None:
41
+ parts = raw.split(".")
42
+ if len(parts) != 4:
43
+ return None
44
+ for part in parts:
45
+ if _IPV4_OCTET.fullmatch(part) is None or int(part) > 255:
46
+ return None
47
+ return raw
48
+
49
+
50
+ def _parse_ipv6(raw: str) -> ipaddress.IPv6Address | None:
51
+ # `ipaddress` accepts zone identifiers, which would give one host many buckets.
52
+ if "%" in raw:
53
+ return None
54
+ try:
55
+ return ipaddress.IPv6Address(raw)
56
+ except ValueError:
57
+ return None
58
+
59
+
60
+ def canonical_ip(raw: str | None) -> str | None:
61
+ """Validate and normalise an address (RFC 5952; IPv4-mapped becomes IPv4), or `None`."""
62
+ if not raw:
63
+ return None
64
+ if ":" not in raw:
65
+ return _canonical_ipv4(raw)
66
+ address = _parse_ipv6(raw)
67
+ if address is None:
68
+ return None
69
+ if address.ipv4_mapped is not None:
70
+ return str(address.ipv4_mapped)
71
+ return address.compressed
72
+
73
+
74
+ def mask_ip(ip: str, prefix: int) -> str | None:
75
+ """Keep an IPv6 network prefix, since one customer commonly controls a whole /64.
76
+
77
+ IPv4 addresses are returned unchanged.
78
+ """
79
+ canonical = canonical_ip(ip)
80
+ if canonical is None:
81
+ return None
82
+ if ":" not in canonical:
83
+ return canonical
84
+ if not isinstance(prefix, int) or isinstance(prefix, bool) or not 0 <= prefix <= 128:
85
+ raise ValueError("omnilimit: IPv6 prefix must be an integer from 0 to 128")
86
+ network = ipaddress.IPv6Network((canonical, prefix), strict=False)
87
+ return network.network_address.compressed
88
+
89
+
90
+ def _from_xff(value: str | None, hops: int) -> str | None:
91
+ if value is None:
92
+ return None
93
+ entries = [entry.strip() for entry in value.split(",")]
94
+ # The client controls everything to the left of what your proxies appended, so a chain
95
+ # that is too short must never fall back to its first value.
96
+ if len(entries) <= hops:
97
+ return None
98
+ return canonical_ip(entries[len(entries) - 1 - hops])
99
+
100
+
101
+ def resolve_client_ip(
102
+ headers: Any,
103
+ peer: str | None = None,
104
+ *,
105
+ preset: IpPreset | None = None,
106
+ xff_hops: int | None = None,
107
+ header: str | None = None,
108
+ use_peer: bool = False,
109
+ ) -> str | None:
110
+ """Resolve the client IP from the one source you name.
111
+
112
+ Choose exactly one: `preset`, `xff_hops` (read `X-Forwarded-For` from the right, skipping
113
+ entries added by your own proxies), `header` (a single-IP header your edge overwrites) or
114
+ `use_peer` (the TCP peer). `headers` is any mapping with lowercase names, or the headers
115
+ object of your framework.
116
+ """
117
+ chosen = sum((preset is not None, xff_hops is not None, header is not None, use_peer))
118
+ if chosen != 1:
119
+ raise ValueError("omnilimit: choose exactly one of preset, xff_hops, header or use_peer")
120
+ if preset is not None:
121
+ if preset not in _PRESETS:
122
+ raise ValueError("omnilimit: unknown IP preset")
123
+ header, xff_hops = _PRESETS[preset]
124
+ if xff_hops is not None:
125
+ if not isinstance(xff_hops, int) or isinstance(xff_hops, bool) or xff_hops < 0:
126
+ raise ValueError("omnilimit: hops must be a non-negative integer")
127
+ return _from_xff(_read_header(headers, "x-forwarded-for"), xff_hops)
128
+ if header is not None:
129
+ value = _read_header(headers, header.lower())
130
+ return canonical_ip(value.strip() if value is not None else None)
131
+ return canonical_ip(peer)
omnilimit/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Stores. Each module imports its client lazily or not at all, so extras stay optional."""
@@ -0,0 +1,49 @@
1
+ """In-process store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections import OrderedDict
8
+ from typing import Literal
9
+
10
+ from omnilimit._gcra import MeterState, apply_hit
11
+ from omnilimit._types import HitParams, RawHit
12
+
13
+
14
+ class MemoryStore:
15
+ """Exact within one process and independent per instance: N instances allow about N times
16
+ the limit. It is thread-safe, and `max_keys` stops rotating ids from exhausting memory.
17
+ """
18
+
19
+ tier: Literal["local"] = "local"
20
+
21
+ def __init__(self, max_keys: int = 100_000) -> None:
22
+ if not isinstance(max_keys, int) or isinstance(max_keys, bool) or max_keys < 1:
23
+ raise ValueError("omnilimit: max_keys must be a positive integer")
24
+ self._max_keys = max_keys
25
+ self._lock = threading.Lock()
26
+ # key -> (state, expires_at); insertion order tracks recency
27
+ self._entries: OrderedDict[str, tuple[MeterState, int]] = OrderedDict()
28
+
29
+ def hit(self, key: str, params: HitParams, now_ms: int | None = None) -> RawHit:
30
+ now = now_ms if now_ms is not None else time.time_ns() // 1_000_000
31
+ with self._lock:
32
+ entry = self._entries.get(key)
33
+ hit, write = apply_hit(entry[0] if entry else None, params, now)
34
+ if write is not None:
35
+ self._entries[key] = (write, write.ts + params.drain_ms)
36
+ self._entries.move_to_end(key)
37
+ self._evict(now)
38
+ return hit
39
+
40
+ def _evict(self, now: int) -> None:
41
+ if len(self._entries) <= self._max_keys:
42
+ return
43
+ for key, (_, expires_at) in list(self._entries.items()):
44
+ if expires_at <= now:
45
+ del self._entries[key]
46
+ if len(self._entries) <= self._max_keys:
47
+ return
48
+ while len(self._entries) > self._max_keys:
49
+ self._entries.popitem(last=False)
@@ -0,0 +1,91 @@
1
+ """Postgres stores: atomic and shared, using a row lock per bucket and the database clock.
2
+
3
+ They are good when Postgres is already there. Each hit is a write, so they are not meant for
4
+ high traffic. They take a psycopg 3 pool, or a connection with `autocommit=True`: anything else
5
+ would hold the bucket's row lock until the surrounding transaction ends.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Literal
11
+
12
+ from omnilimit._generated import POSTGRES_MIGRATION_SQL
13
+ from omnilimit._types import HitParams, RawHit
14
+
15
+ MIGRATION_SQL = POSTGRES_MIGRATION_SQL
16
+ """Creates the table and functions. Apply it with your migration tool; it never runs on its own."""
17
+
18
+ _HIT_SQL = (
19
+ "SELECT allowed, new_level, new_ts FROM omnilimit_hit_v1("
20
+ "%s::text, %s::bigint, %s::bigint, %s::bigint, %s::bigint, %s::bigint)"
21
+ )
22
+ _CLEANUP_SQL = "SELECT omnilimit_cleanup_v1(%s::integer)"
23
+
24
+
25
+ def _is_pool(db: Any) -> bool:
26
+ # A connection also has a `.connection` attribute, so that cannot tell them apart.
27
+ return not hasattr(db, "execute")
28
+
29
+
30
+ def _check_db(db: Any) -> None:
31
+ if not _is_pool(db) and getattr(db, "autocommit", False) is not True:
32
+ raise ValueError("omnilimit: pass a psycopg pool, or a connection with autocommit=True")
33
+
34
+
35
+ def _hit_params(key: str, params: HitParams, now_ms: int | None) -> tuple[Any, ...]:
36
+ return (key, params.limit, params.window_ms, params.capacity, params.drain_ms, now_ms)
37
+
38
+
39
+ def _parse(row: Any) -> RawHit:
40
+ if row is None:
41
+ raise ValueError("omnilimit: omnilimit_hit_v1 returned no rows")
42
+ return RawHit(allowed=row[0] is True, level=int(row[1]), ts=int(row[2]))
43
+
44
+
45
+ class PostgresStore:
46
+ """For blocking connections and pools. Use it with `Limiter`."""
47
+
48
+ tier: Literal["exact"] = "exact"
49
+
50
+ def __init__(self, db: Any) -> None:
51
+ _check_db(db)
52
+ self._db = db
53
+
54
+ def _fetch(self, sql: str, params: tuple[Any, ...]) -> Any:
55
+ if _is_pool(self._db):
56
+ with self._db.connection() as connection:
57
+ return connection.execute(sql, params).fetchone()
58
+ return self._db.execute(sql, params).fetchone()
59
+
60
+ def hit(self, key: str, params: HitParams, now_ms: int | None = None) -> RawHit:
61
+ return _parse(self._fetch(_HIT_SQL, _hit_params(key, params, now_ms)))
62
+
63
+ def cleanup(self, batch: int = 1000) -> int:
64
+ """Delete up to `batch` expired buckets and return how many. Schedule it yourself."""
65
+ return int(self._fetch(_CLEANUP_SQL, (batch,))[0])
66
+
67
+
68
+ class AsyncPostgresStore:
69
+ """For async connections and pools. Use it with `AsyncLimiter`."""
70
+
71
+ tier: Literal["exact"] = "exact"
72
+
73
+ def __init__(self, db: Any) -> None:
74
+ _check_db(db)
75
+ self._db = db
76
+
77
+ async def _fetch(self, sql: str, params: tuple[Any, ...]) -> Any:
78
+ if _is_pool(self._db):
79
+ async with self._db.connection() as connection:
80
+ cursor = await connection.execute(sql, params)
81
+ return await cursor.fetchone()
82
+ cursor = await self._db.execute(sql, params)
83
+ return await cursor.fetchone()
84
+
85
+ async def hit(self, key: str, params: HitParams, now_ms: int | None = None) -> RawHit:
86
+ return _parse(await self._fetch(_HIT_SQL, _hit_params(key, params, now_ms)))
87
+
88
+ async def cleanup(self, batch: int = 1000) -> int:
89
+ """Delete up to `batch` expired buckets and return how many. Schedule it yourself."""
90
+ row = await self._fetch(_CLEANUP_SQL, (batch,))
91
+ return int(row[0])
@@ -0,0 +1,77 @@
1
+ """Redis and Valkey stores: atomic, shared across instances and languages, on the server clock.
2
+
3
+ They take a client from `redis` or `valkey`, or anything with the same `evalsha` and `eval`.
4
+ Set `socket_timeout` and `socket_connect_timeout` on it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from typing import Any, Literal
11
+
12
+ from omnilimit._generated import REDIS_HIT_LUA, REDIS_HIT_SHA1
13
+ from omnilimit._types import HitParams, RawHit
14
+
15
+
16
+ def _is_no_script(error: Exception) -> bool:
17
+ # redis-py strips the NOSCRIPT prefix from the message and raises NoScriptError.
18
+ return (
19
+ type(error).__name__ == "NoScriptError"
20
+ or "NOSCRIPT" in str(error)
21
+ or "No matching script" in str(error)
22
+ )
23
+
24
+
25
+ def _args(params: HitParams, now_ms: int | None) -> list[str]:
26
+ return [
27
+ str(params.limit),
28
+ str(params.window_ms),
29
+ str(params.capacity),
30
+ str(params.drain_ms),
31
+ "" if now_ms is None else str(now_ms),
32
+ ]
33
+
34
+
35
+ def _parse(reply: Any) -> RawHit:
36
+ if not isinstance(reply, Sequence) or isinstance(reply, str | bytes) or len(reply) != 3:
37
+ raise ValueError("omnilimit: unexpected reply from the Redis script")
38
+ # Values arrive as bytes or str, depending on the client's decode_responses.
39
+ return RawHit(allowed=int(reply[0]) == 1, level=int(reply[1]), ts=int(reply[2]))
40
+
41
+
42
+ class RedisStore:
43
+ """For blocking clients. Use it with `Limiter`."""
44
+
45
+ tier: Literal["exact"] = "exact"
46
+
47
+ def __init__(self, client: Any) -> None:
48
+ self._client = client
49
+
50
+ def hit(self, key: str, params: HitParams, now_ms: int | None = None) -> RawHit:
51
+ args = _args(params, now_ms)
52
+ try:
53
+ reply = self._client.evalsha(REDIS_HIT_SHA1, 1, key, *args)
54
+ except Exception as error:
55
+ if not _is_no_script(error):
56
+ raise
57
+ reply = self._client.eval(REDIS_HIT_LUA, 1, key, *args)
58
+ return _parse(reply)
59
+
60
+
61
+ class AsyncRedisStore:
62
+ """For `redis.asyncio` and `valkey.asyncio` clients. Use it with `AsyncLimiter`."""
63
+
64
+ tier: Literal["exact"] = "exact"
65
+
66
+ def __init__(self, client: Any) -> None:
67
+ self._client = client
68
+
69
+ async def hit(self, key: str, params: HitParams, now_ms: int | None = None) -> RawHit:
70
+ args = _args(params, now_ms)
71
+ try:
72
+ reply = await self._client.evalsha(REDIS_HIT_SHA1, 1, key, *args)
73
+ except Exception as error:
74
+ if not _is_no_script(error):
75
+ raise
76
+ reply = await self._client.eval(REDIS_HIT_LUA, 1, key, *args)
77
+ return _parse(reply)
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: omnilimit
3
+ Version: 0.1.0
4
+ Summary: Rate limiting that behaves the same across stacks, with trusted client IPs and an explicit failure mode.
5
+ Project-URL: Homepage, https://github.com/PabloAlaniz/omnilimit
6
+ Project-URL: Repository, https://github.com/PabloAlaniz/omnilimit
7
+ Project-URL: Specification, https://github.com/PabloAlaniz/omnilimit/blob/main/spec/SPEC.md
8
+ Author: Pablo Alaniz
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: fastapi,gcra,postgres,rate-limit,rate-limiting,redis,x-forwarded-for
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Provides-Extra: fastapi
23
+ Requires-Dist: fastapi>=0.109; extra == 'fastapi'
24
+ Provides-Extra: postgres
25
+ Requires-Dist: psycopg>=3.1; extra == 'postgres'
26
+ Provides-Extra: redis
27
+ Requires-Dist: redis>=5.0; extra == 'redis'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # omnilimit for Python
31
+
32
+ Rate limiting that behaves the same across stacks, and proves it. This port follows the same [spec](https://github.com/PabloAlaniz/omnilimit/blob/main/spec/SPEC.md) and passes the same conformance vectors as the TypeScript one, and both can share a counter in Redis or Postgres.
33
+
34
+ - **Trusted client IPs.** It reads `X-Forwarded-For` from the right. Rotating the header does not evade the limit.
35
+ - **An explicit failure mode.** You must decide, per limiter, what happens when the store is down.
36
+ - **No dependencies.** Extras only install a client: `omnilimit[redis]`, `omnilimit[postgres]`, `omnilimit[fastapi]`.
37
+
38
+ ```bash
39
+ pip install omnilimit
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from redis import Redis
46
+
47
+ from omnilimit import Limiter
48
+ from omnilimit.stores.redis import RedisStore
49
+
50
+ client = Redis.from_url(url, socket_timeout=0.25, socket_connect_timeout=0.25)
51
+ limiter = Limiter(
52
+ store=RedisStore(client),
53
+ scope="login",
54
+ limit=5,
55
+ window_ms=60_000,
56
+ burst=2,
57
+ on_store_error="closed", # required: "open" lets requests through, "closed" answers 503
58
+ )
59
+
60
+ decision = limiter.check(user_id)
61
+ if not decision.allowed:
62
+ ... # answer decision.status with decision.headers, which include Retry-After
63
+ ```
64
+
65
+ `5` per minute with burst 5 can admit 9 requests in one minute: this is a rate, not a counter that resets. Lower `burst` if you need fewer.
66
+
67
+ ## Sync or async
68
+
69
+ | Code | Limiter | Stores |
70
+ |---|---|---|
71
+ | Flask, Django, `def` dependencies | `Limiter` | `MemoryStore`, `RedisStore`, `PostgresStore` |
72
+ | `async def` | `AsyncLimiter` | `MemoryStore`, `AsyncRedisStore`, `AsyncPostgresStore` |
73
+
74
+ `Limiter` runs each store call in its own daemon thread, because Python cannot abandon a blocking call. `store_timeout_ms` bounds the request, and `max_store_threads` bounds the calls in flight. Set socket timeouts on your client as well, so a hung call ends.
75
+
76
+ redis-py caps its pool and raises past that cap, which `omnilimit` reports as a store failure. If you expect bursts of concurrent checks, use `BlockingConnectionPool`.
77
+
78
+ ## FastAPI
79
+
80
+ ```python
81
+ from fastapi import Depends, FastAPI
82
+
83
+ from omnilimit import MemoryStore, Limiter
84
+ from omnilimit.fastapi import ip_key, rate_limit
85
+
86
+ limiter = Limiter(
87
+ store=MemoryStore(), scope="api", limit=100, window_ms=60_000, on_store_error="open"
88
+ )
89
+ app = FastAPI(dependencies=[
90
+ Depends(rate_limit(limiter, key=ip_key(preset="cloud-run"), on_missing_key="shared")),
91
+ ])
92
+ ```
93
+
94
+ Denials raise `HTTPException`, so your own handlers format them. Headers on allowed requests are not added when a route returns its own `Response` object, as a file download usually does.
95
+
96
+ ## Client IP only
97
+
98
+ `omnilimit.ip` uses only the standard library and also works next to `slowapi`:
99
+
100
+ ```python
101
+ from omnilimit.ip import resolve_client_ip
102
+
103
+ ip = resolve_client_ip(request.headers, request.client.host, preset="cloud-run-lb")
104
+ ```
105
+
106
+ It returns `None` rather than guessing. Check that the preset matches your deployment: see [client IP](https://github.com/PabloAlaniz/omnilimit/blob/main/docs/client-ip.md).
107
+
108
+ ## Postgres
109
+
110
+ `omnilimit.stores.postgres.MIGRATION_SQL` creates the table and functions: apply it with your migration tool. Stores take a psycopg 3 pool or a connection with `autocommit=True`. Each hit is a write, so it is not meant for high traffic, and `cleanup()` is yours to schedule.
@@ -0,0 +1,18 @@
1
+ omnilimit/__init__.py,sha256=N2saauxmlT7Uz2_fdyCqtPsGl72q3on1KCWHUcpnyyQ,973
2
+ omnilimit/_gcra.py,sha256=aaTckuox3Mp_SZNjr6uex8zF4Ig9XdPqUY0ljSBQJgI,3502
3
+ omnilimit/_generated.py,sha256=fPemu-Ld9EDjEo9t8LqLbTXduh94ZL-2aRFqOyuThSc,4090
4
+ omnilimit/_headers.py,sha256=SBGIL7BRPI7FrmKpwPC8l0OrossqhysJD-ZXaVGt4OI,1060
5
+ omnilimit/_key.py,sha256=zA_1bXP8i9bvbV0wfyFQD_wbNBiw8TIcxJn5_W-liF4,948
6
+ omnilimit/_limiter.py,sha256=pJznnD2n_i0MHoziPHTy7od9OX8SgzEi7-57QDiSyZo,9680
7
+ omnilimit/_types.py,sha256=Oo8URs2fZ5jxB-Y2ItcJp_to2CJY9MTK5Qatgmue8ug,2253
8
+ omnilimit/fastapi.py,sha256=3RoErR9tx5XiLzde3qe9abYhOZl1amy1lvjiyXe4-w4,4148
9
+ omnilimit/ip.py,sha256=cOb5Ns1nD6zgw78vg6OP87FjoVRXYqgDJp9uwomAufw,4823
10
+ omnilimit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ omnilimit/stores/__init__.py,sha256=CRj-mzVzNgedxEGaSaK5pcagYYAgJOEW2FqY5HfqKyU,92
12
+ omnilimit/stores/memory.py,sha256=bY6DrRIgH_FXhZYO3HkH7Yk_a3N9x8xOhl0PpRbiK4E,1887
13
+ omnilimit/stores/postgres.py,sha256=lwHvxauF7-UIx9ZteqBTydi9ligjdFK2cRtVwyOd9FU,3482
14
+ omnilimit/stores/redis.py,sha256=DCNdshsxBeu8YcuNv2c5yFaYNYTjfV9FlZC-JZYz3ps,2611
15
+ omnilimit-0.1.0.dist-info/METADATA,sha256=VcO_MDWvJ0iipBKGNAtlUVJS4vzkLF2ZghaRZ2_vR0M,4545
16
+ omnilimit-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
17
+ omnilimit-0.1.0.dist-info/licenses/LICENSE,sha256=25xFe8kmZ3TZzs93SbzlDP_OHHkT5lsANRqQ_Ao2A0M,1069
18
+ omnilimit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pablo Alaniz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.