Restraint
Composable rate limiting for Python. Restraints are small, single-purpose rules you combine into the pacing a given API needs — hold a rate, keep a gap, cap what is in flight, refuse once a budget is gone, and do as the server says when it pushes back.
Works as a decorator or a context manager, sync or async, and every restraint is safe to share across threads and coroutines.
from restraint import Limit, add, restrain
add("example", Limit(second=1, minute=5))
@restrain("example")
def hello():
print("Hello World")
Install
uv add restraint # or: pip install restraint
Requires Python 3.11+.
Restraints
| Restraint | Holds back when | Reach for it when |
|---|---|---|
Limit(second=…, minute=…, …) |
The allowance for a calendar period is spent | You want to drain each window as fast as the server allows |
TokenBucket(rate=…, burst=…) |
No token has been earned yet | You want a smooth sustained rate with a bounded burst |
SlidingWindow(limit=…, per=…) |
The trailing window is full | The server counts a rolling window too |
Spacing(seconds=…, jitter=…) |
The last call was too recent | Calls must not bunch up, and shouldn't look metronomic |
Jitter(seconds=…) |
Always, by a random amount | Desynchronising a fleet of workers |
Concurrency(limit) |
Too many calls are already running | The cap is on calls in flight, not calls started; waiters are served in arrival order |
Quota(day=…, month=…) |
A hard budget is spent — raises | Running out is an error, not a delay |
Backoff(base=…) |
Recent calls failed | You should stop hammering something that is refusing you |
Adaptive() |
The server says so | The API reports its own rate-limit budget |
Compose them with &
a & b builds a Composite, and chaining flattens rather than nesting.
One rule is rarely enough. Combine them, cheapest rejection first:
from restraint import Adaptive, Quota, Spacing, TokenBucket, restrain
polite = (
Quota(day=10_000) # refuse once the budget is gone
& TokenBucket(rate=5) # hold five a second
& Spacing(seconds=0.05, jitter=0.05) # never bunch, never metronomic
& Adaptive() # then do as the server says
)
with restrain("api", polite) as gate:
response = httpx.get(url)
gate.observe(response.status_code, response.headers)
Members gate left to right and release in reverse. Order matters: a Quota
ahead of a TokenBucket refuses before a token is spent, where the reverse
wastes it.
Usage
Decorator, context manager, async
import asyncio
from restraint import Limit, TokenBucket, add, restrain
add("api", TokenBucket(rate=5))
@restrain("api")
def fetch(url): ...
@restrain("api")
async def afetch(url): ...
with restrain("api"):
...
async def main():
async with restrain("api"):
...
Async gating awaits rather than blocking, so a coroutine waiting on quota leaves the event loop free.
Telling a restraint how the call went
Backoff and Adaptive react to the server, which means they need to be told
what came back — the library never sees your response object. Exceptions are
reported automatically; hand over status and headers yourself:
with restrain("api") as gate:
response = httpx.get(url)
gate.observe(response.status_code, response.headers)
Adaptive reads X-RateLimit-Remaining and X-RateLimit-Reset to spread the
budget it has left across the window it has left, and obeys Retry-After
outright. Without those headers it does nothing, so pair it with a configured
restraint that paces the opening calls.
A 429 or 503 with no usable headers still applies throttle_hold (default 1s),
since being refused is itself information.
Reset headers come in two flavours — seconds from now, or an absolute epoch
timestamp (GitHub, Reddit and X use the latter). Adaptive detects which per
value; force it with reset_style="delta" or "epoch" if your API is
ambiguous. Every wait it produces is bounded by maximum (default 300s).
Reusing a restraint by name
add registers a restraint once; restrain("name") looks it up. Registering a
different restraint under a name already in use raises
RestraintConflictError — pass replace=True if you meant it.
from restraint import Limit, add, restrain
add("shared", Limit(second=10))
@restrain("shared")
def one(): ...
@restrain("shared")
def two(): ... # shares one budget with `one`
Writing your own
Implement _reserve, and both the sync and async paths come for free:
from restraint import Reservation, Restraint
class EveryOtherCall(Restraint):
"""Admit half the calls, delay the rest by a second."""
def __init__(self):
super().__init__()
self._calls = 0
def _reserve(self) -> Reservation:
self._calls += 1
if self._calls % 2:
return Reservation()
return Reservation(1.0, granted=True)
_reserve runs under the restraint's lock, so it can read and write its own
state freely. Return Reservation() to admit immediately, Reservation(wait)
to admit after a reserved delay, or Reservation(wait, granted=False) to make
the caller wait and ask again.
Three optional hooks cover the rest:
| Hook | Called | For |
|---|---|---|
_admitted(token) |
once the reserved wait has elapsed | correcting bookkeeping with the moment the call really started, via Reservation(..., token=...) |
report(outcome) |
when the call finishes | reacting to what the server said |
release() |
when the call finishes | handing back anything held for its duration |
A restraint whose admission is queued rather than reserved per attempt can
override gate and agate instead — Concurrency and Composite both do.
Caveats
- Counters live in memory. A
Quota(day=…)refills when the process restarts, and separate processes each hold their own. Enforcing a limit across workers needs shared state, which this library does not yet have. LimitandQuotawindows follow the system's local wall clock, so a daylight-saving change shiftshourand coarser boundaries. Everything else runs on a monotonic clock.Concurrencyholds its slot until the gated call finishes, so it needs the decorator or awithblock. A baregate()requires a matchingrelease().- Rates are targets rather than hard bounds when callers are concurrent. Waiting
callers reserve slots up front, so one resuming late lets the next start
slightly early. Measured worst cases:
SlidingWindow(limit=50, per=1.0)held 51 in a window across 16 threads, andSpacing(seconds=0.05)produced a 45ms gap across 8. The error tracks scheduler latency against your interval, so leave headroom on sub-second limits.
Development
make install # sync the environment and install the git hooks
make check # ruff, mypy, actionlint — the same hooks CI runs
make test
Static analysis lives in .pre-commit-config.yaml, and CI runs
pre-commit run --all-files rather than invoking each tool separately, so the
hooks and the pipeline cannot disagree. make format applies fixes.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file restraint-0.1.0.tar.gz.
File metadata
- Download URL: restraint-0.1.0.tar.gz
- Upload date:
- Size: 23.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1e0c69d9dc1ccac58dd8567b2031b437f2c799a822dd24ceb1e2fecc6ce30475
|
|
| MD5 |
88640177422597a89a097776575a73d3
|
|
| BLAKE2b-256 |
574970db185584ebf247fc47f3771abef3d217610e0b4ca69b52e915d08b7d12
|
Provenance
The following attestation bundles were made for restraint-0.1.0.tar.gz:
Publisher:
release.yml on KyleJamesWalker/restraint-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
restraint-0.1.0.tar.gz -
Subject digest:
1e0c69d9dc1ccac58dd8567b2031b437f2c799a822dd24ceb1e2fecc6ce30475 - Sigstore transparency entry: 2582219701
- Sigstore integration time:
-
Permalink:
KyleJamesWalker/restraint-py@c3ed80a3c1cc2648f04544f94227fc6d27194530 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/KyleJamesWalker
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c3ed80a3c1cc2648f04544f94227fc6d27194530 -
Trigger Event:
push
-
Statement type:
File details
Details for the file restraint-0.1.0-py3-none-any.whl.
File metadata
- Download URL: restraint-0.1.0-py3-none-any.whl
- Upload date:
- Size: 32.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f5a03f4bc04abcdc20e8a48311e90b1bca3b4f23b51d51015323931d795c296
|
|
| MD5 |
e1e934cbe4dbc20525ea9d6ab3a705b4
|
|
| BLAKE2b-256 |
c8b8c2ff09889e8ffc570c7c3862c6f31c4523d58bae6266fb403324f1292cc1
|
Provenance
The following attestation bundles were made for restraint-0.1.0-py3-none-any.whl:
Publisher:
release.yml on KyleJamesWalker/restraint-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
restraint-0.1.0-py3-none-any.whl -
Subject digest:
4f5a03f4bc04abcdc20e8a48311e90b1bca3b4f23b51d51015323931d795c296 - Sigstore transparency entry: 2582219703
- Sigstore integration time:
-
Permalink:
KyleJamesWalker/restraint-py@c3ed80a3c1cc2648f04544f94227fc6d27194530 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/KyleJamesWalker
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c3ed80a3c1cc2648f04544f94227fc6d27194530 -
Trigger Event:
push
-
Statement type: