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.
Files changed (38) hide show
  1. throttlekit_py-0.1.0/.gitattributes +4 -0
  2. throttlekit_py-0.1.0/.github/workflows/ci.yml +55 -0
  3. throttlekit_py-0.1.0/.github/workflows/release.yml +48 -0
  4. throttlekit_py-0.1.0/.gitignore +19 -0
  5. throttlekit_py-0.1.0/LICENSE +21 -0
  6. throttlekit_py-0.1.0/PKG-INFO +127 -0
  7. throttlekit_py-0.1.0/README.md +101 -0
  8. throttlekit_py-0.1.0/contract/golden-vectors.json +923 -0
  9. throttlekit_py-0.1.0/contract/manifest.sha256 +2 -0
  10. throttlekit_py-0.1.0/contract/throttlekit.proto +111 -0
  11. throttlekit_py-0.1.0/pyproject.toml +69 -0
  12. throttlekit_py-0.1.0/scripts/gen_proto.py +57 -0
  13. throttlekit_py-0.1.0/scripts/sync_contract.py +98 -0
  14. throttlekit_py-0.1.0/src/throttlekit/__init__.py +86 -0
  15. throttlekit_py-0.1.0/src/throttlekit/_contract.py +67 -0
  16. throttlekit_py-0.1.0/src/throttlekit/_scripts/fixedWindow.check.lua +25 -0
  17. throttlekit_py-0.1.0/src/throttlekit/_scripts/fixedWindow.read.lua +1 -0
  18. throttlekit_py-0.1.0/src/throttlekit/_scripts/gcra.check.lua +27 -0
  19. throttlekit_py-0.1.0/src/throttlekit/_scripts/gcra.read.lua +1 -0
  20. throttlekit_py-0.1.0/src/throttlekit/_scripts/manifest.json +152 -0
  21. throttlekit_py-0.1.0/src/throttlekit/_scripts/slidingWindow.check.lua +50 -0
  22. throttlekit_py-0.1.0/src/throttlekit/_scripts/slidingWindow.read.lua +1 -0
  23. throttlekit_py-0.1.0/src/throttlekit/_scripts/slidingWindowLog.check.lua +45 -0
  24. throttlekit_py-0.1.0/src/throttlekit/_scripts/slidingWindowLog.read.lua +1 -0
  25. throttlekit_py-0.1.0/src/throttlekit/_scripts/tokenBucket.check.lua +31 -0
  26. throttlekit_py-0.1.0/src/throttlekit/_scripts/tokenBucket.read.lua +1 -0
  27. throttlekit_py-0.1.0/src/throttlekit/_version.py +1 -0
  28. throttlekit_py-0.1.0/src/throttlekit/decision.py +38 -0
  29. throttlekit_py-0.1.0/src/throttlekit/errors.py +19 -0
  30. throttlekit_py-0.1.0/src/throttlekit/py.typed +0 -0
  31. throttlekit_py-0.1.0/src/throttlekit/redis_backend.py +105 -0
  32. throttlekit_py-0.1.0/src/throttlekit/service_backend.py +127 -0
  33. throttlekit_py-0.1.0/src/throttlekit/strategies.py +112 -0
  34. throttlekit_py-0.1.0/tests/_policies.yaml +6 -0
  35. throttlekit_py-0.1.0/tests/test_contract.py +100 -0
  36. throttlekit_py-0.1.0/tests/test_redis_backend.py +126 -0
  37. throttlekit_py-0.1.0/tests/test_service_backend.py +113 -0
  38. throttlekit_py-0.1.0/tests/test_strategies.py +69 -0
@@ -0,0 +1,4 @@
1
+ # Force LF everywhere. The vendored contract artifacts are sha256-pinned in contract/manifest.sha256
2
+ # (hashed as LF); a CRLF checkout would change their bytes and break the drift gate on a fresh clone.
3
+ * text=auto eol=lf
4
+ *.png binary
@@ -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.