throttlekit-py 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.
- throttlekit/__init__.py +86 -0
- throttlekit/_contract.py +67 -0
- throttlekit/_scripts/fixedWindow.check.lua +25 -0
- throttlekit/_scripts/fixedWindow.read.lua +1 -0
- throttlekit/_scripts/gcra.check.lua +27 -0
- throttlekit/_scripts/gcra.read.lua +1 -0
- throttlekit/_scripts/manifest.json +152 -0
- throttlekit/_scripts/slidingWindow.check.lua +50 -0
- throttlekit/_scripts/slidingWindow.read.lua +1 -0
- throttlekit/_scripts/slidingWindowLog.check.lua +45 -0
- throttlekit/_scripts/slidingWindowLog.read.lua +1 -0
- throttlekit/_scripts/tokenBucket.check.lua +31 -0
- throttlekit/_scripts/tokenBucket.read.lua +1 -0
- throttlekit/_version.py +1 -0
- throttlekit/decision.py +38 -0
- throttlekit/errors.py +19 -0
- throttlekit/py.typed +0 -0
- throttlekit/redis_backend.py +105 -0
- throttlekit/service_backend.py +127 -0
- throttlekit/strategies.py +112 -0
- throttlekit_py-0.1.0.dist-info/METADATA +127 -0
- throttlekit_py-0.1.0.dist-info/RECORD +24 -0
- throttlekit_py-0.1.0.dist-info/WHEEL +4 -0
- throttlekit_py-0.1.0.dist-info/licenses/LICENSE +21 -0
throttlekit/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""ThrottleKit — Python client for distributed rate limiting.
|
|
2
|
+
|
|
3
|
+
Two pluggable backends reach the **one** Node core, both proven against the same golden vectors:
|
|
4
|
+
|
|
5
|
+
* :class:`ServiceBackend` — gRPC to a ``throttlekit-server`` (the lead door; the core computes the
|
|
6
|
+
decision, the client never touches the raw wire). ``import``-light: grpc is loaded lazily.
|
|
7
|
+
* :class:`RedisBackend` — runs the vendored Lua against the **same Redis** a Node fleet uses (the
|
|
8
|
+
direct door; decisions are bit-identical to an embedded library). ``check`` only — by design.
|
|
9
|
+
|
|
10
|
+
from throttlekit import ServiceBackend
|
|
11
|
+
with ServiceBackend("localhost:50051") as rl:
|
|
12
|
+
d = rl.check("api", api_key)
|
|
13
|
+
if not d.allowed:
|
|
14
|
+
... # 429; retry after d.retry_after_ms
|
|
15
|
+
|
|
16
|
+
import redis
|
|
17
|
+
from throttlekit import RedisBackend, Gcra
|
|
18
|
+
api = RedisBackend(redis.Redis.from_url("redis://localhost:6379"),
|
|
19
|
+
Gcra(limit=100, period_ms=60_000, burst=20))
|
|
20
|
+
d = api.check(api_key)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
from .decision import Decision, Forecast
|
|
29
|
+
from .errors import (
|
|
30
|
+
OperationNotSupportedError,
|
|
31
|
+
PolicyNotFoundError,
|
|
32
|
+
ServiceUnavailableError,
|
|
33
|
+
ThrottleKitError,
|
|
34
|
+
)
|
|
35
|
+
from .strategies import (
|
|
36
|
+
FixedWindow,
|
|
37
|
+
Gcra,
|
|
38
|
+
SlidingWindow,
|
|
39
|
+
SlidingWindowLog,
|
|
40
|
+
Strategy,
|
|
41
|
+
TokenBucket,
|
|
42
|
+
from_spec,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from .redis_backend import RedisBackend, RedisClientLike
|
|
47
|
+
from .service_backend import ServiceBackend
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# Backends (lazily imported — neither grpc nor a redis client is needed to import throttlekit).
|
|
51
|
+
"ServiceBackend",
|
|
52
|
+
"RedisBackend",
|
|
53
|
+
"RedisClientLike",
|
|
54
|
+
# Strategies for the direct RedisBackend.
|
|
55
|
+
"Strategy",
|
|
56
|
+
"Gcra",
|
|
57
|
+
"TokenBucket",
|
|
58
|
+
"FixedWindow",
|
|
59
|
+
"SlidingWindow",
|
|
60
|
+
"SlidingWindowLog",
|
|
61
|
+
"from_spec",
|
|
62
|
+
# Domain types + errors.
|
|
63
|
+
"Decision",
|
|
64
|
+
"Forecast",
|
|
65
|
+
"ThrottleKitError",
|
|
66
|
+
"PolicyNotFoundError",
|
|
67
|
+
"OperationNotSupportedError",
|
|
68
|
+
"ServiceUnavailableError",
|
|
69
|
+
"__version__",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
_LAZY = {
|
|
73
|
+
"ServiceBackend": "service_backend",
|
|
74
|
+
"RedisBackend": "redis_backend",
|
|
75
|
+
"RedisClientLike": "redis_backend",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def __getattr__(name: str) -> object:
|
|
80
|
+
"""Lazily import the backends so ``import throttlekit`` needs neither grpc nor a redis client."""
|
|
81
|
+
module = _LAZY.get(name)
|
|
82
|
+
if module is not None:
|
|
83
|
+
import importlib
|
|
84
|
+
|
|
85
|
+
return getattr(importlib.import_module(f".{module}", __name__), name)
|
|
86
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
throttlekit/_contract.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Loader for the vendored wire contract — the Redis Lua scripts and their manifest.
|
|
2
|
+
|
|
3
|
+
Reads ``_scripts/manifest.json`` and the extracted ``*.lua`` beside it (the exact bytes the Node core
|
|
4
|
+
publishes, vendored by ``scripts/sync_contract.py``) and exposes, per (strategy, role), the script
|
|
5
|
+
source, its ordered ARGV names, and the **SHA-1** Redis caches the script by. The ARGV order comes
|
|
6
|
+
from the manifest, so the wire — not this client — is the single source of truth for marshalling.
|
|
7
|
+
|
|
8
|
+
The directory lives inside the package, so it resolves identically in a source checkout and an
|
|
9
|
+
installed wheel. Lookups are cached; the files are read once.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from functools import cache, lru_cache
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent / "_scripts"
|
|
22
|
+
_MANIFEST_PATH = _SCRIPTS_DIR / "manifest.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Script:
|
|
27
|
+
"""One vendored Lua script: its source, ordered ARGV names, and the SHA-1 Redis caches it by."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
"""Manifest name, e.g. ``gcra.check``."""
|
|
31
|
+
strategy: str
|
|
32
|
+
role: str
|
|
33
|
+
"""``check`` (returns the reply tuple, may write) or ``read`` (returns raw state, never writes)."""
|
|
34
|
+
argv: tuple[str, ...]
|
|
35
|
+
"""ARGV names in order — ``now`` first, then strategy params, ``cost`` where present."""
|
|
36
|
+
source: str
|
|
37
|
+
"""The exact Lua text (UTF-8)."""
|
|
38
|
+
sha1: str
|
|
39
|
+
"""``sha1(source)`` hex — the key Redis uses for ``EVALSHA`` (computed client-side)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@lru_cache(maxsize=1)
|
|
43
|
+
def _manifest() -> dict[str, Any]:
|
|
44
|
+
data: dict[str, Any] = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8"))
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def contract_version() -> str:
|
|
49
|
+
"""The vendored wire ``contractVersion`` (a behavioral break in the core bumps this)."""
|
|
50
|
+
return str(_manifest()["contractVersion"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cache
|
|
54
|
+
def script(strategy: str, role: str) -> Script:
|
|
55
|
+
"""The vendored :class:`Script` for ``strategy`` (e.g. ``gcra``) and ``role`` (``check``/``read``)."""
|
|
56
|
+
for entry in _manifest()["scripts"]:
|
|
57
|
+
if entry["strategy"] == strategy and entry["role"] == role:
|
|
58
|
+
source = (_SCRIPTS_DIR / entry["file"]).read_text(encoding="utf-8")
|
|
59
|
+
return Script(
|
|
60
|
+
name=entry["name"],
|
|
61
|
+
strategy=strategy,
|
|
62
|
+
role=role,
|
|
63
|
+
argv=tuple(entry["argv"]),
|
|
64
|
+
source=source,
|
|
65
|
+
sha1=hashlib.sha1(source.encode("utf-8")).hexdigest(),
|
|
66
|
+
)
|
|
67
|
+
raise KeyError(f"no vendored script for strategy={strategy!r} role={role!r}")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
local now = tonumber(ARGV[1])
|
|
2
|
+
if now == 0 then
|
|
3
|
+
local t = redis.call('TIME')
|
|
4
|
+
now = t[1] * 1000 + math.floor(t[2] / 1000)
|
|
5
|
+
end
|
|
6
|
+
local limit = tonumber(ARGV[2])
|
|
7
|
+
local window = tonumber(ARGV[3])
|
|
8
|
+
local cost = tonumber(ARGV[4])
|
|
9
|
+
local window_start = math.floor(now / window) * window
|
|
10
|
+
local reset_at = window_start + window
|
|
11
|
+
local h = redis.call('HMGET', KEYS[1], 's', 'c')
|
|
12
|
+
local start = tonumber(h[1])
|
|
13
|
+
local count = tonumber(h[2])
|
|
14
|
+
if start == nil or start ~= window_start then count = 0 end
|
|
15
|
+
if count + cost <= limit then
|
|
16
|
+
local new_count = count + cost
|
|
17
|
+
redis.call('HSET', KEYS[1], 's', window_start, 'c', new_count)
|
|
18
|
+
local px = math.ceil(reset_at - now)
|
|
19
|
+
if px < 1 then px = 1 end
|
|
20
|
+
redis.call('PEXPIRE', KEYS[1], px)
|
|
21
|
+
return {1, limit, limit - new_count, reset_at, 0}
|
|
22
|
+
end
|
|
23
|
+
local remaining = limit - count
|
|
24
|
+
if remaining < 0 then remaining = 0 end
|
|
25
|
+
return {0, limit, remaining, reset_at, math.ceil(reset_at - now)}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
return redis.call('HMGET', KEYS[1], 's', 'c')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
local now = tonumber(ARGV[1])
|
|
2
|
+
if now == 0 then
|
|
3
|
+
local t = redis.call('TIME')
|
|
4
|
+
now = t[1] * 1000 + math.floor(t[2] / 1000)
|
|
5
|
+
end
|
|
6
|
+
local period = tonumber(ARGV[2])
|
|
7
|
+
local limit = tonumber(ARGV[3])
|
|
8
|
+
local burst = tonumber(ARGV[4])
|
|
9
|
+
local cost = tonumber(ARGV[5])
|
|
10
|
+
local T = period / limit
|
|
11
|
+
local tau = T * burst
|
|
12
|
+
local inc = T * cost
|
|
13
|
+
local tat = tonumber(redis.call('GET', KEYS[1]) or now)
|
|
14
|
+
if tat < now then tat = now end
|
|
15
|
+
local new_tat = tat + inc
|
|
16
|
+
local allow_at = new_tat - tau
|
|
17
|
+
if now < allow_at then
|
|
18
|
+
local remaining = math.floor((tau - (tat - now)) / T)
|
|
19
|
+
if remaining < 0 then remaining = 0 end
|
|
20
|
+
return {0, burst, remaining, math.ceil(tat), math.ceil(allow_at - now)}
|
|
21
|
+
end
|
|
22
|
+
local remaining = math.floor((tau - (new_tat - now)) / T)
|
|
23
|
+
if remaining < 0 then remaining = 0 end
|
|
24
|
+
local px = math.ceil(new_tat - now)
|
|
25
|
+
if px < 1 then px = 1 end
|
|
26
|
+
redis.call('SET', KEYS[1], string.format('%.17g', new_tat), 'PX', px)
|
|
27
|
+
return {1, burst, remaining, math.ceil(new_tat), 0}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
return redis.call('GET', KEYS[1])
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contractVersion": "1",
|
|
3
|
+
"generatedFrom": "throttlekit@1.0.1",
|
|
4
|
+
"frozen": false,
|
|
5
|
+
"replyTuple": [
|
|
6
|
+
"allowed",
|
|
7
|
+
"limit",
|
|
8
|
+
"remaining",
|
|
9
|
+
"resetAt",
|
|
10
|
+
"retryAfterMs"
|
|
11
|
+
],
|
|
12
|
+
"nowArgv": "ARGV[1] = now (epoch-ms); the sentinel 0 means use the Redis server TIME clock",
|
|
13
|
+
"scripts": [
|
|
14
|
+
{
|
|
15
|
+
"name": "gcra.check",
|
|
16
|
+
"strategy": "gcra",
|
|
17
|
+
"role": "check",
|
|
18
|
+
"file": "gcra.check.lua",
|
|
19
|
+
"keys": [
|
|
20
|
+
"key"
|
|
21
|
+
],
|
|
22
|
+
"argv": [
|
|
23
|
+
"now",
|
|
24
|
+
"periodMs",
|
|
25
|
+
"limit",
|
|
26
|
+
"burst",
|
|
27
|
+
"cost"
|
|
28
|
+
],
|
|
29
|
+
"sha256": "53d10fddc07382be33764015d50461c25bbcf6d3e8611fd3309307a4b7b7110d"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "gcra.read",
|
|
33
|
+
"strategy": "gcra",
|
|
34
|
+
"role": "read",
|
|
35
|
+
"file": "gcra.read.lua",
|
|
36
|
+
"keys": [
|
|
37
|
+
"key"
|
|
38
|
+
],
|
|
39
|
+
"argv": [],
|
|
40
|
+
"sha256": "6029ea04e805cdfcf433cb1e6bab5df2e740e0f0d1e9b340d56f59e92baee884"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "tokenBucket.check",
|
|
44
|
+
"strategy": "tokenBucket",
|
|
45
|
+
"role": "check",
|
|
46
|
+
"file": "tokenBucket.check.lua",
|
|
47
|
+
"keys": [
|
|
48
|
+
"key"
|
|
49
|
+
],
|
|
50
|
+
"argv": [
|
|
51
|
+
"now",
|
|
52
|
+
"capacity",
|
|
53
|
+
"refillPerSec",
|
|
54
|
+
"cost"
|
|
55
|
+
],
|
|
56
|
+
"sha256": "c29b2ab807ea8665f0b4765929b79f8924563293b385d7736e95aa224e652711"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "tokenBucket.read",
|
|
60
|
+
"strategy": "tokenBucket",
|
|
61
|
+
"role": "read",
|
|
62
|
+
"file": "tokenBucket.read.lua",
|
|
63
|
+
"keys": [
|
|
64
|
+
"key"
|
|
65
|
+
],
|
|
66
|
+
"argv": [],
|
|
67
|
+
"sha256": "b6a70f2889204dbabdc3fed61dd525ddc3527f9331f9016dfa10d6d8737d3017"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "fixedWindow.check",
|
|
71
|
+
"strategy": "fixedWindow",
|
|
72
|
+
"role": "check",
|
|
73
|
+
"file": "fixedWindow.check.lua",
|
|
74
|
+
"keys": [
|
|
75
|
+
"key"
|
|
76
|
+
],
|
|
77
|
+
"argv": [
|
|
78
|
+
"now",
|
|
79
|
+
"limit",
|
|
80
|
+
"windowMs",
|
|
81
|
+
"cost"
|
|
82
|
+
],
|
|
83
|
+
"sha256": "4344c40ef876f144477bd78483f372f20adfe4a068b236407d638adda7cb1890"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "fixedWindow.read",
|
|
87
|
+
"strategy": "fixedWindow",
|
|
88
|
+
"role": "read",
|
|
89
|
+
"file": "fixedWindow.read.lua",
|
|
90
|
+
"keys": [
|
|
91
|
+
"key"
|
|
92
|
+
],
|
|
93
|
+
"argv": [],
|
|
94
|
+
"sha256": "e071b127ae7859d696132cbd55dc848c6a02f97f92e73abc9b65588226de1eab"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "slidingWindow.check",
|
|
98
|
+
"strategy": "slidingWindow",
|
|
99
|
+
"role": "check",
|
|
100
|
+
"file": "slidingWindow.check.lua",
|
|
101
|
+
"keys": [
|
|
102
|
+
"key"
|
|
103
|
+
],
|
|
104
|
+
"argv": [
|
|
105
|
+
"now",
|
|
106
|
+
"windowMs",
|
|
107
|
+
"limit",
|
|
108
|
+
"cost",
|
|
109
|
+
"buckets"
|
|
110
|
+
],
|
|
111
|
+
"sha256": "09944475ccf648f20e4a772337225deabfa946fbcd9a16d286c069e9e32c1d05"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "slidingWindow.read",
|
|
115
|
+
"strategy": "slidingWindow",
|
|
116
|
+
"role": "read",
|
|
117
|
+
"file": "slidingWindow.read.lua",
|
|
118
|
+
"keys": [
|
|
119
|
+
"key"
|
|
120
|
+
],
|
|
121
|
+
"argv": [],
|
|
122
|
+
"sha256": "417aa8f59e6fad78d1c05f71864fef6cbeb1d96347e7f4a6568c3154d860bbcd"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "slidingWindowLog.check",
|
|
126
|
+
"strategy": "slidingWindowLog",
|
|
127
|
+
"role": "check",
|
|
128
|
+
"file": "slidingWindowLog.check.lua",
|
|
129
|
+
"keys": [
|
|
130
|
+
"key"
|
|
131
|
+
],
|
|
132
|
+
"argv": [
|
|
133
|
+
"now",
|
|
134
|
+
"windowMs",
|
|
135
|
+
"limit",
|
|
136
|
+
"cost"
|
|
137
|
+
],
|
|
138
|
+
"sha256": "9345973de84b2a575b8fb1c78afb04c27af894226c83a717c15ca3d50e1f730f"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "slidingWindowLog.read",
|
|
142
|
+
"strategy": "slidingWindowLog",
|
|
143
|
+
"role": "read",
|
|
144
|
+
"file": "slidingWindowLog.read.lua",
|
|
145
|
+
"keys": [
|
|
146
|
+
"key"
|
|
147
|
+
],
|
|
148
|
+
"argv": [],
|
|
149
|
+
"sha256": "ff6b7d1f22ee540d3ca560803e6f42f5ceb42da0f08a93dcf12dfa101f35b2c2"
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
local now = tonumber(ARGV[1])
|
|
2
|
+
if now == 0 then
|
|
3
|
+
local t = redis.call('TIME')
|
|
4
|
+
now = t[1] * 1000 + math.floor(t[2] / 1000)
|
|
5
|
+
end
|
|
6
|
+
local windowMs = tonumber(ARGV[2])
|
|
7
|
+
local limit = tonumber(ARGV[3])
|
|
8
|
+
local cost = tonumber(ARGV[4])
|
|
9
|
+
local S = tonumber(ARGV[5])
|
|
10
|
+
local key = KEYS[1]
|
|
11
|
+
local w = windowMs / S
|
|
12
|
+
local c = math.floor(now / w)
|
|
13
|
+
local elapsed = now - c * w
|
|
14
|
+
if elapsed < 0 then elapsed = 0 end
|
|
15
|
+
local weight = (w - elapsed) / w
|
|
16
|
+
if weight < 0 then weight = 0 end
|
|
17
|
+
if weight > 1 then weight = 1 end
|
|
18
|
+
local slots = S + 1
|
|
19
|
+
local function getCount(idx)
|
|
20
|
+
local v = redis.call('HGET', key, idx % slots)
|
|
21
|
+
if not v then return 0 end
|
|
22
|
+
local sep = string.find(v, ':')
|
|
23
|
+
if tonumber(string.sub(v, 1, sep - 1)) ~= idx then return 0 end
|
|
24
|
+
return tonumber(string.sub(v, sep + 1))
|
|
25
|
+
end
|
|
26
|
+
local full = 0
|
|
27
|
+
for j = c - S + 1, c do full = full + getCount(j) end
|
|
28
|
+
local oldest = getCount(c - S)
|
|
29
|
+
local estimate = full + oldest * weight
|
|
30
|
+
local projected = estimate + cost
|
|
31
|
+
local resetAt = math.ceil((c + 1) * w + windowMs)
|
|
32
|
+
if projected <= limit then
|
|
33
|
+
local cur = getCount(c)
|
|
34
|
+
redis.call('HSET', key, c % slots, c .. ':' .. (cur + cost))
|
|
35
|
+
redis.call('PEXPIRE', key, math.ceil(windowMs + w))
|
|
36
|
+
local remaining = math.floor(limit - projected)
|
|
37
|
+
if remaining < 0 then remaining = 0 end
|
|
38
|
+
return {1, limit, remaining, resetAt, 0}
|
|
39
|
+
end
|
|
40
|
+
local D = projected - limit
|
|
41
|
+
local retry
|
|
42
|
+
if oldest > 0 and D <= oldest * weight then
|
|
43
|
+
retry = math.ceil(D * w / oldest)
|
|
44
|
+
else
|
|
45
|
+
retry = math.ceil((c + 1) * w - now)
|
|
46
|
+
end
|
|
47
|
+
if retry < 1 then retry = 1 end
|
|
48
|
+
local remaining = math.floor(limit - estimate)
|
|
49
|
+
if remaining < 0 then remaining = 0 end
|
|
50
|
+
return {0, limit, remaining, resetAt, retry}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
return redis.call('HGETALL', KEYS[1])
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
local now = tonumber(ARGV[1])
|
|
2
|
+
if now == 0 then
|
|
3
|
+
local t = redis.call('TIME')
|
|
4
|
+
now = t[1] * 1000 + math.floor(t[2] / 1000)
|
|
5
|
+
end
|
|
6
|
+
local windowMs = tonumber(ARGV[2])
|
|
7
|
+
local limit = tonumber(ARGV[3])
|
|
8
|
+
local cost = tonumber(ARGV[4])
|
|
9
|
+
local key = KEYS[1]
|
|
10
|
+
local windowStart = now - windowMs
|
|
11
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)
|
|
12
|
+
local count = redis.call('ZCARD', key)
|
|
13
|
+
if count + cost <= limit then
|
|
14
|
+
for i = 1, cost do
|
|
15
|
+
redis.call('ZADD', key, now, now .. '-' .. (count + i))
|
|
16
|
+
end
|
|
17
|
+
local px = math.ceil(windowMs)
|
|
18
|
+
if px < 1 then px = 1 end
|
|
19
|
+
redis.call('PEXPIRE', key, px)
|
|
20
|
+
local first = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
|
21
|
+
local oldest = now
|
|
22
|
+
if first[2] then oldest = tonumber(first[2]) end
|
|
23
|
+
local remaining = limit - (count + cost)
|
|
24
|
+
if remaining < 0 then remaining = 0 end
|
|
25
|
+
return {1, limit, remaining, math.ceil(oldest + windowMs), 0}
|
|
26
|
+
end
|
|
27
|
+
local retry
|
|
28
|
+
if count == 0 then
|
|
29
|
+
retry = windowMs
|
|
30
|
+
else
|
|
31
|
+
local kMin = count + cost - limit
|
|
32
|
+
if kMin < 1 then kMin = 1 end
|
|
33
|
+
if kMin > count then kMin = count end
|
|
34
|
+
local ref = redis.call('ZRANGE', key, kMin - 1, kMin - 1, 'WITHSCORES')
|
|
35
|
+
local refScore = now
|
|
36
|
+
if ref[2] then refScore = tonumber(ref[2]) end
|
|
37
|
+
retry = math.ceil(refScore + windowMs - now)
|
|
38
|
+
if retry < 1 then retry = 1 end
|
|
39
|
+
end
|
|
40
|
+
local firstD = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
|
41
|
+
local oldestD = now
|
|
42
|
+
if firstD[2] then oldestD = tonumber(firstD[2]) end
|
|
43
|
+
local remaining = limit - count
|
|
44
|
+
if remaining < 0 then remaining = 0 end
|
|
45
|
+
return {0, limit, remaining, math.ceil(oldestD + windowMs), retry}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
return redis.call('ZRANGE', KEYS[1], 0, -1, 'WITHSCORES')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
local now = tonumber(ARGV[1])
|
|
2
|
+
if now == 0 then
|
|
3
|
+
local t = redis.call('TIME')
|
|
4
|
+
now = t[1] * 1000 + math.floor(t[2] / 1000)
|
|
5
|
+
end
|
|
6
|
+
local capacity = tonumber(ARGV[2])
|
|
7
|
+
local refill_per_sec = tonumber(ARGV[3])
|
|
8
|
+
local cost = tonumber(ARGV[4])
|
|
9
|
+
local refill_per_ms = refill_per_sec / 1000
|
|
10
|
+
local h = redis.call('HMGET', KEYS[1], 't', 'l')
|
|
11
|
+
local tokens = tonumber(h[1])
|
|
12
|
+
local last = tonumber(h[2])
|
|
13
|
+
if tokens == nil then tokens = capacity end
|
|
14
|
+
if last == nil then last = now end
|
|
15
|
+
local elapsed = now - last
|
|
16
|
+
if elapsed < 0 then elapsed = 0 end
|
|
17
|
+
tokens = tokens + elapsed * refill_per_ms
|
|
18
|
+
if tokens > capacity then tokens = capacity end
|
|
19
|
+
local ttl = math.ceil(capacity / refill_per_ms)
|
|
20
|
+
if ttl < 1 then ttl = 1 end
|
|
21
|
+
if tokens >= cost then
|
|
22
|
+
local new_tokens = tokens - cost
|
|
23
|
+
local remaining = math.floor(new_tokens)
|
|
24
|
+
if remaining < 0 then remaining = 0 end
|
|
25
|
+
redis.call('HSET', KEYS[1], 't', string.format('%.17g', new_tokens), 'l', string.format('%.17g', now))
|
|
26
|
+
redis.call('PEXPIRE', KEYS[1], ttl)
|
|
27
|
+
return {1, capacity, remaining, now + math.ceil((capacity - new_tokens) / refill_per_ms), 0}
|
|
28
|
+
end
|
|
29
|
+
local remaining = math.floor(tokens)
|
|
30
|
+
if remaining < 0 then remaining = 0 end
|
|
31
|
+
return {0, capacity, remaining, now + math.ceil((capacity - tokens) / refill_per_ms), math.ceil((cost - tokens) / refill_per_ms)}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
return redis.call('HMGET', KEYS[1], 't', 'l')
|
throttlekit/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
throttlekit/decision.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The core domain objects, mirroring the frozen ThrottleKit ``Decision`` / ``Forecast``.
|
|
2
|
+
|
|
3
|
+
Field names are snake_case (Python protobuf keeps the proto's snake_case fields), which is why the
|
|
4
|
+
JSON golden vectors — generated by the Node core with camelCase keys — are mapped explicitly where they
|
|
5
|
+
are consumed, never relied on by attribute name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Decision:
|
|
15
|
+
"""The immutable result of one rate-limit check — the five frozen fields of the core ``Decision``."""
|
|
16
|
+
|
|
17
|
+
allowed: bool
|
|
18
|
+
"""Whether the request is permitted."""
|
|
19
|
+
limit: int
|
|
20
|
+
"""Effective ceiling: burst capacity (GCRA / token bucket) or window quota."""
|
|
21
|
+
remaining: int
|
|
22
|
+
"""Whole units remaining before the next rejection. Never negative."""
|
|
23
|
+
reset_at: int
|
|
24
|
+
"""Epoch-ms at which the limiter is fully replenished."""
|
|
25
|
+
retry_after_ms: int
|
|
26
|
+
"""Milliseconds to wait before retrying. ``0`` when :attr:`allowed`."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Forecast:
|
|
31
|
+
"""A non-consuming projection of a key's near-future capacity."""
|
|
32
|
+
|
|
33
|
+
spendable_now: int
|
|
34
|
+
"""Whole units of the given cost admissible right now before the next denial."""
|
|
35
|
+
next_replenish_at: int
|
|
36
|
+
"""Epoch-ms when capacity next increases by at least one unit."""
|
|
37
|
+
full_at: int
|
|
38
|
+
"""Epoch-ms when the limiter is fully replenished to its ceiling from the current state."""
|
throttlekit/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exceptions raised by the ThrottleKit client. grpc-free so they can be imported without the stubs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ThrottleKitError(Exception):
|
|
7
|
+
"""Base class for all ThrottleKit client errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PolicyNotFoundError(ThrottleKitError):
|
|
11
|
+
"""The service was not configured with the requested policy (gRPC ``NOT_FOUND``)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OperationNotSupportedError(ThrottleKitError):
|
|
15
|
+
"""The policy's strategy does not support a non-consuming op (gRPC ``UNIMPLEMENTED``)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServiceUnavailableError(ThrottleKitError):
|
|
19
|
+
"""The service could not be reached (gRPC ``UNAVAILABLE``). The caller settles fail-open/closed."""
|
throttlekit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The direct ``RedisBackend`` — the second delivery door: vendored Lua, straight to Redis.
|
|
2
|
+
|
|
3
|
+
Where :class:`~throttlekit.ServiceBackend` reaches the Node core over gRPC, this backend talks to the
|
|
4
|
+
**same Redis** a Node fleet uses and runs the **same vendored Lua** the core ships — so its decisions
|
|
5
|
+
are bit-identical to an embedded Node library. That equivalence is *proven*, not asserted: the golden
|
|
6
|
+
vectors replay through real Redis in ``tests/test_redis_backend.py`` and every reply field must match
|
|
7
|
+
the Node oracle.
|
|
8
|
+
|
|
9
|
+
It re-implements **no** rate-limiting math: the decision is computed server-side, in Lua. Accordingly
|
|
10
|
+
it exposes ``check`` **only** — the contract-vectored, Lua-computed decision. ``peek`` / ``forecast`` /
|
|
11
|
+
``check_many`` deliberately route through the service door, where the core (not a re-derived port)
|
|
12
|
+
computes them; reproducing them here would mean porting the read→decision math client-side and so
|
|
13
|
+
re-deriving the decision in a second place, which the design forbids.
|
|
14
|
+
|
|
15
|
+
The backend is client-agnostic: pass any object with ``evalsha`` / ``eval`` (``redis-py`` satisfies it
|
|
16
|
+
structurally), exactly as the Node ``RedisStore`` accepts any ``RedisClientLike``.
|
|
17
|
+
|
|
18
|
+
import redis
|
|
19
|
+
from throttlekit import RedisBackend, Gcra
|
|
20
|
+
|
|
21
|
+
client = redis.Redis.from_url("redis://localhost:6379")
|
|
22
|
+
api = RedisBackend(client, Gcra(limit=100, period_ms=60_000, burst=20), prefix="prod")
|
|
23
|
+
d = api.check(api_key) # now defaults to the Redis server clock (skew-free)
|
|
24
|
+
if not d.allowed:
|
|
25
|
+
... # 429; retry after d.retry_after_ms
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Protocol, cast, runtime_checkable
|
|
31
|
+
|
|
32
|
+
from . import _contract
|
|
33
|
+
from .decision import Decision
|
|
34
|
+
from .strategies import Strategy
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class RedisClientLike(Protocol):
|
|
39
|
+
"""The minimal Redis surface the backend needs. ``redis-py`` (and ioredis-shaped clients) match."""
|
|
40
|
+
|
|
41
|
+
def evalsha(self, sha: str, numkeys: int, *keys_and_args: str | int) -> object: ...
|
|
42
|
+
|
|
43
|
+
def eval(self, script: str, numkeys: int, *keys_and_args: str | int) -> object: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_noscript(err: Exception) -> bool:
|
|
47
|
+
# Redis returns NOSCRIPT when the script cache is empty (first EVALSHA, or after a restart/failover);
|
|
48
|
+
# the client should then EVAL to re-cache it. Detected client-agnostically: redis-py raises
|
|
49
|
+
# ``NoScriptError`` ("No matching script. Please use EVAL.", code folded into the class name),
|
|
50
|
+
# while other clients may surface the raw ``NOSCRIPT …`` server error. Mirrors the Node store's
|
|
51
|
+
# ``isNoScript``.
|
|
52
|
+
blob = f"{type(err).__name__}: {err}".upper()
|
|
53
|
+
return "NOSCRIPT" in blob or "NO MATCHING SCRIPT" in blob
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _decode(raw: object) -> Decision:
|
|
57
|
+
# The reply tuple is five integers: [allowed, limit, remaining, resetAt, retryAfterMs].
|
|
58
|
+
tup = cast("list[int]", raw)
|
|
59
|
+
return Decision(
|
|
60
|
+
allowed=tup[0] == 1,
|
|
61
|
+
limit=tup[1],
|
|
62
|
+
remaining=tup[2],
|
|
63
|
+
reset_at=tup[3],
|
|
64
|
+
retry_after_ms=tup[4],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RedisBackend:
|
|
69
|
+
"""Runs a strategy's vendored Lua against Redis; the returned :class:`Decision` is authoritative.
|
|
70
|
+
|
|
71
|
+
:param client: any object exposing ``evalsha`` / ``eval`` (e.g. ``redis.Redis``).
|
|
72
|
+
:param strategy: the configured :class:`~throttlekit.strategies.Strategy` (``Gcra`` / ``TokenBucket``
|
|
73
|
+
/ ``FixedWindow`` / ``SlidingWindow`` / ``SlidingWindowLog``).
|
|
74
|
+
:param prefix: optional key namespace, joined as ``f"{prefix}:{key}"`` — the **same** scheme the
|
|
75
|
+
core uses, so a Python and a Node client on one limit address the same Redis key.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, client: RedisClientLike, strategy: Strategy, *, prefix: str = "") -> None:
|
|
79
|
+
self._client = client
|
|
80
|
+
self._strategy = strategy
|
|
81
|
+
self._prefix = prefix
|
|
82
|
+
|
|
83
|
+
def check(self, key: str, cost: int = 1, *, now: int | None = None) -> Decision:
|
|
84
|
+
"""Consume ``cost`` units against ``key``.
|
|
85
|
+
|
|
86
|
+
``now`` is epoch-ms; ``None`` (the default) sends the sentinel ``0`` so the script derives time
|
|
87
|
+
from the **Redis server clock** — the skew-free default a distributed fleet must use. Pass an
|
|
88
|
+
explicit ``now`` only for deterministic replay against a known clock.
|
|
89
|
+
"""
|
|
90
|
+
script = _contract.script(self._strategy.kind, "check")
|
|
91
|
+
now_arg = 0 if now is None else now
|
|
92
|
+
values: dict[str, int] = {"now": now_arg, "cost": cost, **self._strategy.params()}
|
|
93
|
+
argv: list[int] = [values[name] for name in script.argv]
|
|
94
|
+
full_key = f"{self._prefix}:{key}" if self._prefix else key
|
|
95
|
+
return _decode(self._eval(script, [full_key], argv))
|
|
96
|
+
|
|
97
|
+
def _eval(self, script: _contract.Script, keys: list[str], argv: list[int]) -> object:
|
|
98
|
+
flat: list[str | int] = [*keys, *argv]
|
|
99
|
+
try:
|
|
100
|
+
return self._client.evalsha(script.sha1, len(keys), *flat)
|
|
101
|
+
except Exception as err:
|
|
102
|
+
if _is_noscript(err):
|
|
103
|
+
# EVAL re-caches the script for next time, then runs it.
|
|
104
|
+
return self._client.eval(script.source, len(keys), *flat)
|
|
105
|
+
raise
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The gRPC ``ServiceBackend`` — a thin client for the ThrottleKit service door.
|
|
2
|
+
|
|
3
|
+
It speaks ``throttlekit.v1.RateLimiter`` and decodes the reply into :class:`Decision` / :class:`Forecast`.
|
|
4
|
+
No rate-limiting math lives here: the Node core (running in the service) computes every decision, and
|
|
5
|
+
the golden vectors prove this client transports them faithfully. A *denial* is a normal ``Decision``
|
|
6
|
+
(``allowed == False``), never an error; gRPC errors map to the operational exceptions in
|
|
7
|
+
:mod:`throttlekit.errors`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
import grpc
|
|
15
|
+
|
|
16
|
+
from .decision import Decision, Forecast
|
|
17
|
+
from .errors import (
|
|
18
|
+
OperationNotSupportedError,
|
|
19
|
+
PolicyNotFoundError,
|
|
20
|
+
ServiceUnavailableError,
|
|
21
|
+
ThrottleKitError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from ._generated import throttlekit_pb2 as pb
|
|
26
|
+
from ._generated import throttlekit_pb2_grpc as pb_grpc
|
|
27
|
+
except ImportError as exc: # pragma: no cover - exercised only when stubs are absent
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"ThrottleKit gRPC stubs are not generated. Run `python scripts/gen_proto.py` "
|
|
30
|
+
"(after `pip install -e .[dev]`) to generate them from the vendored contract."
|
|
31
|
+
) from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _decision(msg: pb.Decision) -> Decision:
|
|
35
|
+
return Decision(
|
|
36
|
+
allowed=msg.allowed,
|
|
37
|
+
limit=msg.limit,
|
|
38
|
+
remaining=msg.remaining,
|
|
39
|
+
reset_at=msg.reset_at,
|
|
40
|
+
retry_after_ms=msg.retry_after_ms,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _forecast(msg: pb.Forecast) -> Forecast:
|
|
45
|
+
return Forecast(
|
|
46
|
+
spendable_now=msg.spendable_now,
|
|
47
|
+
next_replenish_at=msg.next_replenish_at,
|
|
48
|
+
full_at=msg.full_at,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _mapped(err: grpc.RpcError) -> ThrottleKitError:
|
|
53
|
+
code = err.code()
|
|
54
|
+
details = err.details() or ""
|
|
55
|
+
if code == grpc.StatusCode.NOT_FOUND:
|
|
56
|
+
return PolicyNotFoundError(details)
|
|
57
|
+
if code == grpc.StatusCode.UNIMPLEMENTED:
|
|
58
|
+
return OperationNotSupportedError(details)
|
|
59
|
+
if code == grpc.StatusCode.UNAVAILABLE:
|
|
60
|
+
return ServiceUnavailableError(details)
|
|
61
|
+
return ThrottleKitError(f"{code.name}: {details}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ServiceBackend:
|
|
65
|
+
"""A client for a running ``throttlekit-server``.
|
|
66
|
+
|
|
67
|
+
:param target: ``host:port`` of the service (default ``localhost:50051``).
|
|
68
|
+
:param credentials: gRPC channel credentials for TLS/mTLS; ``None`` uses an **insecure** channel
|
|
69
|
+
(loopback/dev only — front anything exposed with mTLS).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
target: str = "localhost:50051",
|
|
75
|
+
*,
|
|
76
|
+
credentials: grpc.ChannelCredentials | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._channel = (
|
|
79
|
+
grpc.secure_channel(target, credentials)
|
|
80
|
+
if credentials is not None
|
|
81
|
+
else grpc.insecure_channel(target)
|
|
82
|
+
)
|
|
83
|
+
self._stub = pb_grpc.RateLimiterStub(self._channel)
|
|
84
|
+
|
|
85
|
+
def check(self, policy: str, key: str, cost: int = 1) -> Decision:
|
|
86
|
+
"""Consume ``cost`` units against ``policy`` for ``key``; the returned decision is authoritative."""
|
|
87
|
+
try:
|
|
88
|
+
resp = self._stub.Check(pb.CheckRequest(policy=policy, key=key, cost=cost))
|
|
89
|
+
except grpc.RpcError as err:
|
|
90
|
+
raise _mapped(err) from err
|
|
91
|
+
return _decision(resp.decision)
|
|
92
|
+
|
|
93
|
+
def check_many(self, policy: str, keys: Sequence[str], cost: int = 1) -> list[Decision]:
|
|
94
|
+
"""Consume ``cost`` units against ``policy`` for many keys at one instant; one decision per key."""
|
|
95
|
+
try:
|
|
96
|
+
resp = self._stub.CheckMany(
|
|
97
|
+
pb.CheckManyRequest(policy=policy, keys=list(keys), cost=cost)
|
|
98
|
+
)
|
|
99
|
+
except grpc.RpcError as err:
|
|
100
|
+
raise _mapped(err) from err
|
|
101
|
+
return [_decision(d) for d in resp.decisions]
|
|
102
|
+
|
|
103
|
+
def peek(self, policy: str, key: str) -> Decision:
|
|
104
|
+
"""Non-consuming peek for ``key`` under ``policy``."""
|
|
105
|
+
try:
|
|
106
|
+
resp = self._stub.Peek(pb.PeekRequest(policy=policy, key=key))
|
|
107
|
+
except grpc.RpcError as err:
|
|
108
|
+
raise _mapped(err) from err
|
|
109
|
+
return _decision(resp.decision)
|
|
110
|
+
|
|
111
|
+
def forecast(self, policy: str, key: str, cost: int = 1) -> Forecast:
|
|
112
|
+
"""Non-consuming capacity forecast for ``key`` under ``policy``."""
|
|
113
|
+
try:
|
|
114
|
+
resp = self._stub.Forecast(pb.ForecastRequest(policy=policy, key=key, cost=cost))
|
|
115
|
+
except grpc.RpcError as err:
|
|
116
|
+
raise _mapped(err) from err
|
|
117
|
+
return _forecast(resp.forecast)
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
"""Close the underlying channel."""
|
|
121
|
+
self._channel.close()
|
|
122
|
+
|
|
123
|
+
def __enter__(self) -> ServiceBackend:
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, *_exc: object) -> None:
|
|
127
|
+
self.close()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Rate-limit strategies for the direct :class:`~throttlekit.RedisBackend`.
|
|
2
|
+
|
|
3
|
+
A strategy is **pure configuration**. It declares its wire ``kind`` and supplies the *named*
|
|
4
|
+
parameter values the script's ARGV references — it contains **no rate-limiting math**, because the
|
|
5
|
+
decision is computed entirely server-side in the vendored Lua (see :mod:`throttlekit.redis_backend`).
|
|
6
|
+
|
|
7
|
+
The ARGV *order* is read from the vendored ``_scripts/manifest.json`` at call time, never hard-coded
|
|
8
|
+
here, so a reordering in the core flows through on re-vendoring with no client change. Each
|
|
9
|
+
strategy's :meth:`params` therefore returns the **manifest ARGV names** (camelCase, e.g. ``periodMs``)
|
|
10
|
+
mapped to its configured values; the backend fills in ``now`` and ``cost`` and resolves the rest by
|
|
11
|
+
name. The Python constructors take idiomatic snake_case; only the dict keys mirror the wire.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import ClassVar, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class Strategy(Protocol):
|
|
23
|
+
"""A configured strategy: its wire ``kind`` and the named ARGV parameters it contributes."""
|
|
24
|
+
|
|
25
|
+
kind: ClassVar[str]
|
|
26
|
+
|
|
27
|
+
def params(self) -> dict[str, int]:
|
|
28
|
+
"""Named ARGV values (besides ``now``/``cost``), keyed by their manifest ARGV name."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Gcra:
|
|
34
|
+
"""GCRA: ``burst`` cells of headroom, draining one cell every ``period_ms / limit``."""
|
|
35
|
+
|
|
36
|
+
kind: ClassVar[str] = "gcra"
|
|
37
|
+
limit: int
|
|
38
|
+
period_ms: int
|
|
39
|
+
burst: int
|
|
40
|
+
|
|
41
|
+
def params(self) -> dict[str, int]:
|
|
42
|
+
return {"limit": self.limit, "periodMs": self.period_ms, "burst": self.burst}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class TokenBucket:
|
|
47
|
+
"""Token bucket: a bucket of ``capacity`` tokens refilling at ``refill_per_sec`` tokens/second."""
|
|
48
|
+
|
|
49
|
+
kind: ClassVar[str] = "tokenBucket"
|
|
50
|
+
capacity: int
|
|
51
|
+
refill_per_sec: int
|
|
52
|
+
|
|
53
|
+
def params(self) -> dict[str, int]:
|
|
54
|
+
return {"capacity": self.capacity, "refillPerSec": self.refill_per_sec}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class FixedWindow:
|
|
59
|
+
"""Fixed window: at most ``limit`` units per epoch-aligned ``window_ms`` window."""
|
|
60
|
+
|
|
61
|
+
kind: ClassVar[str] = "fixedWindow"
|
|
62
|
+
limit: int
|
|
63
|
+
window_ms: int
|
|
64
|
+
|
|
65
|
+
def params(self) -> dict[str, int]:
|
|
66
|
+
return {"limit": self.limit, "windowMs": self.window_ms}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SlidingWindow:
|
|
71
|
+
"""Sliding window: a ``buckets``-bucketed estimate of the trailing ``window_ms``, ceiling ``limit``."""
|
|
72
|
+
|
|
73
|
+
kind: ClassVar[str] = "slidingWindow"
|
|
74
|
+
limit: int
|
|
75
|
+
window_ms: int
|
|
76
|
+
buckets: int
|
|
77
|
+
|
|
78
|
+
def params(self) -> dict[str, int]:
|
|
79
|
+
return {"limit": self.limit, "windowMs": self.window_ms, "buckets": self.buckets}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class SlidingWindowLog:
|
|
84
|
+
"""Sliding window log: exact count of accepted units in the trailing ``window_ms``, ceiling ``limit``."""
|
|
85
|
+
|
|
86
|
+
kind: ClassVar[str] = "slidingWindowLog"
|
|
87
|
+
limit: int
|
|
88
|
+
window_ms: int
|
|
89
|
+
|
|
90
|
+
def params(self) -> dict[str, int]:
|
|
91
|
+
return {"limit": self.limit, "windowMs": self.window_ms}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def from_spec(kind: str, options: Mapping[str, int]) -> Strategy:
|
|
95
|
+
"""Build a strategy from a golden-vector ``strategy.{kind, options}`` spec (camelCase keys).
|
|
96
|
+
|
|
97
|
+
This is the bridge the conformance harness uses to replay the language-neutral vectors through
|
|
98
|
+
the Python client; it is also a convenient constructor from config.
|
|
99
|
+
"""
|
|
100
|
+
if kind == "gcra":
|
|
101
|
+
return Gcra(limit=options["limit"], period_ms=options["periodMs"], burst=options["burst"])
|
|
102
|
+
if kind == "tokenBucket":
|
|
103
|
+
return TokenBucket(capacity=options["capacity"], refill_per_sec=options["refillPerSec"])
|
|
104
|
+
if kind == "fixedWindow":
|
|
105
|
+
return FixedWindow(limit=options["limit"], window_ms=options["windowMs"])
|
|
106
|
+
if kind == "slidingWindow":
|
|
107
|
+
return SlidingWindow(
|
|
108
|
+
limit=options["limit"], window_ms=options["windowMs"], buckets=options["buckets"]
|
|
109
|
+
)
|
|
110
|
+
if kind == "slidingWindowLog":
|
|
111
|
+
return SlidingWindowLog(limit=options["limit"], window_ms=options["windowMs"])
|
|
112
|
+
raise ValueError(f"unknown strategy kind: {kind!r}")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: throttlekit-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for ThrottleKit — distributed rate limiting via gRPC or direct Redis.
|
|
5
|
+
Project-URL: Homepage, https://github.com/AmeyaBorkar/throttlekit-py
|
|
6
|
+
Project-URL: Core (Node), https://www.npmjs.com/package/throttlekit
|
|
7
|
+
Author: Ameya Borkar
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: gcra,grpc,rate-limiting,throttlekit,token-bucket
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: grpcio>=1.60
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
|
|
19
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
21
|
+
Requires-Dist: redis>=5; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
23
|
+
Provides-Extra: redis
|
|
24
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# throttlekit (Python)
|
|
28
|
+
|
|
29
|
+
Python client for [**ThrottleKit**](https://www.npmjs.com/package/throttlekit) — distributed rate
|
|
30
|
+
limiting against the **one** Node core, reached through either of two pluggable backends and proven
|
|
31
|
+
against the **same** golden vectors:
|
|
32
|
+
|
|
33
|
+
| Backend | Path | Decision computed in | Use it when |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| `ServiceBackend` | gRPC → [`throttlekit-server`](https://github.com/AmeyaBorkar/throttlekit/tree/main/server) | the service (= the core) | you want the full surface (`check`/`check_many`/`peek`/`forecast`) and to never touch the raw wire |
|
|
36
|
+
| `RedisBackend` | vendored Lua → the **same Redis** a Node fleet uses | Lua-in-Redis (the core's own script) | you already run Redis and want one hop, no extra service — `check` only |
|
|
37
|
+
|
|
38
|
+
> **Status: experimental (alpha).** The contract (`throttlekit.proto`, the golden vectors, and the
|
|
39
|
+
> extracted Lua) is vendored and checksum-pinned from the frozen `throttlekit` 1.0 core; this client
|
|
40
|
+
> tracks it. The raw Lua wire is **not** a frozen contract yet (it ships `frozen: false`), so the
|
|
41
|
+
> `RedisBackend` is explicitly experimental and may change with the core's scripts.
|
|
42
|
+
|
|
43
|
+
## The one invariant
|
|
44
|
+
|
|
45
|
+
The whole ThrottleKit design rests on it: **exactly one thing computes a `Decision`** — the Node core,
|
|
46
|
+
directly or as Lua-in-Redis. Neither backend re-implements an algorithm, so there is no second rate
|
|
47
|
+
limiter to keep in sync and no float-determinism risk. The `RedisBackend` marshals ARGV, runs the
|
|
48
|
+
core's vendored script, and decodes the reply; the decision is produced **server-side, in Lua**.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
Installed as **`throttlekit-py`**, imported as **`throttlekit`** (PyPI's `throttlekit` is an unrelated
|
|
53
|
+
project):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install throttlekit-py # (alpha; not yet published) — the gRPC ServiceBackend
|
|
57
|
+
pip install "throttlekit-py[redis]" # + a redis client for the direct RedisBackend
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Use — the service door
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from throttlekit import ServiceBackend
|
|
64
|
+
|
|
65
|
+
with ServiceBackend("localhost:50051") as rl:
|
|
66
|
+
d = rl.check("api", api_key)
|
|
67
|
+
if not d.allowed:
|
|
68
|
+
... # 429 — retry after d.retry_after_ms
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`check` / `check_many` / `peek` / `forecast` return frozen `Decision` / `Forecast` dataclasses. A
|
|
72
|
+
*denial* is a normal `Decision` (`allowed is False`), never an exception; gRPC faults map to
|
|
73
|
+
`PolicyNotFoundError` / `OperationNotSupportedError` / `ServiceUnavailableError`.
|
|
74
|
+
|
|
75
|
+
## Use — the direct Redis door
|
|
76
|
+
|
|
77
|
+
Configure a strategy and point it at the Redis your fleet shares. `check` is the whole surface (it is
|
|
78
|
+
the contract-vectored, Lua-computed decision); `peek` / `forecast` deliberately stay on the service
|
|
79
|
+
door, where the core — not a re-derived client port — computes them.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import redis
|
|
83
|
+
from throttlekit import RedisBackend, Gcra
|
|
84
|
+
|
|
85
|
+
client = redis.Redis.from_url("redis://localhost:6379")
|
|
86
|
+
api = RedisBackend(client, Gcra(limit=100, period_ms=60_000, burst=20), prefix="prod")
|
|
87
|
+
|
|
88
|
+
d = api.check(api_key) # now defaults to the Redis server clock (skew-free across a fleet)
|
|
89
|
+
if not d.allowed:
|
|
90
|
+
... # 429 — retry after d.retry_after_ms
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Strategies: `Gcra`, `TokenBucket`, `FixedWindow`, `SlidingWindow`, `SlidingWindowLog`. The `prefix`
|
|
94
|
+
joins as `f"{prefix}:{key}"` — the **same** key scheme the core uses, so a Python and a Node client on
|
|
95
|
+
one limit address the same Redis key. The backend is client-agnostic: pass any object with `evalsha` /
|
|
96
|
+
`eval` (`redis-py` satisfies it structurally), exactly as the Node `RedisStore` does.
|
|
97
|
+
|
|
98
|
+
## How this stays in lock-step with the core
|
|
99
|
+
|
|
100
|
+
`scripts/sync_contract.py` vendors, with checksums, from the core repo:
|
|
101
|
+
|
|
102
|
+
* `contract/` — the dev/test artifacts: `throttlekit.proto` (→ gRPC stubs) and `golden-vectors.json`.
|
|
103
|
+
* `src/throttlekit/_scripts/` — the **runtime** Lua the `RedisBackend` executes (shipped in the wheel),
|
|
104
|
+
with the core's `manifest.json` (which carries each script's sha256).
|
|
105
|
+
|
|
106
|
+
`tests/test_contract.py` is the **drift-gate** (the vendored bytes must match their checksums and the
|
|
107
|
+
pinned `contractVersion`). But the real proof is behavioral:
|
|
108
|
+
|
|
109
|
+
* **`tests/test_redis_backend.py`** replays **every** rate-limit golden vector — the full,
|
|
110
|
+
time-parametrized timeline — through the Python client → vendored Lua → **real Redis**, and asserts
|
|
111
|
+
every reply field equals the Node oracle bit-for-bit. (Because the direct path puts an explicit `now`
|
|
112
|
+
in ARGV, it can do the rigorous time-parametrized replay the cross-process service door can't.)
|
|
113
|
+
* `tests/test_service_backend.py` starts a real `throttlekit-server` and asserts the clock-independent
|
|
114
|
+
behavior over gRPC.
|
|
115
|
+
|
|
116
|
+
## Develop
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install -e .[dev]
|
|
120
|
+
python scripts/sync_contract.py # vendor proto + vectors + Lua from ../GreenfeildProject (the core)
|
|
121
|
+
python scripts/gen_proto.py # generate the gRPC stubs from the vendored proto
|
|
122
|
+
pytest # unit + contract; the Redis/service tests skip if their backend is absent
|
|
123
|
+
ruff check . && mypy # lint + types
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The `RedisBackend` conformance needs a reachable Redis: it uses `THROTTLEKIT_REDIS_URL` or the project
|
|
127
|
+
default `redis://localhost:6380`, and skips cleanly when neither is up.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
throttlekit/__init__.py,sha256=J9WKzfA4c-R7DdhMcTcc_H_CVxwRQmBxgfaor9v2hTc,2608
|
|
2
|
+
throttlekit/_contract.py,sha256=L1WJUzrEe_OoeuLiOWTPYEp3oGywLQyElZzEt90yUPY,2616
|
|
3
|
+
throttlekit/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
throttlekit/decision.py,sha256=iS4pYk3hvMLYmEAW05inYwZlRo0uemTE-VXbGwxdRc4,1438
|
|
5
|
+
throttlekit/errors.py,sha256=WM6SmXY5wyeIMK-s6OXvTedWmwjz8mLuVRVXGpO4Q90,672
|
|
6
|
+
throttlekit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
throttlekit/redis_backend.py,sha256=16XuRpG3Lu5gEYWhZ-ek-QIRL5JioBhOVai9sI3Cnoo,5081
|
|
8
|
+
throttlekit/service_backend.py,sha256=vxxjMFLIq_gmBqoENP8GzSWk3mC06lhzEnGGwsqiOhk,4601
|
|
9
|
+
throttlekit/strategies.py,sha256=_65laLOqwHOWyjF2mLHT9uMOFePJ2Hq-_OQKuMrYEm8,4131
|
|
10
|
+
throttlekit/_scripts/fixedWindow.check.lua,sha256=Q0TEDvh28URHe9eEg_Ny8grf5KBosjZAfWOK3afLGJA,910
|
|
11
|
+
throttlekit/_scripts/fixedWindow.read.lua,sha256=4HGxJ654WdaWEyy9VdyEjGoC-X-S5zq8m2VYgibeHqs,45
|
|
12
|
+
throttlekit/_scripts/gcra.check.lua,sha256=U9EP3cBzgr4zdkAV1QRhwlu89tPoYR_TMJMHpLe3EQ0,949
|
|
13
|
+
throttlekit/_scripts/gcra.read.lua,sha256=YCnqBOgFzfz0M8sea6td8udA4PDR6bNA1W9Z6Suu6IQ,33
|
|
14
|
+
throttlekit/_scripts/manifest.json,sha256=Z17oAO1CV6zqGbevj2BCb9Fd7Owv8lYe0QrYNxFfC1I,3532
|
|
15
|
+
throttlekit/_scripts/slidingWindow.check.lua,sha256=CZREdcz2SPIOSncjNyJd6r-pRvvNmhbShsBp6eMsHQU,1629
|
|
16
|
+
throttlekit/_scripts/slidingWindow.read.lua,sha256=QXqo9Z5vrXjRwF9xhk_vbL6x2WNH5_SmVowxVNhgu80,37
|
|
17
|
+
throttlekit/_scripts/slidingWindowLog.check.lua,sha256=k0WXPehLKldbj7HHivsEwnr4lCJsg6cXwVyj1Q4fcw8,1540
|
|
18
|
+
throttlekit/_scripts/slidingWindowLog.read.lua,sha256=_2t9HyLuVA08pWCAPm9C9c60LaDwipPc8S36EB81ssI,57
|
|
19
|
+
throttlekit/_scripts/tokenBucket.check.lua,sha256=wpsquAfqhmXwtHZZKbefiSRWMpOzhddzbpWqIk5lJxE,1285
|
|
20
|
+
throttlekit/_scripts/tokenBucket.read.lua,sha256=tqcPKIkgTbq9w_7WHdUl3cNSf5Mx-QFt-hDW2HN9MBc,45
|
|
21
|
+
throttlekit_py-0.1.0.dist-info/METADATA,sha256=XOa3_AdthowuvrN0OEhciV9al8d7FI6FGbAAPL3Rtpk,6181
|
|
22
|
+
throttlekit_py-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
23
|
+
throttlekit_py-0.1.0.dist-info/licenses/LICENSE,sha256=5RVRgnVx69ZHhBNGQ675EZ9LwIgnaPc5WpLcteY1ATM,1069
|
|
24
|
+
throttlekit_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ameya Borkar
|
|
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.
|