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.
@@ -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}")
@@ -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')
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.