DEV Community

Jeffery Kachukwucide
Jeffery Kachukwucide

Posted on

Building a Rate-Limited API with Upstash Redis and Python

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-py installed (pip install redis).
  • flask installed (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.

  1. Go to console.upstash.com and sign up or log in.
  2. Click Create Database.
  3. Give it a name, pick a region close to you, and select the Free plan.
  4. Open the new database and go to the Details tab.
  5. 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."
    )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • redis.from_url accepts the rediss:// URL exactly as Upstash provides it. The extra "s" means the connection is TLS-encrypted, and redis-py handles that automatically.
  • decode_responses=True tells 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:

  1. 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.
  2. Increment that key with INCR. Redis creates missing keys at 0 before incrementing, and INCR is atomic, so concurrent requests from the same client can never race each other into an incorrect count.
  3. On the first request in a new window, set an EXPIRE equal to the window length. Redis deletes the key on its own once the window ends, so the counter resets with no cleanup code required.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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 429 status code is the standard HTTP status for "you've sent too many requests."
  • A Retry-After header 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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Install dependencies and start the app:

pip install -r requirements.txt
python app.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (1)

Collapse
 
topstar_ai profile image
Luis

Great write-up. Rate limiting is one of those things that looks simple at first but becomes a critical reliability layer once an API starts handling real traffic.

Using Redis for distributed rate limiting is a practical approach because it provides fast atomic operations and works well across multiple application instances.

A few production considerations that are worth adding:

Choose the right algorithm depending on the use case (fixed window, sliding window, token bucket, leaky bucket)
Avoid making rate limits only IP-based — authenticated users, API keys, and tenant-level limits are often more useful
Return proper headers (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After) so clients can react correctly
Monitor rejected requests to identify abuse patterns and capacity issues

For larger SaaS systems, rate limiting often evolves into a broader traffic management layer combined with quotas, usage tracking, and billing enforcement.

The interesting part is that rate limiting is not only about blocking bad actors — it is also about protecting system stability and creating predictable user experiences.

Nice implementation with Upstash Redis and Python. 👏