Skip to main content

redlimit

Rate limiting for Redis that survives concurrency. Atomic spends across several keys at once, and refunds — so "only failed attempts cost anything" can be written without leaving a hole in it.

CI Python License

pip install redlimit

Not on PyPI yet — until then, from the tag:

pip install "redlimit @ git+https://github.com/Nappuccino-tlg/redlimit@v0.1.0"

The limiter everybody writes

current = await redis.get(key)  # read
if current >= limit:  # decide
    return False
await redis.incr(key)  # write, too late
return True

Sent one at a time it is correct, and every sequential test of it passes. Sent together, every attempt reads the same number before any of them writes, and every attempt gets through. Measured against a real Redis with a warm connection pool, that code let 50 of 50 simultaneous attempts past a limit of 5.

The gap between the read and the write is the whole problem, and no amount of care in Python closes it — the fix has to happen inside Redis, which runs a script to completion with nothing interleaved. That is all this library is.

from redis.asyncio import Redis
from redlimit import SlidingWindow

limiter = SlidingWindow(Redis.from_url("redis://localhost"), limit=10, window=900)

decision = await limiter.consume(f"login:{email}")
if not decision:
    raise TooManyRequests(retry_after=decision.retry_after)

Only failures cost anything

Throttling sign-ins means counting failures, not attempts — otherwise anyone can lock an account out by failing at it on purpose. The obvious way to write that is to check the budget, verify the password, and charge only when it was wrong, which puts a password check in the gap and makes the race easier to win, not harder.

Spend first, hand it back if the attempt turns out to have been legitimate:

from redlimit import FixedWindow, RateLimited

limiter = FixedWindow(redis, limit=10, window=900)


async def sign_in(email: str, password: str, ip: str) -> User:
    async with limiter.attempt([f"ip:{ip}", f"email:{email}"]) as attempt:
        user = await users.find(email)
        if user is None or not verify(password, user.hash):
            raise Unauthorized  # the attempt keeps its cost
        attempt.refund()  # it was the owner, so it was free
        return user

Signing in correctly forty times in a row costs nothing. Guessing gets ten tries, however the guesses are arranged.

With FastAPI

pip install "redlimit[fastapi]"
from fastapi import Depends
from redlimit import SlidingWindow
from redlimit.fastapi import limit

links = SlidingWindow(redis, limit=30, window=3600)


@app.post("/links", dependencies=[Depends(limit(links))])
async def create_link(): ...

Keyed on the path and the peer address by default, so two endpoints sharing a limiter keep separate budgets. Pass your own key function for anything else. A refusal becomes a 429 carrying Retry-After, because a client told to slow down and not told for how long retries immediately.

The dependency answers before the handler runs, which is the right shape for "this endpoint, this often" and the wrong one as soon as the decision depends on something the handler learns. Sign-in is that case: use attempt() inside the handler, where a refund is still possible.

Several keys, one answer

Per-IP alone lets an attacker spread guesses for one account across a botnet. Per-account alone lets one host walk a password list through every account. Both together need one decision, not two that can disagree halfway through — so a spend either happens on every key or on none of them:

await limiter.consume([f"ip:{ip}", f"email:{email}"])

If any key is out of budget the whole attempt is refused and rolled back, so a caller cannot drain someone else's quota by naming them in an attempt that was always going to fail. It also means a refusal costs nothing: "ten per window" means ten spends, not ten plus however many times you were told no.

The two limiters

FixedWindow SlidingWindow
Redis keys per limiter key 1 2
Boundary burst up to 2× the limit no
Exact yes, within the window approximate
Median latency 1.10 ms 1.14 ms
p99 latency 2.41 ms 2.14 ms
Bytes per caller 72 80

Measured by benchmarks/compare.py — 20,000 attempts over 2,000 callers against Redis 7.4 in Docker on a laptop, connection pool warmed first so the numbers belong to the limiter and not to TCP.

The latency column is the useful surprise: there is nothing in it. SlidingWindow does strictly more work — an extra read per key, arithmetic on top — and lands inside the noise of FixedWindow, because both are one round trip and the round trip is the cost. So the choice between them is not a performance question. Pick on semantics.

The memory column is measured inside a single window; SlidingWindow holds a second key once traffic spans two, so budget roughly double at steady state.

FixedWindow counts per clock-aligned window. A caller can spend the whole budget just before a boundary and the whole of the next just after, so twice the limit passes in a moment straddling the two. Cheapest, and fine when the limit is a guard rail.

SlidingWindow adds what is still inside the previous window, weighted by how far the current one has run. No boundary burst, at the cost of one extra read and an approximation: it assumes the previous window's traffic was evenly spread. The exact answer needs every timestamp in a sorted set per key, which grows with the traffic being limited — the busier a caller, the more it costs to say no to them. Two integers per key, whatever they do, is the better trade for deciding whether someone may try a password again.

Windows are aligned to the Redis clock, not the caller's. Application processes on slightly skewed clocks would otherwise disagree about which window they are in and let more than the limit through between them at every boundary.

When you do not need this

If you want "100 requests an hour per IP" and it does not matter that a burst occasionally gets 105 through, write the ten lines yourself. Nothing here will pay for itself.

It earns its place where going over has a real cost and attempts arrive together: sign-in, password reset, payment, anything one client can fire in parallel.

Requirements

Redis 6.0 or newer (SET ... KEEPTTL), Python 3.10 or newer, and redis>=5.0.

On Redis Cluster, every key in one consume() must live in the same slot — wrap the shared part in {braces}, e.g. f"{{{user_id}}}:ip:{ip}".

Tests

The suite runs against a real Redis, because every guarantee here is a guarantee about what Redis does with a script while other clients wait. A fake would only prove the fake is atomic.

docker run -d -p 6379:6379 redis:7-alpine
pytest
python benchmarks/compare.py     # the table above, on your own hardware

The concurrency tests warm the connection pool before they race. Without that they are theatre: fifty commands issued together on a cold pool queue behind fifty TCP handshakes, so the first attempt finishes its whole cycle before the last one has a socket — and the naive limiter above passes. It was written that way first, and it passed.

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

redlimit-0.1.0.tar.gz (19.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

redlimit-0.1.0-py3-none-any.whl (14.2 kB view details)

Uploaded Python 3

File details

Details for the file redlimit-0.1.0.tar.gz.

File metadata

  • Download URL: redlimit-0.1.0.tar.gz
  • Upload date:
  • Size: 19.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for redlimit-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f3cac042bbb2247157d583a93319509d72d12576edeee7be70db840482645e8a
MD5 348107c529a6952073b6451ba0ffc402
BLAKE2b-256 589f9a4617b643693da8927de9866acc946aff84b02bc37e872818e8a275a1a1

See more details on using hashes here.

Provenance

The following attestation bundles were made for redlimit-0.1.0.tar.gz:

Publisher: release.yml on Nappuccino-tlg/redlimit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file redlimit-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: redlimit-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 14.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for redlimit-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 202722797d2ec0060d744d1cbc1456d7e1b2b0cf10319586a00f23506819aad0
MD5 acc951d03741bef98280bf765e3ec3e1
BLAKE2b-256 5adaa9cfb1bbdde82b460379178906bb04c8536d8ec3df53c81a103f8ade4aeb

See more details on using hashes here.

Provenance

The following attestation bundles were made for redlimit-0.1.0-py3-none-any.whl:

Publisher: release.yml on Nappuccino-tlg/redlimit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page