throttlekit-py 0.1.0__tar.gz
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_py-0.1.0/.gitattributes +4 -0
- throttlekit_py-0.1.0/.github/workflows/ci.yml +55 -0
- throttlekit_py-0.1.0/.github/workflows/release.yml +48 -0
- throttlekit_py-0.1.0/.gitignore +19 -0
- throttlekit_py-0.1.0/LICENSE +21 -0
- throttlekit_py-0.1.0/PKG-INFO +127 -0
- throttlekit_py-0.1.0/README.md +101 -0
- throttlekit_py-0.1.0/contract/golden-vectors.json +923 -0
- throttlekit_py-0.1.0/contract/manifest.sha256 +2 -0
- throttlekit_py-0.1.0/contract/throttlekit.proto +111 -0
- throttlekit_py-0.1.0/pyproject.toml +69 -0
- throttlekit_py-0.1.0/scripts/gen_proto.py +57 -0
- throttlekit_py-0.1.0/scripts/sync_contract.py +98 -0
- throttlekit_py-0.1.0/src/throttlekit/__init__.py +86 -0
- throttlekit_py-0.1.0/src/throttlekit/_contract.py +67 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/fixedWindow.check.lua +25 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/fixedWindow.read.lua +1 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/gcra.check.lua +27 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/gcra.read.lua +1 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/manifest.json +152 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/slidingWindow.check.lua +50 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/slidingWindow.read.lua +1 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/slidingWindowLog.check.lua +45 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/slidingWindowLog.read.lua +1 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/tokenBucket.check.lua +31 -0
- throttlekit_py-0.1.0/src/throttlekit/_scripts/tokenBucket.read.lua +1 -0
- throttlekit_py-0.1.0/src/throttlekit/_version.py +1 -0
- throttlekit_py-0.1.0/src/throttlekit/decision.py +38 -0
- throttlekit_py-0.1.0/src/throttlekit/errors.py +19 -0
- throttlekit_py-0.1.0/src/throttlekit/py.typed +0 -0
- throttlekit_py-0.1.0/src/throttlekit/redis_backend.py +105 -0
- throttlekit_py-0.1.0/src/throttlekit/service_backend.py +127 -0
- throttlekit_py-0.1.0/src/throttlekit/strategies.py +112 -0
- throttlekit_py-0.1.0/tests/_policies.yaml +6 -0
- throttlekit_py-0.1.0/tests/test_contract.py +100 -0
- throttlekit_py-0.1.0/tests/test_redis_backend.py +126 -0
- throttlekit_py-0.1.0/tests/test_service_backend.py +113 -0
- throttlekit_py-0.1.0/tests/test_strategies.py +69 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
lint:
|
|
13
|
+
name: Lint & types
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- run: pip install -e .[dev]
|
|
21
|
+
- run: python scripts/gen_proto.py # ServiceBackend imports the generated stubs
|
|
22
|
+
- run: ruff check .
|
|
23
|
+
- run: ruff format --check .
|
|
24
|
+
- run: mypy
|
|
25
|
+
|
|
26
|
+
test:
|
|
27
|
+
name: Test (py ${{ matrix.python }})
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
strategy:
|
|
30
|
+
fail-fast: false
|
|
31
|
+
matrix:
|
|
32
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
33
|
+
services:
|
|
34
|
+
redis:
|
|
35
|
+
image: redis:7-alpine
|
|
36
|
+
ports:
|
|
37
|
+
- 6379:6379
|
|
38
|
+
options: >-
|
|
39
|
+
--health-cmd "redis-cli ping"
|
|
40
|
+
--health-interval 10s
|
|
41
|
+
--health-timeout 5s
|
|
42
|
+
--health-retries 5
|
|
43
|
+
env:
|
|
44
|
+
# The full cross-language conformance (tests/test_redis_backend.py) replays every golden vector
|
|
45
|
+
# through the vendored Lua against this Redis. The service-door integration test skips (no Node
|
|
46
|
+
# server is built in this repo's CI) — the rigorous time-parametrized proof is the Redis one.
|
|
47
|
+
THROTTLEKIT_REDIS_URL: redis://localhost:6379
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v5
|
|
50
|
+
- uses: actions/setup-python@v5
|
|
51
|
+
with:
|
|
52
|
+
python-version: ${{ matrix.python }}
|
|
53
|
+
- run: pip install -e .[dev]
|
|
54
|
+
- run: python scripts/gen_proto.py
|
|
55
|
+
- run: pytest -v
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishes throttlekit-py to PyPI when a `v*` tag is pushed, e.g.
|
|
4
|
+
# git tag v0.1.0 && git push origin v0.1.0
|
|
5
|
+
#
|
|
6
|
+
# Auth: a PyPI API token stored as the repo secret PYPI_API_TOKEN. Add it once with
|
|
7
|
+
# gh secret set PYPI_API_TOKEN -R AmeyaBorkar/throttlekit-py
|
|
8
|
+
# (paste the token — starting `pypi-` — at the prompt; it lands only in GitHub's encrypted secrets).
|
|
9
|
+
# For the FIRST publish of a not-yet-existing project the token must be ACCOUNT-scoped (a
|
|
10
|
+
# project-scoped token can't exist until the project does); swap to a project-scoped token afterward.
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
tags:
|
|
15
|
+
- "v*"
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
build:
|
|
22
|
+
name: Build dists
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v5
|
|
26
|
+
- uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
- run: pip install build twine
|
|
30
|
+
- run: python -m build
|
|
31
|
+
- run: twine check dist/* # validates metadata + README rendering before any upload
|
|
32
|
+
- uses: actions/upload-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
|
|
37
|
+
pypi:
|
|
38
|
+
name: Publish to PyPI
|
|
39
|
+
needs: build
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/download-artifact@v4
|
|
43
|
+
with:
|
|
44
|
+
name: dist
|
|
45
|
+
path: dist/
|
|
46
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
47
|
+
with:
|
|
48
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
|
|
13
|
+
# Virtual envs
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Generated gRPC stubs (reproduce with `python scripts/gen_proto.py` from the vendored proto)
|
|
19
|
+
src/throttlekit/_generated/
|
|
@@ -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.
|
|
@@ -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,101 @@
|
|
|
1
|
+
# throttlekit (Python)
|
|
2
|
+
|
|
3
|
+
Python client for [**ThrottleKit**](https://www.npmjs.com/package/throttlekit) — distributed rate
|
|
4
|
+
limiting against the **one** Node core, reached through either of two pluggable backends and proven
|
|
5
|
+
against the **same** golden vectors:
|
|
6
|
+
|
|
7
|
+
| Backend | Path | Decision computed in | Use it when |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| `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 |
|
|
10
|
+
| `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 |
|
|
11
|
+
|
|
12
|
+
> **Status: experimental (alpha).** The contract (`throttlekit.proto`, the golden vectors, and the
|
|
13
|
+
> extracted Lua) is vendored and checksum-pinned from the frozen `throttlekit` 1.0 core; this client
|
|
14
|
+
> tracks it. The raw Lua wire is **not** a frozen contract yet (it ships `frozen: false`), so the
|
|
15
|
+
> `RedisBackend` is explicitly experimental and may change with the core's scripts.
|
|
16
|
+
|
|
17
|
+
## The one invariant
|
|
18
|
+
|
|
19
|
+
The whole ThrottleKit design rests on it: **exactly one thing computes a `Decision`** — the Node core,
|
|
20
|
+
directly or as Lua-in-Redis. Neither backend re-implements an algorithm, so there is no second rate
|
|
21
|
+
limiter to keep in sync and no float-determinism risk. The `RedisBackend` marshals ARGV, runs the
|
|
22
|
+
core's vendored script, and decodes the reply; the decision is produced **server-side, in Lua**.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Installed as **`throttlekit-py`**, imported as **`throttlekit`** (PyPI's `throttlekit` is an unrelated
|
|
27
|
+
project):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install throttlekit-py # (alpha; not yet published) — the gRPC ServiceBackend
|
|
31
|
+
pip install "throttlekit-py[redis]" # + a redis client for the direct RedisBackend
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Use — the service door
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from throttlekit import ServiceBackend
|
|
38
|
+
|
|
39
|
+
with ServiceBackend("localhost:50051") as rl:
|
|
40
|
+
d = rl.check("api", api_key)
|
|
41
|
+
if not d.allowed:
|
|
42
|
+
... # 429 — retry after d.retry_after_ms
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`check` / `check_many` / `peek` / `forecast` return frozen `Decision` / `Forecast` dataclasses. A
|
|
46
|
+
*denial* is a normal `Decision` (`allowed is False`), never an exception; gRPC faults map to
|
|
47
|
+
`PolicyNotFoundError` / `OperationNotSupportedError` / `ServiceUnavailableError`.
|
|
48
|
+
|
|
49
|
+
## Use — the direct Redis door
|
|
50
|
+
|
|
51
|
+
Configure a strategy and point it at the Redis your fleet shares. `check` is the whole surface (it is
|
|
52
|
+
the contract-vectored, Lua-computed decision); `peek` / `forecast` deliberately stay on the service
|
|
53
|
+
door, where the core — not a re-derived client port — computes them.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import redis
|
|
57
|
+
from throttlekit import RedisBackend, Gcra
|
|
58
|
+
|
|
59
|
+
client = redis.Redis.from_url("redis://localhost:6379")
|
|
60
|
+
api = RedisBackend(client, Gcra(limit=100, period_ms=60_000, burst=20), prefix="prod")
|
|
61
|
+
|
|
62
|
+
d = api.check(api_key) # now defaults to the Redis server clock (skew-free across a fleet)
|
|
63
|
+
if not d.allowed:
|
|
64
|
+
... # 429 — retry after d.retry_after_ms
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Strategies: `Gcra`, `TokenBucket`, `FixedWindow`, `SlidingWindow`, `SlidingWindowLog`. The `prefix`
|
|
68
|
+
joins as `f"{prefix}:{key}"` — the **same** key scheme the core uses, so a Python and a Node client on
|
|
69
|
+
one limit address the same Redis key. The backend is client-agnostic: pass any object with `evalsha` /
|
|
70
|
+
`eval` (`redis-py` satisfies it structurally), exactly as the Node `RedisStore` does.
|
|
71
|
+
|
|
72
|
+
## How this stays in lock-step with the core
|
|
73
|
+
|
|
74
|
+
`scripts/sync_contract.py` vendors, with checksums, from the core repo:
|
|
75
|
+
|
|
76
|
+
* `contract/` — the dev/test artifacts: `throttlekit.proto` (→ gRPC stubs) and `golden-vectors.json`.
|
|
77
|
+
* `src/throttlekit/_scripts/` — the **runtime** Lua the `RedisBackend` executes (shipped in the wheel),
|
|
78
|
+
with the core's `manifest.json` (which carries each script's sha256).
|
|
79
|
+
|
|
80
|
+
`tests/test_contract.py` is the **drift-gate** (the vendored bytes must match their checksums and the
|
|
81
|
+
pinned `contractVersion`). But the real proof is behavioral:
|
|
82
|
+
|
|
83
|
+
* **`tests/test_redis_backend.py`** replays **every** rate-limit golden vector — the full,
|
|
84
|
+
time-parametrized timeline — through the Python client → vendored Lua → **real Redis**, and asserts
|
|
85
|
+
every reply field equals the Node oracle bit-for-bit. (Because the direct path puts an explicit `now`
|
|
86
|
+
in ARGV, it can do the rigorous time-parametrized replay the cross-process service door can't.)
|
|
87
|
+
* `tests/test_service_backend.py` starts a real `throttlekit-server` and asserts the clock-independent
|
|
88
|
+
behavior over gRPC.
|
|
89
|
+
|
|
90
|
+
## Develop
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install -e .[dev]
|
|
94
|
+
python scripts/sync_contract.py # vendor proto + vectors + Lua from ../GreenfeildProject (the core)
|
|
95
|
+
python scripts/gen_proto.py # generate the gRPC stubs from the vendored proto
|
|
96
|
+
pytest # unit + contract; the Redis/service tests skip if their backend is absent
|
|
97
|
+
ruff check . && mypy # lint + types
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The `RedisBackend` conformance needs a reachable Redis: it uses `THROTTLEKIT_REDIS_URL` or the project
|
|
101
|
+
default `redis://localhost:6380`, and skips cleanly when neither is up.
|