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 +46 -0
- omnilimit/_gcra.py +96 -0
- omnilimit/_generated.py +5 -0
- omnilimit/_headers.py +27 -0
- omnilimit/_key.py +26 -0
- omnilimit/_limiter.py +291 -0
- omnilimit/_types.py +89 -0
- omnilimit/fastapi.py +118 -0
- omnilimit/ip.py +131 -0
- omnilimit/py.typed +0 -0
- omnilimit/stores/__init__.py +1 -0
- omnilimit/stores/memory.py +49 -0
- omnilimit/stores/postgres.py +91 -0
- omnilimit/stores/redis.py +77 -0
- omnilimit-0.1.0.dist-info/METADATA +110 -0
- omnilimit-0.1.0.dist-info/RECORD +18 -0
- omnilimit-0.1.0.dist-info/WHEEL +4 -0
- omnilimit-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|
omnilimit/_generated.py
ADDED
|
@@ -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,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.
|