volter 0.1.1__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.
volter-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Md Talim
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.
volter-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: volter
3
+ Version: 0.1.1
4
+ Summary: Rate limiting library with token bucket and sliding window algorithms, in-memory and Redis-backed
5
+ Author: Md. Talim
6
+ Author-email: Md. Talim <talimmohammad116@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: fastapi>=0.139.0 ; extra == 'fastapi'
10
+ Requires-Dist: redis>=8.0.1 ; extra == 'redis'
11
+ Requires-Python: >=3.12
12
+ Provides-Extra: fastapi
13
+ Provides-Extra: redis
14
+ Description-Content-Type: text/markdown
15
+
16
+ # volter
17
+
18
+ A rate limiting library for Python, implementing **token bucket** and **sliding window log** algorithms, each with an in-memory backend and a Redis-backed backend for multi-process correctness. Ships with FastAPI middleware for drop-in use.
19
+
20
+ ```python
21
+ from volter import TokenBucketLimiter
22
+
23
+ limiter = TokenBucketLimiter(capacity=10, refill_rate=1) # 10 requests, refills 1/sec
24
+
25
+ if limiter.allow("user:123"):
26
+ process_request()
27
+ else:
28
+ reject_with_429()
29
+ ```
30
+
31
+ ## Why this exists
32
+
33
+ Most rate limiter examples online implement one algorithm, in memory, in a single file, and stop there. That's fine for a demo but doesn't reflect how rate limiting actually gets used in production: behind a load balancer, across multiple app instances, sharing state that has to stay correct under concurrent access. This project implements two different algorithms with genuinely different tradeoffs, and takes each one from "correct in a single process" to "correct across N processes sharing Redis" — including the concurrency bugs that show up at each step and how they were fixed.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install volter # core only — in-memory limiters, zero dependencies
39
+ pip install volter[redis] # + Redis-backed limiters
40
+ pip install volter[fastapi] # + FastAPI middleware
41
+ pip install volter[redis,fastapi] # everything
42
+ ```
43
+
44
+ The core package (`TokenBucketLimiter`, `SlidingWindowLimiter`) has **no third-party dependencies**. `redis` and `fastapi` are only imported inside their own modules (`redis_token_bucket.py`, `redis_sliding_window.py`, `fastapi_middleware.py`), so installing the bare package never forces you to pull in libraries you don't need. Importing one of those modules without installing its extra fails with a plain `ModuleNotFoundError` — that's expected, not a bug.
45
+
46
+ ## The two algorithms, and why both exist
47
+
48
+ | | Token Bucket | Sliding Window Log |
49
+ | ------------------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
50
+ | **Memory per key** | O(1) — two numbers (`tokens`, `last_refill`) | O(requests in window) — one entry per allowed request until it ages out |
51
+ | **Precision** | Approximate, allows short bursts up to `capacity`, then smooths to `refill_rate` | Exact count over the exact window, no averaging |
52
+ | **Best for** | APIs that tolerate bursty-then-steady traffic (most APIs) | Cases where an exact count matters more than memory cost |
53
+
54
+ Neither algorithm is strictly better — this is a genuine engineering tradeoff, not a "pick the fancier one" situation. Token bucket's O(1) memory comes at the cost of not tracking individual requests, which is exactly what gives it burst tolerance. Sliding window log's exact precision comes at the cost of storing a timestamp per request, which is exactly what gives it precision.
55
+
56
+ ## Design decisions
57
+
58
+ ### Per-key locking, not one global lock
59
+
60
+ Every in-memory limiter manages many independent buckets/logs — one per key (e.g. per user, per IP). Concurrency safety is implemented at **two levels**:
61
+
62
+ - An outer lock (`_buckets_lock` / `_logs_lock`) guards only the _creation_ of a new key's state, held very briefly.
63
+ - A per-key lock guards the actual read-compute-write logic for that one key.
64
+
65
+ This means a request for `user:123` never blocks a concurrent request for `user:456` — they hold different locks entirely. A single global lock would serialize _all_ traffic regardless of key, which defeats much of the point under real concurrent load.
66
+
67
+ **Double-checked locking** is used for bucket/log creation: the common path (key already exists) reads the dict without taking any lock at all; the outer lock is only paid once, the first time a new key appears.
68
+
69
+ ### `time.monotonic()`, not `time.time()`
70
+
71
+ Elapsed-time calculations (used in token bucket's refill and sliding window's cutoff) use `time.monotonic()` deliberately. `time.time()` is wall-clock time and can jump backward — NTP sync, manual clock changes, VM pause/resume — which would make `elapsed` negative and corrupt the refill/eviction math. `time.monotonic()` is guaranteed never to go backward within a process.
72
+
73
+ ### `current_sum` running total instead of summing on every call (sliding window)
74
+
75
+ A naive sliding window log recomputes its count by iterating every entry in the window on each call — O(n) per request. This implementation instead maintains a running total (`current_sum`) that's only adjusted as entries are evicted or added, making each call O(k) where k is just the number of _expired_ entries evicted that round — amortized O(1), not O(n).
76
+
77
+ ### Weighted requests (`tokens_requested`)
78
+
79
+ Both in-memory limiters accept an optional `tokens_requested` parameter (default `1`), so a single call can represent a variable-cost request — e.g. a search endpoint costing 1 unit vs. a bulk export costing 20, the same pattern used by GitHub's and Stripe's own rate limiters. Callers who don't need this never have to think about it; the default keeps the simple case simple.
80
+
81
+ **This is dropped in the Redis-backed MVP** — see below.
82
+
83
+ ## Redis-backed limiters: why Lua, and what it's doing
84
+
85
+ ### The problem
86
+
87
+ The in-memory implementation protects its read-compute-write sequence with a `threading.Lock`. That works because all callers share one process's memory. Across multiple app server processes, there's no shared memory to hold a lock in — and Redis itself doesn't give you multi-command atomicity for free: `HMGET` → compute → `HSET` is three separate round trips, and another process can read the same stale value in between, causing both to compute independently off the same starting point (the same check-then-act race the in-process lock was preventing, just relocated).
88
+
89
+ Redis is single-threaded: any _single_ command is atomic with respect to every other client, because the event loop never interleaves commands. A Lua script sent via `EVAL`/`EVALSHA` runs as **one command** from the event loop's perspective — nothing else executes while it runs. The script becomes the critical section: the same role `bucket.lock` played in-process, just relocated into Redis's own execution model instead of a Python-level mutex.
90
+
91
+ `MULTI`/`EXEC` was considered and rejected for this: it queues commands atomically but doesn't let you branch on a value read _inside_ the transaction without an additional `WATCH` + optimistic-retry dance. A Lua script can read, branch, and write in one indivisible step, which maps directly onto this algorithm's logic.
92
+
93
+ ### Token bucket (Redis)
94
+
95
+ Implemented as a Lua script operating on a Redis hash (`tokens`, `last_refill` fields):
96
+
97
+ 1. Read current `tokens` / `last_refill` from the hash (or treat as a fresh bucket if the key doesn't exist).
98
+ 2. Compute elapsed time using Redis's own `TIME` command — **not** a timestamp passed in from Python — so there's no clock-skew risk between different app servers with slightly different clocks.
99
+ 3. Refill, check against capacity, decrement if allowed, write back with `HSET`.
100
+ 4. `EXPIRE` the key with a configurable TTL.
101
+
102
+ `register_script()` (via `redis-py`) handles caching the script server-side and automatically falls back from `EVALSHA` to a full `EVAL` if Redis responds `NOSCRIPT` (e.g. after a restart or `SCRIPT FLUSH`) — no manual `SCRIPT LOAD` management needed.
103
+
104
+ ### Sliding window log (Redis)
105
+
106
+ Implemented using a Redis **sorted set** (ZSET) — score = timestamp, member = a unique ID per request:
107
+
108
+ 1. `ZREMRANGEBYSCORE key -inf cutoff` — evict every member scored at or below the cutoff. This is the direct Redis-native equivalent of the in-memory version's `while entries[0][0] <= cutoff: popleft()` loop, and it's cheap for the same reason: a sorted set is backed by a skip list, so eviction walks from the low end and stops as soon as it clears the cutoff, rather than scanning every member.
109
+ 2. `ZCARD key` — count remaining members. This plays the same role `current_sum` played in-process.
110
+ 3. If under capacity, `ZADD key now member_id` and `EXPIRE`.
111
+
112
+ **Why a unique member ID, not the timestamp as the member:** a sorted set's members are unique — adding the same member twice just updates its score rather than creating a second entry. If the timestamp itself were used as the member, two requests landing at the same microsecond (plausible under real load, since Redis's `TIME` has microsecond resolution) would collide, and the second `ZADD` would silently overwrite the first instead of adding a new entry — undercounting real traffic and letting more requests through than the configured capacity. A `uuid4` generated per request guarantees two simultaneous requests still occupy two distinct ZSET entries. The score does the "when" work; the member does the "this is one distinct, countable event" work — deliberately decoupled.
113
+
114
+ **Why the UUID is generated in Python, not inside the Lua script:** Redis requires scripts to be deterministic, since their effects (not the script itself) get replicated to replicas — calls to random number generators or non-`TIME` clock reads aren't available inside a script for this reason. Randomness has to be manufactured outside the script and passed in as an argument.
115
+
116
+ ### Advantage of the Redis-backed version: expiry is built in
117
+
118
+ The in-memory limiters have a known, documented limitation (see below): their internal dict of per-key state grows forever, since nothing ever removes an entry once a key has been seen. The Redis-backed versions don't have this problem — every write is paired with an `EXPIRE`, so idle keys clean themselves up naturally as part of Redis's own key expiry mechanism. No manual cleanup logic was needed to get this for free.
119
+
120
+ ### Dropped from the Redis MVP: weighted `tokens_requested`
121
+
122
+ The in-memory limiters support a variable request cost via `tokens_requested`. This is intentionally **not** carried over to the Redis-backed sliding window implementation in this MVP — supporting it would mean encoding a weight into each ZSET member and summing weights on read instead of a plain `ZCARD` count, adding real complexity for a feature not yet exercised elsewhere in the project. This is a deliberate scope cut, not an oversight; extending it is a natural next step (see below).
123
+
124
+ ### Verifying atomicity: multi-process tests, not multi-thread
125
+
126
+ The in-memory limiters' concurrency tests use `ThreadPoolExecutor` — sufficient there because threads share process memory, so it genuinely exercises the `threading.Lock`. That same approach would prove nothing for the Redis-backed versions, since the whole point is correctness **across separate processes** that share no memory at all. Their tests instead spin up real OS processes (`multiprocessing.Process`), each with its own Redis client, all hammering the same key concurrently, and assert that exactly `capacity` requests succeed — no over-admission, despite zero shared process state.
127
+
128
+ ## FastAPI middleware
129
+
130
+ ```python
131
+ from fastapi import FastAPI
132
+ from volter.token_bucket import TokenBucketLimiter
133
+ from volter.fastapi_middleware import RateLimitMiddleware
134
+
135
+ app = FastAPI()
136
+ limiter = TokenBucketLimiter(capacity=5, refill_rate=1)
137
+ app.add_middleware(RateLimitMiddleware, limiter=limiter)
138
+ ```
139
+
140
+ Rejected requests get a `429` with a `Retry-After` header. The middleware is written against a `Protocol` (anything with `.allow(key, tokens_requested) -> bool`), not a concrete class — it works unmodified with any of the four limiter implementations, which is a direct payoff of keeping all four classes' interfaces identical from the start. The rate-limit key defaults to client IP but accepts a custom `key_func`, e.g. to key by API key or authenticated user ID instead.
141
+
142
+ ## Known limitations / natural next steps
143
+
144
+ - **In-memory dict growth**: `_buckets` / `_logs` never evict entries for keys that stop being used — every key ever seen stays in memory for the process lifetime. A TTL-based eviction (e.g. a background sweep, or lazy eviction on access) would be the natural fix. The Redis-backed versions don't share this problem, since `EXPIRE` handles it natively.
145
+ - **Weighted requests on the Redis sliding window**: dropped from this MVP, as explained above — implementable by encoding weight into ZSET members and summing instead of counting.
146
+ - **`Retry-After` is currently a fixed placeholder**, not computed from actual time-until-next-allowed-request. Each algorithm could expose a `retry_after(key) -> float` method (token bucket: time until enough tokens accumulate; sliding window: time until the oldest entry expires) for the middleware to report precisely instead of a constant.
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ uv sync --all-extras # install everything, including redis/fastapi extras and dev deps
152
+ uv run pytest # run the full test suite
153
+ uv run pytest -v tests/test_redis_token_bucket.py # requires a local Redis (see below)
154
+ ```
155
+
156
+ Redis-backed tests need a running Redis instance:
157
+
158
+ ```bash
159
+ docker run -d --name volter-redis -p 6379:6379 redis:7-alpine
160
+ ```
volter-0.1.1/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # volter
2
+
3
+ A rate limiting library for Python, implementing **token bucket** and **sliding window log** algorithms, each with an in-memory backend and a Redis-backed backend for multi-process correctness. Ships with FastAPI middleware for drop-in use.
4
+
5
+ ```python
6
+ from volter import TokenBucketLimiter
7
+
8
+ limiter = TokenBucketLimiter(capacity=10, refill_rate=1) # 10 requests, refills 1/sec
9
+
10
+ if limiter.allow("user:123"):
11
+ process_request()
12
+ else:
13
+ reject_with_429()
14
+ ```
15
+
16
+ ## Why this exists
17
+
18
+ Most rate limiter examples online implement one algorithm, in memory, in a single file, and stop there. That's fine for a demo but doesn't reflect how rate limiting actually gets used in production: behind a load balancer, across multiple app instances, sharing state that has to stay correct under concurrent access. This project implements two different algorithms with genuinely different tradeoffs, and takes each one from "correct in a single process" to "correct across N processes sharing Redis" — including the concurrency bugs that show up at each step and how they were fixed.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install volter # core only — in-memory limiters, zero dependencies
24
+ pip install volter[redis] # + Redis-backed limiters
25
+ pip install volter[fastapi] # + FastAPI middleware
26
+ pip install volter[redis,fastapi] # everything
27
+ ```
28
+
29
+ The core package (`TokenBucketLimiter`, `SlidingWindowLimiter`) has **no third-party dependencies**. `redis` and `fastapi` are only imported inside their own modules (`redis_token_bucket.py`, `redis_sliding_window.py`, `fastapi_middleware.py`), so installing the bare package never forces you to pull in libraries you don't need. Importing one of those modules without installing its extra fails with a plain `ModuleNotFoundError` — that's expected, not a bug.
30
+
31
+ ## The two algorithms, and why both exist
32
+
33
+ | | Token Bucket | Sliding Window Log |
34
+ | ------------------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
35
+ | **Memory per key** | O(1) — two numbers (`tokens`, `last_refill`) | O(requests in window) — one entry per allowed request until it ages out |
36
+ | **Precision** | Approximate, allows short bursts up to `capacity`, then smooths to `refill_rate` | Exact count over the exact window, no averaging |
37
+ | **Best for** | APIs that tolerate bursty-then-steady traffic (most APIs) | Cases where an exact count matters more than memory cost |
38
+
39
+ Neither algorithm is strictly better — this is a genuine engineering tradeoff, not a "pick the fancier one" situation. Token bucket's O(1) memory comes at the cost of not tracking individual requests, which is exactly what gives it burst tolerance. Sliding window log's exact precision comes at the cost of storing a timestamp per request, which is exactly what gives it precision.
40
+
41
+ ## Design decisions
42
+
43
+ ### Per-key locking, not one global lock
44
+
45
+ Every in-memory limiter manages many independent buckets/logs — one per key (e.g. per user, per IP). Concurrency safety is implemented at **two levels**:
46
+
47
+ - An outer lock (`_buckets_lock` / `_logs_lock`) guards only the _creation_ of a new key's state, held very briefly.
48
+ - A per-key lock guards the actual read-compute-write logic for that one key.
49
+
50
+ This means a request for `user:123` never blocks a concurrent request for `user:456` — they hold different locks entirely. A single global lock would serialize _all_ traffic regardless of key, which defeats much of the point under real concurrent load.
51
+
52
+ **Double-checked locking** is used for bucket/log creation: the common path (key already exists) reads the dict without taking any lock at all; the outer lock is only paid once, the first time a new key appears.
53
+
54
+ ### `time.monotonic()`, not `time.time()`
55
+
56
+ Elapsed-time calculations (used in token bucket's refill and sliding window's cutoff) use `time.monotonic()` deliberately. `time.time()` is wall-clock time and can jump backward — NTP sync, manual clock changes, VM pause/resume — which would make `elapsed` negative and corrupt the refill/eviction math. `time.monotonic()` is guaranteed never to go backward within a process.
57
+
58
+ ### `current_sum` running total instead of summing on every call (sliding window)
59
+
60
+ A naive sliding window log recomputes its count by iterating every entry in the window on each call — O(n) per request. This implementation instead maintains a running total (`current_sum`) that's only adjusted as entries are evicted or added, making each call O(k) where k is just the number of _expired_ entries evicted that round — amortized O(1), not O(n).
61
+
62
+ ### Weighted requests (`tokens_requested`)
63
+
64
+ Both in-memory limiters accept an optional `tokens_requested` parameter (default `1`), so a single call can represent a variable-cost request — e.g. a search endpoint costing 1 unit vs. a bulk export costing 20, the same pattern used by GitHub's and Stripe's own rate limiters. Callers who don't need this never have to think about it; the default keeps the simple case simple.
65
+
66
+ **This is dropped in the Redis-backed MVP** — see below.
67
+
68
+ ## Redis-backed limiters: why Lua, and what it's doing
69
+
70
+ ### The problem
71
+
72
+ The in-memory implementation protects its read-compute-write sequence with a `threading.Lock`. That works because all callers share one process's memory. Across multiple app server processes, there's no shared memory to hold a lock in — and Redis itself doesn't give you multi-command atomicity for free: `HMGET` → compute → `HSET` is three separate round trips, and another process can read the same stale value in between, causing both to compute independently off the same starting point (the same check-then-act race the in-process lock was preventing, just relocated).
73
+
74
+ Redis is single-threaded: any _single_ command is atomic with respect to every other client, because the event loop never interleaves commands. A Lua script sent via `EVAL`/`EVALSHA` runs as **one command** from the event loop's perspective — nothing else executes while it runs. The script becomes the critical section: the same role `bucket.lock` played in-process, just relocated into Redis's own execution model instead of a Python-level mutex.
75
+
76
+ `MULTI`/`EXEC` was considered and rejected for this: it queues commands atomically but doesn't let you branch on a value read _inside_ the transaction without an additional `WATCH` + optimistic-retry dance. A Lua script can read, branch, and write in one indivisible step, which maps directly onto this algorithm's logic.
77
+
78
+ ### Token bucket (Redis)
79
+
80
+ Implemented as a Lua script operating on a Redis hash (`tokens`, `last_refill` fields):
81
+
82
+ 1. Read current `tokens` / `last_refill` from the hash (or treat as a fresh bucket if the key doesn't exist).
83
+ 2. Compute elapsed time using Redis's own `TIME` command — **not** a timestamp passed in from Python — so there's no clock-skew risk between different app servers with slightly different clocks.
84
+ 3. Refill, check against capacity, decrement if allowed, write back with `HSET`.
85
+ 4. `EXPIRE` the key with a configurable TTL.
86
+
87
+ `register_script()` (via `redis-py`) handles caching the script server-side and automatically falls back from `EVALSHA` to a full `EVAL` if Redis responds `NOSCRIPT` (e.g. after a restart or `SCRIPT FLUSH`) — no manual `SCRIPT LOAD` management needed.
88
+
89
+ ### Sliding window log (Redis)
90
+
91
+ Implemented using a Redis **sorted set** (ZSET) — score = timestamp, member = a unique ID per request:
92
+
93
+ 1. `ZREMRANGEBYSCORE key -inf cutoff` — evict every member scored at or below the cutoff. This is the direct Redis-native equivalent of the in-memory version's `while entries[0][0] <= cutoff: popleft()` loop, and it's cheap for the same reason: a sorted set is backed by a skip list, so eviction walks from the low end and stops as soon as it clears the cutoff, rather than scanning every member.
94
+ 2. `ZCARD key` — count remaining members. This plays the same role `current_sum` played in-process.
95
+ 3. If under capacity, `ZADD key now member_id` and `EXPIRE`.
96
+
97
+ **Why a unique member ID, not the timestamp as the member:** a sorted set's members are unique — adding the same member twice just updates its score rather than creating a second entry. If the timestamp itself were used as the member, two requests landing at the same microsecond (plausible under real load, since Redis's `TIME` has microsecond resolution) would collide, and the second `ZADD` would silently overwrite the first instead of adding a new entry — undercounting real traffic and letting more requests through than the configured capacity. A `uuid4` generated per request guarantees two simultaneous requests still occupy two distinct ZSET entries. The score does the "when" work; the member does the "this is one distinct, countable event" work — deliberately decoupled.
98
+
99
+ **Why the UUID is generated in Python, not inside the Lua script:** Redis requires scripts to be deterministic, since their effects (not the script itself) get replicated to replicas — calls to random number generators or non-`TIME` clock reads aren't available inside a script for this reason. Randomness has to be manufactured outside the script and passed in as an argument.
100
+
101
+ ### Advantage of the Redis-backed version: expiry is built in
102
+
103
+ The in-memory limiters have a known, documented limitation (see below): their internal dict of per-key state grows forever, since nothing ever removes an entry once a key has been seen. The Redis-backed versions don't have this problem — every write is paired with an `EXPIRE`, so idle keys clean themselves up naturally as part of Redis's own key expiry mechanism. No manual cleanup logic was needed to get this for free.
104
+
105
+ ### Dropped from the Redis MVP: weighted `tokens_requested`
106
+
107
+ The in-memory limiters support a variable request cost via `tokens_requested`. This is intentionally **not** carried over to the Redis-backed sliding window implementation in this MVP — supporting it would mean encoding a weight into each ZSET member and summing weights on read instead of a plain `ZCARD` count, adding real complexity for a feature not yet exercised elsewhere in the project. This is a deliberate scope cut, not an oversight; extending it is a natural next step (see below).
108
+
109
+ ### Verifying atomicity: multi-process tests, not multi-thread
110
+
111
+ The in-memory limiters' concurrency tests use `ThreadPoolExecutor` — sufficient there because threads share process memory, so it genuinely exercises the `threading.Lock`. That same approach would prove nothing for the Redis-backed versions, since the whole point is correctness **across separate processes** that share no memory at all. Their tests instead spin up real OS processes (`multiprocessing.Process`), each with its own Redis client, all hammering the same key concurrently, and assert that exactly `capacity` requests succeed — no over-admission, despite zero shared process state.
112
+
113
+ ## FastAPI middleware
114
+
115
+ ```python
116
+ from fastapi import FastAPI
117
+ from volter.token_bucket import TokenBucketLimiter
118
+ from volter.fastapi_middleware import RateLimitMiddleware
119
+
120
+ app = FastAPI()
121
+ limiter = TokenBucketLimiter(capacity=5, refill_rate=1)
122
+ app.add_middleware(RateLimitMiddleware, limiter=limiter)
123
+ ```
124
+
125
+ Rejected requests get a `429` with a `Retry-After` header. The middleware is written against a `Protocol` (anything with `.allow(key, tokens_requested) -> bool`), not a concrete class — it works unmodified with any of the four limiter implementations, which is a direct payoff of keeping all four classes' interfaces identical from the start. The rate-limit key defaults to client IP but accepts a custom `key_func`, e.g. to key by API key or authenticated user ID instead.
126
+
127
+ ## Known limitations / natural next steps
128
+
129
+ - **In-memory dict growth**: `_buckets` / `_logs` never evict entries for keys that stop being used — every key ever seen stays in memory for the process lifetime. A TTL-based eviction (e.g. a background sweep, or lazy eviction on access) would be the natural fix. The Redis-backed versions don't share this problem, since `EXPIRE` handles it natively.
130
+ - **Weighted requests on the Redis sliding window**: dropped from this MVP, as explained above — implementable by encoding weight into ZSET members and summing instead of counting.
131
+ - **`Retry-After` is currently a fixed placeholder**, not computed from actual time-until-next-allowed-request. Each algorithm could expose a `retry_after(key) -> float` method (token bucket: time until enough tokens accumulate; sliding window: time until the oldest entry expires) for the middleware to report precisely instead of a constant.
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ uv sync --all-extras # install everything, including redis/fastapi extras and dev deps
137
+ uv run pytest # run the full test suite
138
+ uv run pytest -v tests/test_redis_token_bucket.py # requires a local Redis (see below)
139
+ ```
140
+
141
+ Redis-backed tests need a running Redis instance:
142
+
143
+ ```bash
144
+ docker run -d --name volter-redis -p 6379:6379 redis:7-alpine
145
+ ```
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "volter"
3
+ version = "0.1.1"
4
+ description = "Rate limiting library with token bucket and sliding window algorithms, in-memory and Redis-backed"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE*"]
8
+ authors = [
9
+ { name = "Md. Talim", email = "talimmohammad116@gmail.com" }
10
+ ]
11
+ requires-python = ">=3.12"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ redis = ["redis>=8.0.1"]
16
+ fastapi = ["fastapi>=0.139.0"]
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.11.26,<0.12.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "httpx2>=2.5.0",
25
+ "pytest>=9.1.1",
26
+ "uvicorn>=0.50.2",
27
+ ]
@@ -0,0 +1,4 @@
1
+ from volter.sliding_window import SlidingWindowLimiter
2
+ from volter.token_bucket import TokenBucketLimiter
3
+
4
+ __all__ = ["SlidingWindowLimiter", "TokenBucketLimiter"]
@@ -0,0 +1,45 @@
1
+ from typing import Callable, Protocol, final, override
2
+
3
+ from fastapi import Request, Response
4
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
5
+ from starlette.types import ASGIApp
6
+
7
+
8
+ class _Limiter(Protocol):
9
+ def allow(self, key: str, tokens_requested: float = 1.0) -> bool: ...
10
+
11
+
12
+ def _default_key_func(request: Request) -> str:
13
+ client = request.client
14
+ return client.host if client else "unknown"
15
+
16
+
17
+ @final
18
+ class RateLimitMiddleware(BaseHTTPMiddleware):
19
+ def __init__(
20
+ self,
21
+ app: ASGIApp,
22
+ limiter: _Limiter,
23
+ key_func: Callable[[Request], str] = _default_key_func,
24
+ tokens_requested: float = 1.0,
25
+ ):
26
+ super().__init__(app)
27
+ self.limiter = limiter
28
+ self.key_func = key_func
29
+ self.tokens_requested = tokens_requested
30
+
31
+ @override
32
+ async def dispatch(
33
+ self, request: Request, call_next: RequestResponseEndpoint
34
+ ) -> Response:
35
+ key = self.key_func(request)
36
+
37
+ if not self.limiter.allow(key, self.tokens_requested):
38
+ return Response(
39
+ content='{"detail": "Rate limit exceeded"}',
40
+ status_code=429,
41
+ media_type="application/json",
42
+ headers={"Retry-After": "1"},
43
+ )
44
+
45
+ return await call_next(request)
File without changes
@@ -0,0 +1,60 @@
1
+ import uuid
2
+ from typing import final
3
+
4
+ import redis
5
+
6
+ _SLIDING_WINDOW_SCRIPT = """
7
+ -- KEYS[1] = zset key
8
+ -- ARGV[1] = capacity
9
+ -- ARGV[2] = window_size (seconds)
10
+ -- ARGV[3] = ttl seconds
11
+ -- ARGV[4] = unique member id
12
+
13
+ local capacity = tonumber(ARGV[1])
14
+ local window = tonumber(ARGV[2])
15
+ local ttl = tonumber(ARGV[3])
16
+ local member_id = ARGV[4]
17
+
18
+ local time_result = redis.call('TIME')
19
+ local now = tonumber(time_result[1]) + tonumber(time_result[2]) / 1000000
20
+ local cutoff = now - window
21
+
22
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', cutoff)
23
+
24
+ local count = redis.call('ZCARD', KEYS[1])
25
+
26
+ local allowed = 0
27
+ if count < capacity then
28
+ redis.call('ZADD', KEYS[1], now, member_id)
29
+ redis.call('EXPIRE', KEYS[1], ttl)
30
+ allowed = 1
31
+ end
32
+
33
+ return allowed
34
+ """
35
+
36
+
37
+ @final
38
+ class RedisSlidingWindowLimiter:
39
+ def __init__(
40
+ self,
41
+ redis_client: redis.Redis,
42
+ capacity: int,
43
+ window_size: float,
44
+ ttl: int | None = None,
45
+ key_prefix: str = "volter:sw",
46
+ ):
47
+ self.capacity = capacity
48
+ self.window_size = window_size
49
+ self.ttl = ttl if ttl is not None else int(window_size) + 1
50
+ self.key_prefix = key_prefix
51
+ self._redis = redis_client
52
+ self._script = redis_client.register_script(_SLIDING_WINDOW_SCRIPT)
53
+
54
+ def allow(self, key: str) -> bool:
55
+ full_key = f"{self.key_prefix}:{key}"
56
+ member_id = uuid.uuid4().hex
57
+ result = self._script(
58
+ keys=[full_key], args=[self.capacity, self.window_size, self.ttl, member_id]
59
+ )
60
+ return bool(result)
@@ -0,0 +1,69 @@
1
+ from typing import final
2
+
3
+ import redis
4
+
5
+ _TOKEN_BUCKET_SCRIPT = """
6
+ -- KEYS[1] = bucket key
7
+ -- ARGV[1] = capacity
8
+ -- ARGV[2] = refill rate (tokens per second)
9
+ -- ARGV[3] = tokens requested
10
+ -- ARGV[4] = ttl seconds (so idle keys clean themselves up)
11
+
12
+ local capacity = tonumber(ARGV[1])
13
+ local refill_rate = tonumber(ARGV[2])
14
+ local requested = tonumber(ARGV[3])
15
+ local ttl = tonumber(ARGV[4])
16
+
17
+ local time_result = redis.call('TIME')
18
+ local now = tonumber(time_result[1]) + tonumber(time_result[2]) / 1000000
19
+
20
+ local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
21
+ local tokens = tonumber(bucket[1])
22
+ local last_refill = tonumber(bucket[2])
23
+
24
+ if tokens == nil then
25
+ tokens = capacity
26
+ last_refill = now
27
+ end
28
+
29
+ local elapsed = now - last_refill
30
+ if elapsed < 0 then elapsed = 0 end
31
+ tokens = math.min(capacity, tokens + elapsed * refill_rate)
32
+
33
+ local allowed = 0
34
+ if tokens >= requested then
35
+ tokens = tokens - requested
36
+ allowed = 1
37
+ end
38
+
39
+ redis.call('HSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
40
+ redis.call('EXPIRE', KEYS[1], ttl)
41
+
42
+ return allowed
43
+ """
44
+
45
+
46
+ @final
47
+ class RedisTokenBucketLimiter:
48
+ def __init__(
49
+ self,
50
+ redis_client: redis.Redis,
51
+ capacity: int,
52
+ refill_rate: float,
53
+ ttl: int = 3600,
54
+ key_prefix: str = "volter:tb",
55
+ ):
56
+ self.capacity = capacity
57
+ self.refill_rate = refill_rate
58
+ self.ttl = ttl
59
+ self.key_prefix = key_prefix
60
+ self._redis = redis_client
61
+ self._script = redis_client.register_script(_TOKEN_BUCKET_SCRIPT)
62
+
63
+ def allow(self, key: str, tokens_requested: float = 1.0) -> bool:
64
+ full_key = f"{self.key_prefix}:{key}"
65
+ result = self._script(
66
+ keys=[full_key],
67
+ args=[self.capacity, self.refill_rate, tokens_requested, self.ttl],
68
+ )
69
+ return bool(result)
@@ -0,0 +1,53 @@
1
+ import threading
2
+ import time
3
+ from collections import deque
4
+ from dataclasses import dataclass, field
5
+ from typing import final
6
+
7
+
8
+ @dataclass
9
+ class _Log:
10
+ # A queue storing (timestamp, weight) tuples of allowed requests
11
+ entries: deque[tuple[float, float]] = field(default_factory=deque)
12
+ # Total weight (sum of tokens/requests) currently in the queue
13
+ current_sum: float = 0.0
14
+ lock: threading.Lock = field(default_factory=threading.Lock)
15
+
16
+
17
+ @final
18
+ class SlidingWindowLimiter:
19
+ def __init__(self, capacity: int, window_size: float):
20
+ self.capacity = capacity
21
+ self.window_size = window_size
22
+ self._logs: dict[str, _Log] = {}
23
+ self._logs_lock = threading.Lock()
24
+
25
+ def _get_log(self, key: str) -> _Log:
26
+ log = self._logs.get(key)
27
+ if log is not None:
28
+ return log
29
+
30
+ with self._logs_lock:
31
+ log = self._logs.get(key)
32
+ if log is None:
33
+ log = _Log()
34
+ self._logs[key] = log
35
+ return log
36
+
37
+ def allow(self, key: str, tokens_requested: float = 1.0) -> bool:
38
+ log = self._get_log(key)
39
+
40
+ with log.lock:
41
+ now = time.monotonic()
42
+ cutoff = now - self.window_size
43
+
44
+ while log.entries and log.entries[0][0] <= cutoff:
45
+ _, weight = log.entries.popleft()
46
+ log.current_sum -= weight
47
+
48
+ if log.current_sum + tokens_requested <= self.capacity:
49
+ log.entries.append((now, tokens_requested))
50
+ log.current_sum += tokens_requested
51
+ return True
52
+
53
+ return False
@@ -0,0 +1,48 @@
1
+ import threading
2
+ import time
3
+ from dataclasses import dataclass, field
4
+ from typing import final
5
+
6
+
7
+ @dataclass
8
+ class _Bucket:
9
+ tokens: float
10
+ last_refill: float
11
+ lock: threading.Lock = field(default_factory=threading.Lock)
12
+
13
+
14
+ @final
15
+ class TokenBucketLimiter:
16
+ def __init__(self, capacity: int, refill_rate: float):
17
+ self.capacity = capacity
18
+ self.refill_rate = refill_rate
19
+ self._buckets: dict[str, _Bucket] = {}
20
+ self._buckets_lock = threading.Lock()
21
+
22
+ def _get_bucket(self, key: str) -> _Bucket:
23
+ bucket = self._buckets.get(key)
24
+ if bucket is not None:
25
+ return bucket
26
+
27
+ with self._buckets_lock:
28
+ bucket = self._buckets.get(key)
29
+ if bucket is None:
30
+ bucket = _Bucket(tokens=self.capacity, last_refill=time.monotonic())
31
+ self._buckets[key] = bucket
32
+ return bucket
33
+
34
+ def allow(self, key: str, tokens_requested: float = 1.0) -> bool:
35
+ bucket = self._get_bucket(key)
36
+
37
+ with bucket.lock:
38
+ now = time.monotonic()
39
+ elapsed = now - bucket.last_refill
40
+ bucket.tokens = min(
41
+ self.capacity, bucket.tokens + (elapsed * self.refill_rate)
42
+ )
43
+ bucket.last_refill = now
44
+
45
+ if bucket.tokens >= tokens_requested:
46
+ bucket.tokens -= tokens_requested
47
+ return True
48
+ return False