Skip to content

API Rate Limiting

Rate limiting protects your API from:

  • brute force login attacks
  • abusive clients
  • accidental traffic spikes

A popular extension is Flask-Limiter.

Install:

bash
pip install Flask-Limiter
python
from flask import Flask
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
 
app = Flask(__name__)
 
limiter = Limiter(
    get_remote_address,
    app=app,
    default_limits=["200 per day", "50 per hour"],
)
 
 
@app.get("/api/status")
@limiter.limit("10 per minute")
def status():
    return {"status": "ok"}

Good limits depend on:

  • endpoint type (login should be tighter)
  • expected client behavior
  • whether the endpoint is expensive (DB-heavy)

For multi-instance deployments, configure shared storage (Redis) so limits are consistent across instances.

A rate limit is a counter keyed by something about the caller, reset on a schedule. Flask-Limiter’s key function decides what that something is:

diagram Diagram mermaid
setup.py
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
 
limiter = Limiter(get_remote_address, app=app, default_limits=["5 per minute"])
 
@app.route("/api")
@limiter.limit("3 per minute")      # overrides the default for this route
def api():
    return jsonify(ok=True)
 
@app.route("/health")
@limiter.exempt                      # never limited
def health():
    return "ok"

Measured, with RATELIMIT_HEADERS_ENABLED:

requeststatusX-RateLimit-Remaining
12002
22001
32000
44290

The default limit applied to undecorated routes exactly as configured: seven calls to a route under 5 per minute measured [200, 200, 200, 200, 200, 429, 429]. An exempt route took eight calls and returned 200 every time. A request from a different IP had its own budget and was allowed immediately.

The 429 body is HTML. Measured, with no error handler:

default 429
Content-Type: text/html; charset=utf-8
<!doctype html><html lang=en><title>429 Too Many Requests</title>...

A JSON client receiving that will fail to parse it and report something unhelpful. Give it a JSON handler:

json_429.py
@app.errorhandler(429)
def too_many(e):
    return jsonify(error="rate limit exceeded", detail=str(e.description)), 429
measured
Content-Type: application/json
{"detail":"3 per 1 minute","error":"rate limit exceeded"}

Storage is in memory by default. Measured: MemoryStorage, and two limiters held two separate storage objects.

get_remote_address is the default and is wrong in two common situations:

  • Behind a proxy or load balancer, every request appears to come from the proxy, so all your users share one budget. You need ProxyFix and the forwarded header — and you must only trust it when your own proxy sets it.
  • For an authenticated API, the fair unit is the account, not the connection. Key on the API key or user id so one customer on a shared office IP is not throttled by their colleagues.
key_by_user.py
def key_func():
    return request.headers.get("X-API-Key") or get_remote_address()
 
limiter = Limiter(key_func, app=app)
sketch A limit of 3 per minute p5.js
Each allowed request decrements the remaining budget. When it reaches zero the next request is refused with 429 and a Retry-After header.
pch.quizTag pch.quizDefaultTitle
  1. With flask-limiter's default storage and gunicorn -w 4, what does a configured limit of 100 per hour actually allow?

    pch.quizShowAnswer

    B — up to 400 per hour, because each worker process keeps its own in-memory counters — Measured storage was MemoryStorage, and two limiters held separate storage objects. Use a shared backend such as storage_uri=redis://... for anything beyond one process.

  2. By default, what Content-Type does flask-limiter's 429 response use?

    pch.quizShowAnswer

    B — text/html, which a JSON client cannot parse — Measured an HTML error page. Register an errorhandler(429) returning jsonify so API clients get something they can read.

  3. Why is get_remote_address a poor key function behind a load balancer?

    pch.quizShowAnswer

    B — every request appears to come from the proxy, so all users share a single budget — You need ProxyFix and the forwarded header — trusted only when your own proxy sets it. For an authenticated API, keying on the API key or user id is fairer still.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading