Dev.to WebDev 🛠 Dev 👁 0 📖 12 min read

A 429 Without Retry-After Is a Bug: How I Rate Limit Django APIs With a Redis Token Bucket

TL;DR: A rate limiter has two jobs: protect everyone else from one noisy client, and tell that client exactly how to behave. Most implementations only do the first. Below: why a token bucket beats fixed windows, an atomi

TL;DR: A rate limiter has two jobs: protect everyone else from one noisy client, and tell that client exactly how to behave. Most implementations only do the first. Below: why a token bucket beats fixed windows, an atomic Redis Lua script, Django middleware that keys by identity and charges expensive endpoints more, the headers clients actually need, a client that respects them, and a test that proves the limit holds.

Here's my slightly grumpy opinion: a 429 without Retry-After is a bug. It doesn't stop a misbehaving client. It teaches it to retry harder, at random, until something else falls over.

Most APIs get rate limiting the hard way. A partner's integration goes into a retry loop, a scraper finds the search endpoint, or one customer's nightly sync starts at the same minute as everyone else's. The database is the first thing to notice, and by then every other customer is noticing too.

Rate limiting is easy to bolt on and easy to get subtly wrong. Fixed windows let clients burst to twice the limit at a window boundary. Counters stored in each process stop working the moment you have two app servers behind a load balancer. And a bare 429 just moves the problem around.

Picking an algorithm

There are three common approaches:

Algorithm How it works Weak spot
Fixed window Count requests per calendar minute A client can send the full limit at 12:00:59 and again at 12:01:00
Sliding log Store a timestamp per request, count the last 60 s Accurate, but memory grows with traffic
Token bucket Bucket refills at a steady rate up to a cap; each request takes a token Slightly harder to explain in docs

The token bucket is usually the right default. It allows short bursts up to the bucket size, enforces a steady average rate, and needs only two numbers per client: the current token count and when it was last updated. It also maps neatly onto how people describe limits in contracts: "60 requests a minute, bursts of up to 60".

The bucket in Redis

The check and the update have to be atomic. Two app servers reading the same count and both allowing the request is exactly the race you're trying to prevent. A Lua script runs atomically inside Redis, so nothing else touches the key between the read and the write:

-- ratelimit.lua
-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill rate (tokens/sec), ARGV[3] = cost
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])

local t = redis.call("TIME")
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000

local state = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now

tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("EXPIRE", KEYS[1], math.ceil(capacity / rate) * 2)

return { allowed, tostring(tokens) }

Three details are easy to miss:

  1. Use Redis's clock, not the app server's. TIME means clock drift between servers can't hand out free tokens. Calling TIME before a write inside a script needs Redis 5 or later (script effects replication), which every managed Redis offers today.
  2. Return the token count as a string. Redis converts Lua numbers to integers in replies and would silently drop the fractional part, which makes your Retry-After maths wrong.
  3. Let idle buckets expire. A full bucket and a missing key mean the same thing, so the EXPIRE keeps memory proportional to active clients rather than every client you've ever seen.

Calling it from Django

# ratelimit.py
import logging
import math

import redis
from django.conf import settings
from django.http import JsonResponse

logger = logging.getLogger(__name__)

r = redis.Redis.from_url(settings.REDIS_URL, decode_responses=True)
token_bucket = r.register_script((settings.BASE_DIR / "ratelimit.lua").read_text())

DEFAULT_POLICY = {"capacity": 60, "rate": 1.0}  # burst 60, 60/min sustained
ENDPOINT_COST = {
    "/api/reports/export/": 10,
    "/api/search/": 3,
}


def client_key(request):
    if request.user.is_authenticated:
        return f"rl:user:{request.user.pk}"
    # Only trust a client IP header that your own proxy sets and overwrites.
    ip = request.META.get("HTTP_X_REAL_IP") or request.META["REMOTE_ADDR"]
    return f"rl:ip:{ip}"


def policy_for(request):
    account = getattr(request.user, "account", None)
    if account and account.rate_capacity:
        return {"capacity": account.rate_capacity, "rate": account.rate_refill}
    return DEFAULT_POLICY


class RateLimitMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if not request.path.startswith("/api/"):
            return self.get_response(request)

        policy = policy_for(request)
        cost = ENDPOINT_COST.get(request.path, 1)

        try:
            allowed, remaining = token_bucket(
                keys=[client_key(request)],
                args=[policy["capacity"], policy["rate"], cost],
            )
        except redis.RedisError:
            logger.warning("rate_limiter_unavailable", exc_info=True)
            return self.get_response(request)  # fail open, loudly

        remaining = float(remaining)
        if allowed:
            response = self.get_response(request)
        else:
            wait = math.ceil((cost - remaining) / policy["rate"])
            response = JsonResponse(
                {"error": "rate_limited", "retry_after_seconds": wait}, status=429
            )
            response["Retry-After"] = str(wait)

        response["RateLimit-Limit"] = str(policy["capacity"])
        response["RateLimit-Remaining"] = str(math.floor(remaining))
        return response

A few decisions are baked in here, and they're worth making deliberately rather than by accident.

Key by identity, not IP, wherever you can. Many users share an office or mobile carrier IP, and behind a load balancer REMOTE_ADDR is often just the proxy's address. If you key unauthenticated traffic by IP, read it from a header that only your proxy sets and that it overwrites on every request. Otherwise anyone can send X-Forwarded-For: 1.2.3.4 and get a fresh bucket per request.

Order matters. Put the middleware after AuthenticationMiddleware in MIDDLEWARE, so request.user is populated. If you authenticate with DRF tokens instead of sessions, the user isn't known at middleware time; in that case, do the same check in a DRF throttle class (more on that below) or resolve the token yourself.

Decide what happens when Redis is down. For most read-heavy APIs, failing open (allowing the request and logging loudly) beats an outage caused by the rate limiter itself. For login, password reset and OTP endpoints, fail closed: a brute-force window during a Redis incident is worse than a few failed logins.

Not every request costs the same

A search with a dozen filters and a GET for a single record shouldn't draw from the bucket equally. Because the script takes a cost argument, expensive endpoints can charge more: the export above costs 10 tokens, so a client with a 60-token bucket can run six in a burst and then has to wait. In real projects I'd resolve the cost from the URL pattern name (request.resolver_match) rather than raw paths, so /api/reports/42/export/ is covered too.

Two more patterns come up often:

  • Separate buckets per endpoint group. Use a key like rl:user:42:exports so a burst of exports doesn't block ordinary reads.
  • Two limits at once. A short-term bucket (bursts) plus a daily quota (fair use). Run both checks and refuse if either fails; send the headers for whichever is closer to empty.

Plans work the same way. Larger customers get a larger capacity and refill rate, read from their account record as in policy_for() instead of being hard-coded. That lets you raise a limit for one customer at 2 a.m. without a deploy.

Put a cheap limit at the edge too

The Django limiter is about fairness between clients. It still runs Python for every request, so it won't save you from a flood. A coarse per-IP limit at the proxy is a cheap first line:

# nginx.conf
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;

server {
    location /api/ {
        limit_req zone=api burst=40 nodelay;
        limit_req_status 429;
        proxy_pass http://app;
    }
}

Set it well above any legitimate client's rate. Its job is to absorb abuse before it reaches your workers, not to enforce plans.

Tell clients what's happening

A rate limit is a contract, and clients can only respect what they can see:

  • Return 429 Too Many Requests, not 403 or 500. Client libraries and SDKs treat these very differently, and a 500 will trigger retry policies you don't want. MDN's page on the 429 status code is a good one to link from your own API docs.
  • Send Retry-After with the number of seconds until a request will succeed. Many HTTP clients can honour it automatically.
  • Send remaining-quota headers on every response, not only on 429s, so clients can slow down before they hit the wall. The IETF httpapi working group has a draft for standard rate-limit headers, and recent versions use structured RateLimit and RateLimit-Policy fields. Until it's final, pick one format, document it, and don't change it casually.
  • Publish the limits in your API docs, including the burst size and how limits differ by plan or endpoint.

Your own clients have to cooperate

A mobile app or React frontend that retries a 429 immediately makes an overloaded system worse. Retries need to read Retry-After, back off with jitter, and give up after a few attempts:

async function fetchWithBackoff(url: string, init: RequestInit = {}, maxAttempts = 4) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const res = await fetch(url, init);
    if (res.status !== 429 && res.status < 500) return res;

    const retryAfter = Number(res.headers.get("Retry-After"));
    const base = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : 2 ** attempt * 500;
    const jitter = Math.random() * 250;
    await new Promise((r) => setTimeout(r, base + jitter));
  }
  throw new Error(`Gave up on ${url} after ${maxAttempts} attempts`);
}

The jitter matters more than it looks. Without it, every client that got a 429 at the same second comes back at the same second, and you've built a synchronised retry storm. Only retry non-idempotent requests like POST this way if they carry an idempotency key.

Prove it with a test

Rate limiters are rarely tested, which is how a typo in a policy ends up allowing ten times the intended traffic. Run this against a real Redis (a service container in CI is enough):

import pytest
from django.test import Client


@pytest.mark.django_db
def test_burst_then_429(user, settings, redis_flush):
    client = Client()
    client.force_login(user)

    codes = [client.get("/api/orders/").status_code for _ in range(61)]

    assert codes[:60] == [200] * 60
    assert codes[60] == 429


@pytest.mark.django_db
def test_429_tells_client_when_to_retry(user, redis_flush):
    client = Client()
    client.force_login(user)
    for _ in range(60):
        client.get("/api/orders/")

    res = client.get("/api/orders/")

    assert res.status_code == 429
    assert int(res["Retry-After"]) >= 1
    assert res["RateLimit-Remaining"] == "0"

redis_flush is a small fixture that deletes rl:* keys before each test, so runs don't leak into each other. These tests depend on the refill being slow relative to how fast the loop runs; at 1 token per second, that holds comfortably.

What about DRF's built-in throttling?

Django REST Framework's throttle classes are fine for simple per-user limits. SimpleRateThrottle keeps a list of request timestamps in the cache and checks it in Python, which isn't atomic: under concurrent requests, a few extra can slip through. For basic abuse protection on a small API, that rarely matters.

When limits are part of a commercial agreement, or traffic is high enough that "a few extra" becomes a lot, wrap the Lua script in a custom throttle class instead. Subclass BaseThrottle, call the script in allow_request(), and return the wait time from wait(). DRF then sends the 429 and Retry-After for you, and you keep the atomic check.

Choosing the numbers

The capacity and rate in the example are placeholders. Real limits should come from data, not instinct:

  • Look at current traffic first. Before enforcing anything, run the limiter in "log only" mode for a week: compute the decision, record it, but never return a 429. You will learn what the 99th percentile client actually does, and which legitimate integrations would have been blocked.
  • Set the sustained rate above real peak usage. If your heaviest legitimate customer averages 20 requests per second at their busiest, a 10 per second limit is a support ticket, not protection.
  • Set the burst for page loads and batch jobs. A single screen in your own frontend might fire 15 requests in parallel. The bucket size must cover that comfortably, or your own app will trip the limit.
  • Differentiate by plan or client type. Internal services, paying customers and anonymous traffic deserve different buckets. Store per-plan capacity and rate alongside the account and pass them into the script instead of using module constants.

Revisit the numbers whenever traffic patterns change. Limits that were generous a year ago can quietly become the bottleneck after a successful product launch, and tightening them should be announced to API customers well before it takes effect.

Watch it in production

A limiter you cannot see is a limiter you cannot tune. Log every refusal with the client key, the endpoint and the cost, and track the count of 429s per client as a metric. Two patterns are worth alerting on: one client being refused thousands of times an hour, which usually means a broken integration stuck in a retry loop, and a sudden rise in refusals across many clients, which usually means your limits are wrong or your own frontend has started making more requests than it used to.

Give support staff a way to see a client's current state. A small admin view that reads the bucket hash for a given customer, showing the tokens left and when the bucket was last touched, answers "why is this customer getting 429s?" in seconds instead of a debugging session. Pair it with a documented, audited way to raise one customer's limit temporarily, such as a per-account override stored in the database, so a large customer's data migration does not need a code deploy.

Also alert on the "limiter unavailable" warning from the Redis error path. An API that is failing open because Redis is down is an API running without its protection, and nobody should find that out from the next abusive client.

Make the headers readable from the browser

If your API serves a frontend on another origin, the rate limit headers are invisible to JavaScript by default. Browsers only expose a small set of "safe" response headers to cross-origin code, so Retry-After and the quota headers arrive in the response and are then hidden from your own client. Add them to Access-Control-Expose-Headers in your CORS configuration; with django-cors-headers that is the CORS_EXPOSE_HEADERS setting. Without it, the cooperative client logic above silently falls back to its default backoff, and nobody notices until someone opens the network tab and wonders why the headers are there but unused.

A checklist before you ship

  • [ ] Token bucket with an atomic check (Lua or equivalent).
  • [ ] Keys based on user or API client, with a trusted IP source for anonymous traffic.
  • [ ] Expensive endpoints cost more or have their own bucket.
  • [ ] Deliberate fail-open or fail-closed behaviour per endpoint group.
  • [ ] 429 with Retry-After, plus remaining-quota headers on every response.
  • [ ] Limits documented, including bursts and plan differences.
  • [ ] Your own clients back off with jitter.
  • [ ] A test that proves the limit actually triggers.

Wrapping up

A good rate limiter is boring for well-behaved clients and very clear with badly behaved ones. Use a token bucket for burst-friendly limits, make the check atomic in Redis, key it by identity, put a cheap limit at the edge, and send headers that let clients slow down on their own. Most of the work isn't the algorithm. It's the contract you publish around it.

Question for you: what's the worst retry storm you've seen hit an API, and was it a third-party client or your own app that caused it?

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.