Rate Limiting (Flask-Limiter)

Reviewed & published by Brayan K

Rate limiting caps how many requests a single client may make in a time window, rejecting the excess with a 429 Too Many Requests response to protect your API from abuse.

Part of the free Flask course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll build a fixed-window limiter and a token bucket from scratch, return proper 429 responses, key limits on IP or user, and see how Flask-Limiter does it declaratively.

What You'll Learn in This Lesson

1 A Fixed-Window Limiter & the 429 Response

A public API is a target. A buggy client, a scraper, or an attacker can hammer an endpoint thousands of times a second and exhaust your database or bandwidth. Rate limiting caps how many requests one client may make in a window and rejects the rest.

The simplest strategy is a fixed window: allow N requests per window, then reset the counter when the window expires. When a client goes over, you return HTTP 429 Too Many Requests — the standard "slow down" status.

The runnable example below allows 3 requests, then returns 429 for the 4th and 5th. The limit is keyed on the client's IP, so different clients get independent allowances.

# app.py — a fixed-window rate limiter in plain Python
import time
from flask import Flask, request, jsonify

app = Flask(__name__)

LIMIT = 3          # max requests
WINDOW = 60        # per this many seconds
_hits = {}         # key -> (count, window_start)

def allowed(key):
    now = time.time()
    count, start = _hits.get(key, (0, now))
    if now - start >= WINDOW:   # window expired -> reset
        count, start = 0, now
    if count >= LIMIT:
        return False
    _hits[key] = (count + 1, start)
    return True

@app.route("/api/ping")
def ping():
    key = request.remote_addr or "anon"   # rate-limit per client IP
    if not allowed(key):
        return jsonify(error="Too Many Requests"), 429
    return jsonify(pong=True)

# Fire five requests; the 4th and 5th are blocked:
with app.test_client() as c:
    for i in range(5):
        r = c.get("/api/ping")
        print(i + 1, r.status_code, r.get_json())

# app.run() would serve this forever, so it goes last and stays commented
# out — the test client above is how Flask itself exercises routes:
#     if __name__ == "__main__":
#         app.run(debug=True)

# ✅ Expected output:
# 1 200 {'pong': True}
# 2 200 {'pong': True}
# 3 200 {'pong': True}
# 4 429 {'error': 'Too Many Requests'}
# 5 429 {'error': 'Too Many Requests'}

Requests 1-3 return 200 with pong; requests 4 and 5 return 429 with an error. The window holds the line.

2 Smoothing Bursts: the Token Bucket

A fixed window has a weakness: a client can fire its whole quota at the very end of one window and again at the start of the next, doubling the burst. A token bucket fixes this. The bucket holds up to capacity tokens, refills at a steady rate, and each request must take one token — so requests are smoothed to the refill rate.

The runnable class below starts with 2 tokens and refills 1 per second. The first two requests succeed, the next two fail (the bucket is empty), and after waiting just over a second one token has refilled, so the next request succeeds again.

# A token-bucket limiter: tokens refill over time, smoothing bursts
import time

class TokenBucket:
    def __init__(self, capacity, refill_per_sec):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_per_sec = refill_per_sec
        self.last = time.time()

    def take(self):
        now = time.time()
        # add tokens for the elapsed time, capped at capacity
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill_per_sec)
        self.last = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

# capacity 2, refills 1 token/sec
bucket = TokenBucket(capacity=2, refill_per_sec=1)
print([bucket.take() for _ in range(4)])   # [True, True, False, False]
time.sleep(1.1)                            # wait for ~1 token to refill
print(bucket.take())                       # True

# ✅ Expected output:
# [True, True, False, False]
# True

3 Keying on IP or User & the Retry-After Header

The key decides who a limit applies to. Keying on IP protects anonymous endpoints, but for an authenticated API you usually key on the user or API key so each account gets a fair, independent allowance. A well-behaved limiter also sets a Retry-After header telling the client how long to wait.

The runnable example keys on an X-User-Id header when present. alice uses up her allowance and gets a 429 with Retry-After, while bob — a different key — is still allowed.

# Per-user limits with a Retry-After header
import time
from flask import Flask, request, jsonify

app = Flask(__name__)

LIMIT, WINDOW = 2, 60
_hits = {}

def check(key):
    now = time.time()
    count, start = _hits.get(key, (0, now))
    if now - start >= WINDOW:
        count, start = 0, now
    if count >= LIMIT:
        retry_after = int(WINDOW - (now - start))
        return False, retry_after
    _hits[key] = (count + 1, start)
    return True, 0

@app.route("/api/data")
def data():
    # key on the user header if present, else the IP
    user = request.headers.get("X-User-Id")
    key = f"user:{user}" if user else f"ip:{request.remote_addr}"
    ok, retry_after = check(key)
    if not ok:
        resp = jsonify(error="rate limited")
        resp.status_code = 429
        resp.headers["Retry-After"] = str(retry_after)
        return resp
    return jsonify(ok=True, key=key)

with app.test_client() as c:
    # alice and bob have independent buckets
    print(c.get("/api/data", headers={"X-User-Id": "alice"}).get_json())  # {'key': 'user:alice', 'ok': True}
    print(c.get("/api/data", headers={"X-User-Id": "alice"}).get_json())  # {'key': 'user:alice', 'ok': True}
    blocked = c.get("/api/data", headers={"X-User-Id": "alice"})
    print(blocked.status_code, "Retry-After" in blocked.headers)          # 429 True
    print(c.get("/api/data", headers={"X-User-Id": "bob"}).get_json())    # {'key': 'user:bob', 'ok': True}

# ✅ Expected output:
# {'key': 'user:alice', 'ok': True}
# {'key': 'user:alice', 'ok': True}
# 429 True
# {'key': 'user:bob', 'ok': True}

In Flask-Limiter you swap the key_func — for example lambda: current_user.id — to limit per user instead of per IP.

🔬 Rate Limiting Cheat Sheet

ConceptMeaning
429Too Many Requests — the "you're over the limit" status.
Retry-AfterHeader telling the client how many seconds to wait.
Key functionDecides who the limit applies to (IP, user, API key).
StorageWhere counts live; use Redis so all workers share them.

🎯 Your Turn: Fill in the Blanks

Complete the limiter below. Replace each ___ so the route allows 2 requests per IP and returns the right status when exceeded.

# 🎯 YOUR TURN: replace each ___ to finish the limiter
import time
from flask import Flask, request, jsonify

app = Flask(__name__)
LIMIT, WINDOW = 2, 60
_hits = {}

@app.route("/api/hello")
def hello():
    key = request.remote_addr or "anon"
    now = time.time()
    count, start = _hits.get(key, (0, now))
    if now - start >= WINDOW:
        count, start = 0, now
    if count >= LIMIT:
        return jsonify(error="slow down"), ___    # which status code?
    _hits[key] = (count + ___, start)             # bump the count by how much?
    return jsonify(hello=True)

with app.test_client() as c:
    print(c.get("/api/hello").status_code)  # 200
    print(c.get("/api/hello").status_code)  # 200
    print(c.get("/api/hello").status_code)  # 429

Common Errors & Quick Reference

❌ Limits don't add up across workers

An in-memory dict (or Flask-Limiter's default memory storage) is per-process, so 4 workers allow 4× the limit. Use a shared storage_uri="redis://..." so every worker counts against one store.

❌ Everyone is rate-limited as one client

Behind a proxy, request.remote_addr is the proxy's IP, so all users share one bucket. Configure ProxyFix / trusted proxies so the real client IP (from X-Forwarded-For) is used as the key.

TaskCode
Per-route limit@limiter.limit("5/minute")
Exempt a route@limiter.exempt
Key by IPkey_func=get_remote_address
Reject statusreturn body, 429

🏆 Mini Challenge: Two Routes, Two Limits

Build one limit helper and apply different limits to two routes.

# 🏆 Mini Challenge: two routes, different limits
import time
from flask import Flask, request, jsonify

app = Flask(__name__)
_hits = {}

def limit(name, max_requests, window=60):
    key = (name, request.remote_addr or "anon")
    now = time.time()
    count, start = _hits.get(key, (0, now))
    if now - start >= window:
        count, start = 0, now
    if count >= max_requests:
        return False
    _hits[key] = (count + 1, start)
    return True

@app.route("/cheap")
def cheap():
    if not limit("cheap", 5):
        return jsonify(error="429"), 429
    return jsonify(route="cheap", ok=True)

@app.route("/expensive")
def expensive():
    if not limit("expensive", 1):
        return jsonify(error="429"), 429
    return jsonify(route="expensive", ok=True)

# Try it:
with app.test_client() as c:
    print([c.get("/expensive").status_code for _ in range(3)])  # [200, 429, 429]
    print([c.get("/cheap").status_code for _ in range(3)])      # [200, 200, 200]

❓ Frequently Asked Questions

Lesson complete — your API can defend itself!

You built a fixed-window limiter and a token bucket, returned 429 with Retry-After, keyed limits on IP and user, and saw the declarative Flask-Limiter equivalent.

Practice quiz

Which HTTP status means the client has sent too many requests?

  • 429 Too Many Requests
  • 403 Forbidden
  • 401 Unauthorized
  • 404 Not Found

Answer: 429 Too Many Requests. 429 Too Many Requests is the standard rate-limit rejection status.

Which response header tells the client how long to wait before retrying?

  • Cache-Control
  • Retry-After
  • X-Rate-Remaining
  • Location

Answer: Retry-After. Retry-After gives the number of seconds to wait before sending another request.

What weakness does a fixed-window limiter have?

  • It needs Redis
  • It cannot return 429
  • Bursts can sneak through at window edges
  • It blocks all requests

Answer: Bursts can sneak through at window edges. A client can spend its quota at the end of one window and again at the start of the next.

How does a token bucket smooth bursts?

  • It blocks every other request
  • It resets nightly
  • It caches responses
  • Tokens refill at a steady rate and each request takes one

Answer: Tokens refill at a steady rate and each request takes one. Requests are smoothed to the refill rate; a request passes only if a token is available.

Which Flask-Limiter decorator applies a per-route limit?

  • @limiter.limit("5/minute")
  • @app.rate(5)
  • @limit_route(5)
  • @throttle(5)

Answer: @limiter.limit("5/minute"). @limiter.limit("5/minute") declares the allowance for that route.

What does key_func=get_remote_address do in Flask-Limiter?

  • Caches the response
  • Keys limits on the client IP address
  • Sets the storage backend
  • Returns the 429 body

Answer: Keys limits on the client IP address. get_remote_address makes the limit count per client IP.

Why use Redis (storage_uri) instead of in-memory counts?

  • It is required by Flask
  • It encrypts requests
  • So all worker processes share one count
  • To speed up the database

Answer: So all worker processes share one count. In-memory counts are per-process, so multiple workers would each allow the full limit.

For an authenticated API, what is usually the best key for limits?

  • The request path
  • The User-Agent
  • A random value
  • The user or API key

Answer: The user or API key. Keying on the user or API key gives each account its own fair allowance.

Why might every user appear rate-limited as one client behind a proxy?

  • request.remote_addr is the proxy's IP
  • Redis is offline
  • The limit is too high
  • 429 is disabled

Answer: request.remote_addr is the proxy's IP. Behind a proxy remote_addr is the proxy IP unless ProxyFix / X-Forwarded-For is configured.

Which Flask-Limiter decorator removes limiting from a route?

  • @limiter.skip
  • @limiter.exempt
  • @limiter.off
  • @limiter.free

Answer: @limiter.exempt. @limiter.exempt exempts a route from rate limiting.

Continue this course

Frequently asked questions

What is rate limiting?

Rate limiting caps how many requests a single client can make in a given time window. If a client exceeds the limit, the server rejects further requests with a 429 Too Many Requests response until the window resets. It protects APIs from abuse, runaway scripts, and denial-of-service traffic.

What does a 429 status code mean?

429 Too Many Requests is the HTTP status a server returns when a client has sent too many requests in a given amount of time. It is commonly paired with a Retry-After header telling the client how many seconds to wait before trying again.

What is the difference between fixed-window and token-bucket limiting?

A fixed window allows N requests per time window and resets the counter at the window boundary, which can let bursts through at the edges. A token bucket refills tokens continuously and lets a request through only if a token is available, smoothing out bursts and allowing a controlled steady rate.

Should I rate limit by IP address or by user?

By IP is the default and protects anonymous endpoints, but many users can share one IP (behind NAT or a proxy) and one user can have many IPs. For authenticated APIs, key the limit on the user or API key so each account gets its own fair allowance. You can combine both.

What does Flask-Limiter add over a hand-rolled limiter?

Flask-Limiter gives you a declarative @limiter.limit('100/hour') decorator, multiple strategies, shared storage backends like Redis so all workers count together, automatic 429 responses with headers, and per-route plus global default limits — all configured in a few lines instead of managing dictionaries yourself.