API Rate Limiting
Rate limiting protects your API from:
- brute force login attacks
- abusive clients
- accidental traffic spikes
Flask-Limiter
Section titled “Flask-Limiter”A popular extension is Flask-Limiter.
Install:
pip install Flask-LimiterBasic usage (example)
Section titled “Basic usage (example)”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"}Choosing limits
Section titled “Choosing limits”Good limits depend on:
- endpoint type (login should be tighter)
- expected client behavior
- whether the endpoint is expensive (DB-heavy)
Production note
Section titled “Production note”For multi-instance deployments, configure shared storage (Redis) so limits are consistent across instances.
What a limiter actually counts
Section titled “What a limiter actually counts”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:
flowchart TD R["request"] --> K["key_func(request)
usually the client IP"] K --> C{"count for this key
within the window"} C -->|"under the limit"| A["allow, increment"] C -->|"over the limit"| D["429 Too Many Requests"] A --> H["X-RateLimit-Remaining decremented"] D --> RA["Retry-After tells the client when to come back"]
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:
| request | status | X-RateLimit-Remaining |
|---|---|---|
| 1 | 200 | 2 |
| 2 | 200 | 1 |
| 3 | 200 | 0 |
| 4 | 429 | 0 |
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.
Two defaults that will bite an API
Section titled “Two defaults that will bite an API”The 429 body is HTML. Measured, with no error handler:
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:
@app.errorhandler(429)
def too_many(e):
return jsonify(error="rate limit exceeded", detail=str(e.description)), 429Content-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.
Choosing the key
Section titled “Choosing the key”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
ProxyFixand 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.
def key_func():
return request.headers.get("X-API-Key") or get_remote_address()
limiter = Limiter(key_func, app=app)See it move
Section titled “See it move”Check yourself
Section titled “Check yourself”-
With flask-limiter's default storage and gunicorn -w 4, what does a configured limit of 100 per hour actually allow?
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.
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.
-
By default, what Content-Type does flask-limiter's 429 response use?
Measured an HTML error page. Register an errorhandler(429) returning jsonify so API clients get something they can read.
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.
-
Why is get_remote_address a poor key function behind a load balancer?
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.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.coffeeCtapch.feedbackHeading
pch.feedbackSubheading