An API with no rate limit is an API waiting to be hammered. A careless script stuck in a retry loop, a scraper with no backoff, or a client with a bug in its polling logic can all send hundreds of requests a second โ and without a limit in place, your server tries to handle every single one. Rate limiting fixes this: it caps how many requests a client can make in a given window of time, and rejects anything past that cap before it reaches your application logic.
This tutorial adds rate limiting to a small Flask API using Upstash, a managed Redis service with a generous free tier. By the end, every route in the API will enforce a request limit per client, and clients who go over it will get a clear 429 Too Many Requests response instead of silently overwhelming your server.
The mental model you'll walk away with: you don't have to let bad actors or careless clients hammer your API into the ground. A Redis counter and a few lines of Python are enough to enforce a limit.
What you need before starting
Before you start, gather the following:
- Basic Python knowledge.
- Flask basics (routes, responses).
- An Upstash account (free tier).
-
redis-pyinstalled (pip install redis). -
flaskinstalled (pip install flask).
What you'll build
The API manages a simple items resource with standard CRUD routes: GET /items, GET /items/<id>, POST /items, PUT /items/<id>, and DELETE /items/<id>. Items live in memory โ this tutorial is about protecting the API, not persisting data, so storage stays as simple as possible.
The interesting part is a single decorator, @rate_limit, that sits on every route. It uses Redis to track how many requests each client has made in the current time window and rejects any request past the configured limit.
Create an Upstash Redis database
Upstash gives you a Redis database with a REST-friendly free tier, and no local Redis installation to manage.
- Go to console.upstash.com and sign up or log in.
- Click Create Database.
- Give it a name, pick a region close to you, and select the Free plan.
- Open the new database and go to the Details tab.
- Copy the Redis URL. It starts with
rediss://and already bundles your username, password, host, and port into one string.
Save that URL somewhere for the next step โ it's the only Upstash-specific detail this project needs.
Connect Python to Upstash Redis
Upstash Redis speaks the standard Redis protocol, so the regular redis-py client connects to it directly. No Upstash-specific SDK is required.
Start by centralizing configuration in one module, so every environment variable the app depends on is visible in one place:
# config.py
import os
REDIS_URL = os.environ.get("UPSTASH_REDIS_URL", "")
RATE_LIMIT_MAX_REQUESTS = int(os.environ.get("RATE_LIMIT_MAX_REQUESTS", 10))
RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", 60))
if not REDIS_URL:
raise RuntimeError(
"UPSTASH_REDIS_URL is not set. Copy .env.example to .env, fill in "
"your Upstash connection string, then export the variables before "
"running the app."
)
RATE_LIMIT_MAX_REQUESTS and RATE_LIMIT_WINDOW_SECONDS control the limit itself โ the defaults above allow 10 requests per 60-second window. Raising the RuntimeError early means a missing connection string fails loudly at startup instead of causing confusing errors later.
With configuration in place, create the Redis client:
# redis_client.py
import redis
from config import REDIS_URL
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
Two details matter here:
-
redis.from_urlaccepts therediss://URL exactly as Upstash provides it. The extra "s" means the connection is TLS-encrypted, andredis-pyhandles that automatically. -
decode_responses=Truetells the client to return plain Python strings instead of bytes. This keeps the rate-limiting code below simpler to read.
Build the rate limiter
This is the core of the tutorial: a fixed-window counter that tracks requests per client using Redis.
The algorithm has four steps:
- Build a Redis key unique to both the client and the current time window โ for example,
ratelimit:203.0.113.5:28819023, where the number is the current Unix timestamp divided by the window length. - Increment that key with
INCR. Redis creates missing keys at 0 before incrementing, andINCRis atomic, so concurrent requests from the same client can never race each other into an incorrect count. - On the first request in a new window, set an
EXPIREequal to the window length. Redis deletes the key on its own once the window ends, so the counter resets with no cleanup code required. - Once the count passes the configured limit, reject every further request in that window.
Here's the implementation:
# rate_limiter.py
import time
from functools import wraps
from flask import request, jsonify
from redis_client import redis_client
from config import RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_SECONDS
def _get_client_id() -> str:
"""Identify the caller by IP address."""
return request.remote_addr or "unknown"
def is_rate_limited(client_id: str) -> tuple[bool, dict]:
"""Increment and check a client's request count for the current window."""
current_window = int(time.time()) // RATE_LIMIT_WINDOW_SECONDS
redis_key = f"ratelimit:{client_id}:{current_window}"
# This single INCR call is the entire rate-limit counter.
request_count = redis_client.incr(redis_key)
if request_count == 1:
redis_client.expire(redis_key, RATE_LIMIT_WINDOW_SECONDS)
seconds_into_window = int(time.time()) % RATE_LIMIT_WINDOW_SECONDS
seconds_remaining = RATE_LIMIT_WINDOW_SECONDS - seconds_into_window
info = {
"limit": RATE_LIMIT_MAX_REQUESTS,
"remaining": max(0, RATE_LIMIT_MAX_REQUESTS - request_count),
"retry_after": seconds_remaining,
}
return request_count > RATE_LIMIT_MAX_REQUESTS, info
Notice what's absent: no locks, no background cleanup job, no scheduled task to purge old counters. INCR and EXPIRE push both the concurrency safety and the cleanup work onto Redis, which is exactly what it's designed for.
_get_client_id identifies callers by IP address, which is enough for a tutorial and for many small APIs. If this app sat behind a reverse proxy or load balancer, you'd read the client's real IP from the X-Forwarded-For header instead โ otherwise every request would appear to come from the proxy.
Apply the rate limiter to your routes
A rate limiter that nothing calls doesn't protect anything. The connection point is a decorator that wraps is_rate_limited around each route:
# rate_limiter.py (continued)
def rate_limit(f):
@wraps(f)
def wrapper(*args, **kwargs):
client_id = _get_client_id()
limited, info = is_rate_limited(client_id)
if limited:
response = jsonify({
"error": "rate_limit_exceeded",
"message": (
f"Too many requests. Limit is {info['limit']} requests "
f"per {RATE_LIMIT_WINDOW_SECONDS} seconds."
),
"retry_after_seconds": info["retry_after"],
})
response.status_code = 429
response.headers["Retry-After"] = str(info["retry_after"])
return response
return f(*args, **kwargs)
return wrapper
Applying it to a route is a one-line addition:
# app.py
from flask import Flask, jsonify
import store
from rate_limiter import rate_limit
app = Flask(__name__)
@app.route("/items", methods=["GET"])
@rate_limit
def list_items_route():
return jsonify(store.list_items())
The same @rate_limit decorator sits above every other route in app.py โ the POST, PUT, DELETE, and single-item GET all get it too. Because the decorator runs before the view function's own code, a rejected request never touches store.py at all; it's turned away at the door. This is also why the CRUD logic in store.py (an in-memory dict guarded by a thread lock) doesn't need to know anything about rate limiting, HTTP, or Redis โ it just manages items, and the decorator handles abuse protection as a separate concern layered on top.
Return a proper error response when the limit is exceeded
A rate limiter is only useful if callers can tell why their request failed and when to try again. The rate_limit decorator above returns two things that matter for that:
- A
429status code is the standard HTTP status for "you've sent too many requests." - A
Retry-Afterheader tells well-behaved clients exactly how many seconds to wait.
The JSON body follows the same idea, giving both a machine-readable error code and a human-readable message:
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Limit is 10 requests per 60 seconds.",
"retry_after_seconds": 37
}
A client library can check response.status_code == 429, read Retry-After, and back off automatically โ no guessing required.
Test the rate limiter locally
Copy .env.example to .env, paste in your Upstash Redis URL, and export the variables into your shell:
export $(cat .env | xargs)
Install dependencies and start the app:
pip install -r requirements.txt
python app.py
With the default limit of 10 requests per 60 seconds, send 11 requests in a row and watch the last one fail:
for i in $(seq 1 11); do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/items
done
The first 10 lines print 200. The 11th prints 429 โ the rate limiter did its job.
Know the fixed-window trade-off
Everything above adds up to a small amount of code: one Redis client, one counter function, and one decorator. In exchange, the decorator protects every route in the API from being overwhelmed by a single client, with no extra infrastructure beyond Redis itself.
This fixed-window approach is deliberately simple, and simple has a real trade-off worth knowing: a client can send its full limit right at the end of one window and its full limit again right at the start of the next, effectively doubling the rate for a brief moment at window boundaries. For most APIs, that's a fine trade for the simplicity of INCR plus EXPIRE. If you outgrow this fixed-window approach, the same Redis connection can support a sliding-window or token-bucket algorithm instead โ but the core idea doesn't change: a Redis counter and a few lines of Python are enough to keep bad actors and careless clients from taking your API down.
The Repository link : https://github.com/anthonyonyenaobijeffery-debug/Protecting-Your-Python-API-from-Abuse-with-Upstash-Redis
United States
NORTH AMERICA
Related News
Secret Claude Tracker Shocks Users After Anthropic's Anti-Surveillance Stance
12h ago
EV Batteries Defy Expectations, Last Hundreds of Thousands of Miles
1d ago
GBase 8a Performance Anomaly Case Study: How a Single Parameter Change Sparked a Chain Reaction
1d ago
Who Else Has Inherited a Codebase With Zero Comments and a Prayer?
1d ago
ๅฎ็พ็ๅนณๅบธ
3h ago