Excellent โ hereโs your definitive, single-source Redis + FastAPI + Prometheus + Grafana guide, ready to publish as a full blog post, e-book chapter, or narrated video script. Everything is consolidated: code, folder tree, line explanations, tests, metrics, Grafana use, and storytelling intro/outro.
Build, Observe, and Master Atomic Operations, Rate Limiting, and Real-Time Dashboards
Modern APIs are not just about serving requests; theyโre about control โ controlling load, speed, and visibility. A single user or bot can flood your endpoints, making your service unreliable. Thatโs where Redis steps in โ a lightning-fast in-memory data store, capable of atomic increments, token-bucket algorithms, and millions of ops per second.
But control without visibility is blind. To see whatโs really happening, you need Prometheus and Grafana โ tools that turn raw metrics into real-time dashboards.
This tutorial builds a complete system, from Redis operations to Grafana dashboards, inside a single Docker Compose setup.
redis-redislim/
โโ docker-compose.yml
โโ .env
โโ api/
โ โโ Dockerfile
โ โโ requirements.txt
โ โโ app/
โ โโ __init__.py
โ โโ redis_client.py
โ โโ ratelimit.py
โ โโ metrics.py
โ โโ main.py
โ โโ scripts/load_test.py
โโ prometheus/prometheus.yml
โโ grafana/
โโ provisioning/
โ โโ datasources/datasource.yml
โ โโ dashboards/dashboards.yml
โโ dashboards/redis_rate_limiter.jsonYouโll learn:
INCR, ZADD, and Lua scripts| Component | Purpose | Port |
|---|---|---|
| Redis | Atomic counters, Lua logic | 6379 |
| Redis Commander | GUI key explorer | 8081 |
| FastAPI | Core REST API | 8001 |
| Prometheus | Metrics collector | 9090 |
| Grafana | Dashboard visualizer | 3000 |
version: "3.9"Compose spec version.
services:Defines all services.
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "PING"]
interval: 5s
timeout: 3s
retries: 20 redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"
depends_on:
- redisBrowser GUI for viewing Redis keys.
api:
build:
context: ./api
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
ports:
- "8001:8001"
volumes:
- ./api/app:/app/app prometheus:
image: prom/prometheus:latest
command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.retention.time=2d"]
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
depends_on:
- apiPrometheus scrapes metrics from the API.
grafana:
image: grafana/grafana:latest
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_DEFAULT_THEME=dark
ports:
- "3000:3000"
volumes:
- ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheusGrafana auto-provisions Prometheus datasource and loads dashboards.
volumes:
redisdata:Persistent volume for Redis.
REDIS_URL=redis://redis:6379/0
APP_HOST=0.0.0.0
APP_PORT=8001
FIXED_WINDOW_LIMIT=5
FIXED_WINDOW_SECONDS=10
SLIDING_WINDOW_LIMIT=8
SLIDING_WINDOW_SECONDS=10Stores configuration and limits.
Creates a reusable Redis connection.
from redis.asyncio import Redis
import os
_redis: Redis | None = None
async def get_redis() -> Redis:
global _redis
if _redis is None:
url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
_redis = Redis.from_url(url, encoding="utf-8", decode_responses=True)
return _redisImplements Fixed Window, Sliding Window, Token Bucket.
async def fixed_window_allow(redis, key, limit, window_secs):
rk = f"rl:fixed:{key}"
async with redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(rk)
exists = await redis.exists(rk)
pipe.multi()
pipe.incr(rk, 1)
if not exists:
pipe.expire(rk, window_secs)
res = await pipe.execute()
new_count = res[0]
ttl = await redis.ttl(rk)
allowed = new_count <= limit
remaining = max(limit - new_count, 0)
return allowed, remaining, ttl
except Exception:
continue
finally:
await pipe.reset()Uses atomic INCR and EXPIRE for simple burst control.
async def sliding_window_allow(redis, key, limit, window_secs):
rk = f"rl:slide:{key}"
now_ms = int(time.time() * 1000)
window_start = now_ms - window_secs * 1000
async with redis.pipeline(transaction=True) as pipe:
pipe.zremrangebyscore(rk, 0, window_start)
pipe.zadd(rk, {str(now_ms): now_ms})
pipe.zcard(rk)
pipe.expire(rk, window_secs * 2)
_, _, count, _ = await pipe.execute()
return count <= limit, max(limit - count, 0)Counts timestamps within the last N seconds using a sorted set.
TOKEN_BUCKET_LUA = """
local tokens_key = KEYS[1]
local ts_key = KEYS[2]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local tokens = tonumber(redis.call('GET', tokens_key))
local last_ts = tonumber(redis.call('GET', ts_key))
if tokens == nil then tokens = capacity end
if last_ts == nil then last_ts = now_ms end
local delta = math.max(0, now_ms - last_ts)
local refill = (delta / 1000.0) * refill_rate
tokens = math.min(capacity, tokens + refill)
local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end
redis.call('SET', tokens_key, tokens)
redis.call('SET', ts_key, now_ms)
redis.call('PEXPIRE', tokens_key, 7200000)
redis.call('PEXPIRE', ts_key, 7200000)
return {allowed, tokens}
"""Runs atomically on the Redis server โ no race conditions.
Defines Prometheus metrics and /metrics endpoint.
from prometheus_client import Counter, Histogram, CONTENT_TYPE_LATEST, generate_latest
from starlette.responses import Response
REQUESTS_TOTAL = Counter("requests_total", "Total API requests", ["endpoint","status","strategy"])
RATE_LIMITED_TOTAL = Counter("rate_limited_total", "Rate-limited responses", ["strategy"])
REQUEST_LATENCY = Histogram("request_latency_seconds", "Latency of requests in seconds")
async def metrics_endpoint():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)Ties it all together.
from fastapi import FastAPI, Depends, Request, Query
from redis.asyncio import Redis
from .redis_client import get_redis
from .ratelimit import fixed_window_allow, sliding_window_allow, token_bucket_allow
from .metrics import REQUESTS_TOTAL, RATE_LIMITED_TOTAL, REQUEST_LATENCY, metrics_endpoint
import time, osapp = FastAPI(title="Redis A-to-Z: Atomic Ops & Rate Limiting + Metrics")
@app.middleware("http")
async def track_latency(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
REQUEST_LATENCY.observe(time.perf_counter() - start)
return response@app.get("/health")
async def health(redis: Redis = Depends(get_redis)):
pong = await redis.ping()
return {"ok": pong}(Fixed, Sliding, Token Bucket) โ call the functions above, record metrics, and return JSON results.
Concurrent tester using aiohttp to generate load.
prometheus.yml:
global:
scrape_interval: 5s
scrape_configs:
- job_name: "api"
static_configs:
- targets: ["api:8001"]
metrics_path: /metricsTells Prometheus to poll the API every 5 s for metrics.
Browse: http://localhost:9090
Try:
requests_total
rate(requests_total[1m])
histogram_quantile(0.9, sum(rate(request_latency_seconds_bucket[1m])) by (le))Pre-configured datasource (grafana/provisioning/datasources/datasource.yml)
and dashboard definition (grafana/dashboards/redis_rate_limiter.json).
Run โ http://localhost:3000 โ login admin/admin.
Dashboard Panels:
| Test | Command | Why it works | |
|---|---|---|---|
| Health | curl http://localhost:8001/health |
Confirms Redis โ API link | |
| Atomic Counter | curl http://localhost:8001/counter/incr?key=page |
Redis INCR ensures atomicity |
|
| Stock Transfer | Transfer items AโB | Uses WATCH/MULTI/EXEC for safe multi-key transaction |
|
| Wallet Debit | Lua script debit | One atomic check-then-update | |
| Rate Limiting | Hit /rate/fixed repeatedly |
INCR + TTL window |
|
| Sliding Window | Hit /rate/sliding |
ZSET timestamps for smoother throttling | |
| Token Bucket | Hit /rate/tokenbucket |
Lua atomic refill + consume logic | |
| Metrics | curl [http://localhost:8001/metrics](http://localhost:8001/metrics) | head |
Prometheus-compatible export | |
| Grafana View | localhost:3000 | Live visualization |
/metrics.Grafana queries Prometheus with PromQL expressions like:
sum(rate(requests_total[1m])) by (endpoint)Charts update every few seconds.
Prometheus = collector Grafana = visualizer
Intro (10 s) โToday weโre building a complete, self-contained monitoring lab with Redis, FastAPI, Prometheus, and Grafana.โ
Mid (2 min) โEvery call you make to the API instantly updates Redis โ counters, windows, and buckets โ and Prometheus records those events every few seconds. Grafana visualizes them, turning invisible backend logic into a living dashboard.โ
Outro (20 s) โIn just minutes, youโve built a production-grade rate limiter with real-time observability. Redis keeps your system atomic and fast; Prometheus keeps it measurable; Grafana makes it beautiful. Iโm Sreeprakash from TechieTalksAI โ see you in the next automation deep dive!โ
| Layer | Tool | Role |
|---|---|---|
| Business Logic | FastAPI | Defines endpoints & metrics |
| In-Memory Store | Redis | Atomic operations & rate limits |
| Metrics Collector | Prometheus | Records numeric time-series |
| Visualization | Grafana | Turns metrics into insight |
This consolidated system is your laboratory for API observability โ you can test, tune, and visualize rate limiting in real time.
APIs are the backbone of the internet. Every second, millions of requests hit servers across the world โ fetching data, authenticating users, processing payments, or sending messages.
But what happens when too many requests arrive at once? Your server slows down, fails, or โ worse โ becomes vulnerable to abuse.
Thatโs where Redis shines.
Redis isnโt just a cache โ itโs an in-memory atomic database capable of handling millions of operations per second with microsecond latency. Its atomic commands (INCR, ZADD, Lua scripts) make it perfect for:
This project is your complete A-to-Z Redis lab, containerized for instant exploration.
Youโll learn by building and testing โ every concept backed by a live endpoint and a measurable outcome.
redis-redislim/
โโ docker-compose.yml # orchestrates everything
โโ .env # config for app limits
โโ api/
โ โโ Dockerfile
โ โโ requirements.txt
โ โโ app/
โ โโ main.py # FastAPI endpoints
โ โโ redis_client.py # Redis connection helper
โ โโ ratelimit.py # fixed, sliding, token bucket logic
โ โโ metrics.py # Prometheus counters & histograms
โ โโ __init__.py
โ โโ scripts/load_test.py
โโ prometheus/prometheus.yml
โโ grafana/
โโ provisioning/
โ โโ datasources/datasource.yml
โ โโ dashboards/dashboards.yml
โโ dashboards/redis_rate_limiter.jsonEach piece is modular and self-contained โ no external dependencies other than Docker.
| Component | Role | Port |
|---|---|---|
| FastAPI | Core API service with Prometheus metrics | 8001 |
| Redis | Atomic in-memory data store | 6379 |
| Redis Commander | GUI for live key inspection | 8081 |
| Prometheus | Metrics collection & scraping | 9090 |
| Grafana | Visualization dashboards | 3000 |
INCR, ZADD, or Lua scripts./metrics every 5 seconds to record live data.docker compose up --build.api/app/redis_client.pyCreates a singleton async Redis connection โ safe, fast, reusable.
_redis = Redis.from_url(url, encoding="utf-8", decode_responses=True)Every FastAPI route can depend on get_redis() for a shared client.
api/app/ratelimit.pyImplements three atomic limiters:
Fixed Window โ Uses INCR + EXPIRE.
Simple but allows bursts at the window edges.
Sliding Window โ Uses ZADD and timestamp trimming.
Smooths out bursts by counting recent events only.
Token Bucket โ Uses a Lua script for atomic refill and consumption. Perfect for steady, controlled request pacing.
api/app/metrics.pyDefines Prometheus counters and histograms:
REQUESTS_TOTAL # per endpoint, status, strategy
RATE_LIMITED_TOTAL # per strategy
REQUEST_LATENCY # histogram p50, p90, p99 latencyapi/app/main.py@app.middleware("http").Routes:
/counter/* for atomic increments/stock/* for transactions with WATCH/MULTI/EXEC/wallet/* for Lua debit/rate/* for 3 rate limit strategies/metrics for Prometheus scraping/health for heartbeatprometheus/prometheus.ymlPrometheus scrapes the APIโs /metrics endpoint every 5 seconds.
grafana/ folderAuto-provisions:
Dashboard showing:
From project root:
docker compose up --buildAfter 30 seconds, visit:
| Service | URL | Description |
|---|---|---|
| FastAPI | http://localhost:8001/docs | Interactive Swagger UI |
| Redis Commander | http://localhost:8081 | See Redis keys live |
| Prometheus | http://localhost:9090 | Raw metrics explorer |
| Grafana | http://localhost:3000 | admin / admin |
| Redis | localhost:6379 | optional CLI access |
curl -s http://localhost:8001/health
# โ {"ok": true}Why it works:
get_redis() pings Redis. If Redis is reachable, FastAPI returns ok: true.
curl -s "http://localhost:8001/counter/incr?key=page_views"
curl -s "http://localhost:8001/counter/get?key=page_views"What happens:
INCR increases the value atomically โ no race conditions.GET retrieves it for validation.Why it works:
Redis INCR is single-threaded and atomic โ even under heavy concurrency.
curl -s "http://localhost:8001/stock/seed?item=a&qty=50" -X POST
curl -s "http://localhost:8001/stock/seed?item=b&qty=10" -X POST
curl -s "http://localhost:8001/stock/transfer" \
-H "Content-Type: application/json" \
-d '{"from_item":"a","to_item":"b","qty":7}' -X POSTWhat happens:
WATCH observes both keys.MULTI/EXEC performs DECRBY and INCRBY together.Why it works:
Redis transactions guarantee atomic updates when executed within MULTI/EXEC.
curl -s "http://localhost:8001/wallet/seed?user=sree&amount=100" -X POST
curl -s "http://localhost:8001/wallet/debit?user=sree&amount=30" -X POST
curl -s "http://localhost:8001/wallet/debit?user=sree&amount=100" -X POSTExpected:
Why it works: Lua scripts in Redis run atomically in one thread. The debit logic:
if balance >= amount then
redis.call('DECRBY', key, amount)guarantees no inconsistent balance.
Each limiter uses a composite key:
<X-API-Key>:<client-ip> โ ensuring fair separation of users and IPs.
for i in {1..7}; do
curl -s -H "X-API-Key: demo123" "http://localhost:8001/rate/fixed";
doneExpected:
First 5 allowed, next 2 blocked with allowed: false, retry_in: X.
Why:
INCR counts per 10-second window.
Once limit = 5, remaining resets after expiry.
for i in {1..12}; do
curl -s -H "X-API-Key: demo123" "http://localhost:8001/rate/sliding";
doneExpected: Fewer blocks โ because requests โslideโ off after 10s.
Why:
Uses ZADD timestamps and ZREMRANGEBYSCORE to count active events within the window.
for i in {1..15}; do
curl -s -H "X-API-Key: gold-user" \
"http://localhost:8001/rate/tokenbucket?capacity=10&refill_per_sec=2&cost=1";
done
sleep 3
for i in {1..5}; do
curl -s -H "X-API-Key: gold-user" \
"http://localhost:8001/rate/tokenbucket?capacity=10&refill_per_sec=2&cost=1";
doneExpected:
Why it works: The Lua script atomically updates both token count and timestamp. No race condition โ perfect for multi-instance deployments.
Visit http://localhost:8081 and watch keys appear:
| Strategy | Key Pattern | Type |
|---|---|---|
| Fixed | rl:fixed:<key> |
string |
| Sliding | rl:slide:<key> |
zset |
| Token Bucket | rl:tb:tokens:<key> & rl:tb:ts:<key> |
string |
TTL counts down automatically (10s for fixed/sliding, 2h for token bucket).
Check raw metrics:
curl -s http://localhost:8001/metrics | head -n 20Look for:
requests_total{endpoint="/rate/fixed",status="200",strategy="fixed"} 5.0
rate_limited_total{strategy="tokenbucket"} 2.0
request_latency_seconds_bucket{le="0.5"} 7.0Why it works:
Every request increments counters; FastAPI middleware measures latency and exports via /metrics.
Open Grafana โ http://localhost:3000 โ login admin/admin.
Dashboard: Redis Rate Limiter (API)
Panels:
Why it works: Prometheus scrapes the FastAPI metrics; Grafana queries Prometheus via datasource provisioning.
python api/app/scripts/load_test.py "http://localhost:8001/rate/fixed" 15
python api/app/scripts/load_test.py "http://localhost:8001/rate/sliding" 20
python api/app/scripts/load_test.py "http://localhost:8001/rate/tokenbucket?capacity=10&refill_per_sec=2&cost=1" 20Why it works:
This script sends multiple concurrent requests using aiohttp to test concurrency.
Youโll see OK and BLOCKED logs in terminal, and your Grafana graphs will spike in real-time.
| Concept | Redis Feature | Outcome |
|---|---|---|
| Atomic counter | INCR |
Race-free increments |
| Sliding window | ZADD + score trimming |
Rolling request count |
| Token bucket | Lua + timestamps | Smooth rate control |
| Transactions | WATCH/MULTI/EXEC |
Safe multi-key updates |
| Metrics | Prometheus client | Real-time observability |
| Visualization | Grafana dashboards | Live analytics UI |
Redis acts as your control layer, FastAPI as your business layer, Prometheus as your data layer, and Grafana as your insight layer โ all within Docker.
you got it โ hereโs a clear, file-by-file, line/section-by-line walkthrough of the working project. Iโll keep the explanations tight but complete so you (or your team) can reason about every decision, change things safely, and debug fast.
version: "3.9"services: redis:
image: redis:7-alpine command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"] ports:
- "6379:6379" volumes:
- redisdata:/data healthcheck:
test: ["CMD", "redis-cli", "PING"]
interval: 5s
timeout: 3s
retries: 20 redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"
depends_on:
- redisredis service; waits for Redis. api:
build:
context: ./api
dockerfile: Dockerfileapi/Dockerfile. env_file:
- .env depends_on:
redis:
condition: service_healthy ports:
- "8001:8001" volumes:
- ./api/app:/app/app/app/app. This ensures import path app.main:app works and enables hot-reload. prometheus:
image: prom/prometheus:latest
command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.retention.time=2d"]
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
depends_on:
- api/metrics. Retains 2 days of time-series. grafana:
image: grafana/grafana:latest
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_DEFAULT_THEME=dark
ports:
- "3000:3000"
volumes:
- ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheusvolumes:
redisdata:REDIS_URL=redis://redis:6379/0
APP_HOST=0.0.0.0
APP_PORT=8001
FIXED_WINDOW_LIMIT=5
FIXED_WINDOW_SECONDS=10
SLIDING_WINDOW_LIMIT=8
SLIDING_WINDOW_SECONDS=10redis.FROM python:3.11-slim
WORKDIR /appCOPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txtCOPY app ./appEXPOSE 8001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"]app.main:app (FastAPI instance) with hot-reload.fastapi==0.115.0 # Web framework
uvicorn==0.30.6 # ASGI server
redis==5.0.7 # Async Redis client
pydantic==2.9.2 # Data validation models
aiohttp==3.10.5 # Load-test helper script
prometheus_client==0.20.0 # /metrics exporter# makes "app" a proper packageapp.* imports are resolvable everywhere.from redis.asyncio import Redis
import os
_redis: Redis | None = Noneasync def get_redis() -> Redis:
global _redis
if _redis is None:
url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
_redis = Redis.from_url(url, encoding="utf-8", decode_responses=True)
return _redisdecode_responses=True โ get strings instead of bytes everywhere.Why: connection reuse avoids overhead; async client fits FastAPIโs async routes.
import time
from typing import Tuple
from redis.asyncio import Redisasync def fixed_window_allow(redis: Redis, key: str, limit: int, window_secs: int) -> Tuple[bool, int, int]:
rk = f"rl:fixed:{key}"
async with redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(rk)
exists = await redis.exists(rk)
pipe.multi()
pipe.incr(rk, 1) # atomic counter++
if not exists:
pipe.expire(rk, window_secs) # start TTL on first hit
res = await pipe.execute() # commit tx
new_count = res[0]
ttl = await redis.ttl(rk) # seconds until window resets
allowed = new_count <= limit
remaining = max(limit - new_count, 0)
return allowed, remaining, ttl
except Exception:
continue # on concurrent write conflicts, retry
finally:
await pipe.reset()EXPIRE set and INCR pairing is consistent even under concurrency. (You could do simple INCR + EXPIRE when key doesnโt exist, but WATCH makes the demo robust.)async def sliding_window_allow(redis: Redis, key: str, limit: int, window_secs: int) -> Tuple[bool, int]:
rk = f"rl:slide:{key}"
now_ms = int(time.time() * 1000)
window_start_ms = now_ms - window_secs * 1000
async with redis.pipeline(transaction=True) as pipe:
pipe.zremrangebyscore(rk, 0, window_start_ms) # evict old timestamps
pipe.zadd(rk, {str(now_ms): now_ms}) # add this request
pipe.zcard(rk) # count active
pipe.expire(rk, window_secs * 2) # cleanup if idle later
_, _, count, _ = await pipe.execute()
allowed = count <= limit
remaining = max(limit - count, 0)
return allowed, remainingTOKEN_BUCKET_LUA = """
local tokens_key = KEYS[1]
local ts_key = KEYS[2]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- tokens/sec
local now_ms = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local tokens = tonumber(redis.call('GET', tokens_key))
local last_ts = tonumber(redis.call('GET', ts_key))
if tokens == nil then tokens = capacity end
if last_ts == nil then last_ts = now_ms end
local delta = math.max(0, now_ms - last_ts)
local refill = (delta / 1000.0) * refill_rate
tokens = math.min(capacity, tokens + refill)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('SET', tokens_key, tokens)
redis.call('SET', ts_key, now_ms)
redis.call('PEXPIRE', tokens_key, 7200000) -- 2h
redis.call('PEXPIRE', ts_key, 7200000)
return {allowed, tokens}
"""cost.async def token_bucket_allow(redis: Redis, key: str, capacity: int, refill_per_sec: float, cost: float = 1.0) -> Tuple[bool, float]:
tokens_key = f"rl:tb:tokens:{key}"
ts_key = f"rl:tb:ts:{key}"
now_ms = int(time.time() * 1000)
allowed, tokens = await redis.eval(TOKEN_BUCKET_LUA, 2, tokens_key, ts_key, capacity, refill_per_sec, now_ms, cost)
return bool(allowed), float(tokens)from prometheus_client import Counter, Histogram, CONTENT_TYPE_LATEST, generate_latest
from starlette.responses import ResponseREQUESTS_TOTAL = Counter(
"requests_total", "Total API requests", ["endpoint", "status", "strategy"]
)
RATE_LIMITED_TOTAL = Counter(
"rate_limited_total", "Rate-limited responses", ["strategy"]
)
REQUEST_LATENCY = Histogram(
"request_latency_seconds", "Latency of requests in seconds"
)async def metrics_endpoint():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)/metrics.from fastapi import FastAPI, Depends, Query, Request
from pydantic import BaseModel
import os, time
from typing import Optional
from redis.asyncio import Redis
from .redis_client import get_redis
from .ratelimit import fixed_window_allow, sliding_window_allow, token_bucket_allow
from .metrics import REQUESTS_TOTAL, RATE_LIMITED_TOTAL, REQUEST_LATENCY, metrics_endpointapp = FastAPI(title="Redis A-to-Z: Atomic Ops & Rate Limiting + Metrics")@app.middleware("http")
async def track_latency(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
REQUEST_LATENCY.observe(time.perf_counter() - start)
return response@app.get("/metrics")
async def metrics():
return await metrics_endpoint()@app.get("/health")
async def health(redis: Redis = Depends(get_redis)):
pong = await redis.ping()
return {"ok": pong}def make_composite_id(request: Request, api_key: Optional[str]) -> str:
ip = request.headers.get("x-forwarded-for")
if ip:
ip = ip.split(",")[0].strip()
else:
ip = request.client.host if request.client else "unknown"
key = api_key or "anon"
return f"{key}:{ip}"<apiKey>:<clientIp>; fair multi-tenant throttling.@app.get("/counter/incr")
async def incr_counter(key: str = Query("page_views"), redis: Redis = Depends(get_redis)):
new_val = await redis.incr(key, 1)
REQUESTS_TOTAL.labels(endpoint="/counter/incr", status="200", strategy="na").inc()
return {"key": key, "new_value": new_val}
@app.get("/counter/get")
async def get_counter(key: str = Query("page_views"), redis: Redis = Depends(get_redis)):
val = await redis.get(key)
REQUESTS_TOTAL.labels(endpoint="/counter/get", status="200", strategy="na").inc()
return {"key": key, "value": int(val) if val is not None else 0}REQUESTS_TOTAL.class Transfer(BaseModel):
from_item: str
to_item: str
qty: int@app.post("/stock/seed")
async def seed_stock(item: str = Query(...), qty: int = Query(100), redis: Redis = Depends(get_redis)):
await redis.set(f"stock:{item}", qty)
REQUESTS_TOTAL.labels(endpoint="/stock/seed", status="200", strategy="na").inc()
return {"seeded": {item: qty}}@app.get("/stock/get")
async def get_stock(item: str, redis: Redis = Depends(get_redis)):
v = await redis.get(f"stock:{item}")
REQUESTS_TOTAL.labels(endpoint="/stock/get", status="200", strategy="na").inc()
return {"item": item, "qty": int(v) if v else 0}@app.post("/stock/transfer")
async def transfer_stock(body: Transfer, redis: Redis = Depends(get_redis)):
s_from = f"stock:{body.from_item}"
s_to = f"stock:{body.to_item}"
async with redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(s_from, s_to) # optimistic lock
from_qty_raw = await redis.get(s_from)
from_qty = int(from_qty_raw) if from_qty_raw else 0
if from_qty < body.qty:
await pipe.reset()
REQUESTS_TOTAL.labels(endpoint="/stock/transfer", status="200", strategy="na").inc()
return {"ok": False, "reason": "insufficient stock"}
pipe.multi()
pipe.decrby(s_from, body.qty) # debit A
pipe.incrby(s_to, body.qty) # credit B
await pipe.execute() # commit atomically
break
except Exception:
continue # retry on conflict
finally:
await pipe.reset()
a = int(await redis.get(s_from) or 0)
b = int(await redis.get(s_to) or 0)
REQUESTS_TOTAL.labels(endpoint="/stock/transfer", status="200", strategy="na").inc()
return {"ok": True, "from": {body.from_item: a}, "to": {body.to_item: b}}DEBIT_LUA = """
local key = KEYS[1]
local amount = tonumber(ARGV[1])
local bal = tonumber(redis.call('GET', key) or '0')
if bal >= amount then
redis.call('DECRBY', key, amount)
return {1, bal - amount}
else
return {0, bal}
end
"""@app.post("/wallet/seed")
async def wallet_seed(user: str, amount: int = 100, redis: Redis = Depends(get_redis)):
await redis.set(f"wallet:{user}", amount)
REQUESTS_TOTAL.labels(endpoint="/wallet/seed", status="200", strategy="na").inc()
return {"user": user, "balance": amount}
@app.post("/wallet/debit")
async def wallet_debit(user: str, amount: int, redis: Redis = Depends(get_redis)):
key = f"wallet:{user}"
ok, new_bal = await redis.eval(DEBIT_LUA, 1, key, amount)
REQUESTS_TOTAL.labels(endpoint="/wallet/debit", status="200", strategy="na").inc()
return {"ok": bool(ok), "user": user, "balance": int(new_bal)}All three use composite ID so demo123@1.2.3.4 is separate from demo123@5.6.7.8 or anon@ip.
@app.get("/rate/fixed")
async def rate_fixed(request: Request, user: Optional[str] = None, api_key: Optional[str] = None, redis: Redis = Depends(get_redis)):
limit = int(os.getenv("FIXED_WINDOW_LIMIT", "5"))
secs = int(os.getenv("FIXED_WINDOW_SECONDS", "10"))
key = user or make_composite_id(request, api_key or request.headers.get("x-api-key"))
allowed, remaining, ttl = await fixed_window_allow(redis, key, limit, secs)
status = "200" if allowed else "429"
REQUESTS_TOTAL.labels(endpoint="/rate/fixed", status=status, strategy="fixed").inc()
if not allowed:
RATE_LIMITED_TOTAL.labels(strategy="fixed").inc()
return {"strategy": "fixed", "key": key, "allowed": allowed, "remaining": remaining, "retry_in": ttl, "limit": limit, "window_seconds": secs}status label set to "429" when blocked.@app.get("/rate/sliding")
async def rate_sliding(request: Request, user: Optional[str] = None, api_key: Optional[str] = None, redis: Redis = Depends(get_redis)):
limit = int(os.getenv("SLIDING_WINDOW_LIMIT", "8"))
secs = int(os.getenv("SLIDING_WINDOW_SECONDS", "10"))
key = user or make_composite_id(request, api_key or request.headers.get("x-api-key"))
allowed, remaining = await sliding_window_allow(redis, key, limit, secs)
status = "200" if allowed else "429"
REQUESTS_TOTAL.labels(endpoint="/rate/sliding", status=status, strategy="sliding").inc()
if not allowed:
RATE_LIMITED_TOTAL.labels(strategy="sliding").inc()
return {"strategy": "sliding", "key": key, "allowed": allowed, "remaining": remaining, "limit": limit, "window_seconds": secs}@app.get("/rate/tokenbucket")
async def rate_tokenbucket(
request: Request,
user: Optional[str] = None,
api_key: Optional[str] = None,
capacity: int = Query(10, description="Max tokens"),
refill_per_sec: float = Query(2.0, description="Tokens added per second"),
cost: float = Query(1.0, description="Tokens required per request"),
redis: Redis = Depends(get_redis)
):
key = user or make_composite_id(request, api_key or request.headers.get("x-api-key"))
allowed, tokens_left = await token_bucket_allow(redis, key, capacity, refill_per_sec, cost)
status = "200" if allowed else "429"
REQUESTS_TOTAL.labels(endpoint="/rate/tokenbucket", status=status, strategy="tokenbucket").inc()
if not allowed:
RATE_LIMITED_TOTAL.labels(strategy="tokenbucket").inc()
return {"strategy": "tokenbucket", "key": key, "allowed": allowed, "tokens_left": tokens_left, "capacity": capacity, "refill_per_sec": refill_per_sec, "cost": cost}import asyncio, aiohttp, sysasync def hit(url: str, i: int):
async with aiohttp.ClientSession() as sess:
async with sess.get(url) as r:
js = await r.json()
ok = js.get("allowed")
print(f"{i:02d}: {'OK' if ok else 'BLOCKED'} -> {js}")async def main():
url = sys.argv[1]
n = int(sys.argv[2])
await asyncio.gather(*[hit(url, i) for i in range(n)])if __name__ == "__main__":
asyncio.run(main())global:
scrape_interval: 5sscrape_configs:
- job_name: "api"
static_configs:
- targets: ["api:8001"]
metrics_path: /metricsapiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: trueapiVersion: 1
providers:
- name: 'RateLimiterDashboards'
orgId: 1
folder: ''
type: file
options:
path: /var/lib/grafana/dashboardsDefines panels for:
INCR shows Redis counters never โtearโ.MULTI/EXEC ensures consistent two-key updates (no partial transfers).retry_in shows time to reset.Perfect โ youโve reached the most rewarding stage ๐ โ seeing Redis limits and Prometheus metrics come alive inside Grafana. Letโs go step-by-step so you know exactly how to open Grafana, explore, and interpret each panel.
From your project root:
docker compose up --buildWait until you see these in logs:
grafana-1 | t=... level=info msg="Starting Grafana" ...
api-1 | INFO: Uvicorn running on http://0.0.0.0:8001That means all services (Redis, FastAPI, Prometheus, Grafana) are live.
Open a browser and visit:
Default credentials:
| User | Password |
|---|---|
admin |
admin |
Youโll be prompted to set a new password (optional โ you can skip for local dev).
Grafana should already have Prometheus pre-configured via provisioning, but verify:
You should see Prometheus
http://prometheus:9090If itโs red, click Save & Test once.
If you donโt see it immediately:
grafana/dashboards/redis_rate_limiter.jsonOnce you start hitting API endpoints (with curl, browser, or load_test.py), youโll see live data populate.
Query:
sum(increase(requests_total[5m]))
Shows how many total requests were received in the last 5 minutes across all endpoints.
โก๏ธ Try running:
for i in {1..10}; do curl -s http://localhost:8001/rate/fixed; doneWatch the counter rise.
Query:
sum(increase(rate_limited_total[5m]))
Shows how many requests were throttled (HTTP 429) in the past 5 minutes. Hit an endpoint repeatedly to trigger it:
for i in {1..10}; do curl -s http://localhost:8001/rate/fixed; doneOnce you exceed 5/10 s, youโll see this panel climb.
Query:
sum by (endpoint,status) (rate(requests_total[1m]))
Plots a time series of request rate (requests/sec) per endpoint and HTTP status.
This lets you visually confirm how limits throttle bursts.
Query:
sum by (strategy) (rate(rate_limited_total[1m]))
Shows which strategy (fixed/sliding/tokenbucket) is currently blocking most traffic. Great for comparing fairness between algorithms.
Queries:
histogram_quantile(0.5, sum(rate(request_latency_seconds_bucket[1m])) by (le))
histogram_quantile(0.9, ...)
histogram_quantile(0.99, ...)Displays median, 90th, and 99th percentile response times in seconds. If you simulate heavy load, youโll notice p99 rising slightly.
Top-right corner in Grafana:
This continuously updates your panels as Prometheus scrapes.
Click Explore (compass icon ๐งญ):
requests_total โ press Enter
โ shows all label combinations (endpoint, status, strategy)rate_limited_total โ visualize throttled requests over timerequest_latency_seconds_bucket โ see histogram bucketsYou can clone and tweak panels:
[30s] instead of [1m])endpoint="/rate/tokenbucket")All edits can be saved to a new dashboard or the same one.
| Symptom | Fix | |
|---|---|---|
| Grafana opens but panels empty | Wait 10โ15 s; ensure youโve called API endpoints to generate data. | |
| โData source errorโ | Open Prometheus UI at http://localhost:9090 โ Status > Targets; verify api:8001 is UP. |
|
| No dashboard found | Import the JSON manually from grafana/dashboards/redis_rate_limiter.json. |
|
| All graphs flat = 0 | Check that /metrics is reachable: curl [http://localhost:8001/metrics](http://localhost:8001/metrics) | head. |
Youโll see, in Grafana:
Congratulations ๐ โ youโre now watching Redis, Prometheus, and Grafana in perfect sync!
Perfect ๐ฌ โ hereโs your ready-to-record YouTube walkthrough script (about 2ยฝ minutes). Itโs written as a teleprompter-style narration โ friendly, confident, and perfectly suited for your TechieTalksAI video voiceover.
Title: Redis + FastAPI + Grafana: Watch Rate Limiting Come Alive!
๐๏ธ [Opening โ 0:00-0:20]
โWelcome to TechieTalksAI! In this quick walkthrough, youโll see how our Redis-powered FastAPI application tracks requests, enforces rate limits, and visualizes everything in real time using Prometheus and Grafana โ all running inside Docker.โ
โLetโs open Grafana and see the system in action!โ
๐๏ธ [Step 1 โ Launch Grafana โ 0:20-0:40]
โOnce Docker is up, open your browser and go to localhost:3000.
Login with the default username admin and password admin.
Grafana starts with a pre-configured Prometheus data source that collects all metrics from our FastAPI app automatically.โ
๐๏ธ [Step 2 โ Open the Dashboard โ 0:40-1:00]
โNext, on the left panel, click Dashboards and open โRedis Rate Limiter (API)โ. Youโll see several live panels โ Total Requests, Rate-Limited Requests, Endpoint Metrics, and Latency Percentiles โ all updating in real time as our API runs.โ
๐๏ธ [Step 3 โ Generate Live Data โ 1:00-1:40]
โLetโs create some activity.
In a terminal, run a small curl loop hitting our /rate/fixed endpoint.โ
(show terminal)
for i in {1..10}; do curl -s http://localhost:8001/rate/fixed; doneโWatch what happens โ the Total Requests chart jumps immediately.
When you cross the fixed-window limit, new calls return 429 Too Many Requests.
See that red line in the Rate-Limited chart? Thatโs Redis instantly throttling extra calls!โ
๐๏ธ [Step 4 โ Explore the Dashboard โ 1:40-2:10]
โEach panel tells a story:
๐๏ธ [Step 5 โ Wrap-Up โ 2:10-2:40]
โIn just a few minutes, we built a complete observability loop: FastAPI collects metrics โ Prometheus scrapes them โ Grafana visualizes them. Every spike you see here corresponds to a real Redis atomic operation happening inside Docker!โ
โWant to try it yourself? The full Docker Compose and code are linked below. If you found this demo useful, donโt forget to like, subscribe, and share โ Iโm Sreeprakash from TechieTalksAI โ helping you build smarter systems with AI, automation, and cloud!โ
| Time | Visual |
|---|---|
| 0:00-0:10 | โRedis + FastAPI + Grafana โ Real-time Rate Limiting!โ overlay |
| 0:40 | Screen capture of Grafana login and dashboard list |
| 1:00 | Terminal sending curl requests |
| 1:20 | Grafana charts animating spikes |
| 2:10 | Split view: Redis Commander + Grafana + terminal |
| 2:35 | โSubscribe to TechieTalksAIโ outro banner |
Excellent question โ Prometheus is the invisible engine that makes your Grafana charts alive. Letโs go through it thoroughly but clearly, step by step, so you understand what Prometheus is, how it fits your Redis-FastAPI system, and how you can browse and query it directly.
Prometheus is an open-source time-series database built specifically for metrics and monitoring.
It doesnโt store logs or traces โ only numeric measurements over time, like:
Every metric is stored with labels (like endpoint="/rate/fixed", status="200", strategy="tokenbucket") that let you query and group data flexibly.
So you can ask questions like:
โHow many
/rate/fixedrequests got 429 errors in the last 5 minutes?โ
Prometheus collects this data by scraping HTTP endpoints โ usually at /metrics โ that return metrics in a simple text format.
In your Docker setup, the pieces connect like this:
FastAPI โ exposes /metrics via prometheus_client
Prometheus โ scrapes /metrics every 5 seconds
Grafana โ queries Prometheus and draws chartsSo Prometheus acts as the data collector and historian โ the middleman between FastAPI and Grafana.
Inside your project you have prometheus/prometheus.yml:
global:
scrape_interval: 5sscrape_configs:
- job_name: "api"
static_configs:
- targets: ["api:8001"]
metrics_path: /metricstargets = the internal Docker host name and port of your FastAPI container.metrics_path = the endpoint Prometheus will call (/metrics).Prometheus automatically reaches out to http://api:8001/metrics every 5 seconds and records every metric name/value it sees.
Your FastAPI app uses prometheus_client and exports these variables:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
requests_total |
Counter | endpoint, status, strategy | Counts total requests per endpoint/status |
rate_limited_total |
Counter | strategy | Counts blocked requests |
request_latency_seconds |
Histogram | none | Tracks request durations in buckets |
Every time a request is handled, the app increments or observes these metrics. Then Prometheus scrapes them and stores a timestamped value like:
requests_total{endpoint="/rate/fixed",status="200",strategy="fixed"} 57
requests_total{endpoint="/rate/fixed",status="429",strategy="fixed"} 8
request_latency_seconds_bucket{le="0.5"} 61You can open its web UI anytime at:
Youโll see a simple search box labeled โExpressionโ.
Try typing these and hitting Execute:
requests_totalLists all request counters and their label combinations.
rate(requests_total[1m])Shows per-second request rate averaged over the last 1 minute.
sum(rate(rate_limited_total[1m]))Aggregates how many requests were blocked per second recently.
histogram_quantile(0.9, sum(rate(request_latency_seconds_bucket[1m])) by (le))Calculates 90th-percentile latency.
Click Status โ Targets (top menu).
You should see:
| Job | Endpoint | State |
|---|---|---|
| api | api:8001/metrics | UP โ |
If itโs DOWN, Prometheus couldnโt reach the FastAPI service โ fix networking or the endpoint.
(metric_name, labels) โ [timestamp, value][5m], [1h], [1d].For example:
increase(requests_total[5m])โ โHow much did this counter increase in the last 5 minutes?โ
Letโs generate some requests:
for i in {1..15}; do curl -s http://localhost:8001/rate/tokenbucket; doneNow in Prometheus UI:
sum(rate(requests_total{strategy="tokenbucket"}[30s]))Youโll see a numeric value โ thatโs the requests per second for tokenbucket endpoint, measured over the last 30 seconds.
| Feature | Benefit |
|---|---|
| Pull-based | No need to push metrics; Prometheus fetches them safely. |
| Time-series DB | Perfect for โhow has this changed over time?โ |
| Labels system | Lets you drill down and aggregate with precision. |
| Alerting rules | You can define alerts like โtoo many 429s per minuteโ. |
| Integrates with Grafana | Makes data visually clear for analysis and demos. |
In your system, Prometheus makes Redisโs real-time behavior measurable and explainable.
Without it, Grafana would have no live data โ Redis operations would remain a black box.
/metrics using prometheus_client.localhost:9090.Perfect ๐๏ธ Hereโs your 1-minute TechieTalksAI voice-over script explaining Prometheus โ short, sharp, visual, and fits naturally as a mid-section segment inside your Redis + Grafana video.
๐๏ธ [Start โ 0:00 โ 0:10] โNow letโs talk about the silent brain behind these live charts โ Prometheus. Itโs the system that continuously collects, stores, and organizes all our API metrics.โ
๐๏ธ [0:10 โ 0:25]
โEvery few seconds, Prometheus scrapes our FastAPI endpoint /metrics,
pulling raw numbers like total requests, rate-limited calls, and latency samples.
Each value gets labeled โ endpoint, status, strategy โ and stored as a time-series,
so we can rewind and see how traffic changed over time.โ
(show the Prometheus UI at localhost:9090 and a quick query like requests_total)
๐๏ธ [0:25 โ 0:40] โIt doesnโt wait for the app to send data โ Prometheus goes and fetches it. That pull-based model makes it super reliable and safe, even if one service crashes or restarts โ Prometheus keeps the history intact.โ
(cut to Grafana graphs updating while Prometheus target list shows โUPโ)
๐๏ธ [0:40 โ 0:55] โGrafana then queries Prometheus using its powerful PromQL language. Those lines and bars you see? Theyโre live math over the Prometheus database โ turning plain metrics into beautiful, readable insights.โ
๐๏ธ [0:55 โ 1:00] โSo next time you see a dashboard spike, remember: Prometheus is the heartbeat recorder of your entire API ecosystem.โ
| Time | Visual |
|---|---|
| 0:05 | Prometheus logo + โTime-Series Database for Metricsโ |
| 0:15 | /metrics endpoint text output scrolling |
| 0:25 | Prometheus web UI query box with requests_total |
| 0:35 | Target list showing โapi:8001 โ UP โ โ |
| 0:45 | Grafana dashboard animating |
| 0:58 | Fade-in tagline: โPrometheus โ Metrics that Matter.โ |
This project demonstrates not just how Redis works โ but why itโs indispensable for modern backends.
You now have:
Use this as your personal Redis lab. Extend it to:
With this, youโve mastered Redis as the brain behind intelligent, observable, and self-protecting APIs.
A fully self-contained, Docker-Compose quickstart that teaches Redis from zero to โI built a rate limiter,โ with runnable code, clear endpoints, and easy curl tests. It includes:
INCR, MULTI/EXEC, and a small Lua script.Learn rate limiting via:
INCR + EXPIRE (simple, a bit bursty)ZADD + ZREMRANGEBYSCORE (fair & precise)redis-redislim/
โโ docker-compose.yml
โโ .env
โโ api/
โ โโ Dockerfile
โ โโ requirements.txt
โ โโ app/
โ โโ main.py
โ โโ redis_client.py
โ โโ ratelimit.py
โ โโ scripts/
โ โโ load_test.pydocker-compose.ymlversion: "3.9"
services:
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "PING"]
interval: 5s
timeout: 3s
retries: 20
redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"
depends_on:
- redis
api:
build:
context: ./api
dockerfile: Dockerfile
env_file:
- .env
depends_on:
redis:
condition: service_healthy
ports:
- "8001:8001"
volumes:
- ./api/app:/app
volumes:
redisdata:.envREDIS_URL=redis://redis:6379/0
APP_HOST=0.0.0.0
APP_PORT=8001
FIXED_WINDOW_LIMIT=5
FIXED_WINDOW_SECONDS=10
SLIDING_WINDOW_LIMIT=8
SLIDING_WINDOW_SECONDS=10api/DockerfileFROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# copy code (compose also binds the folder for hot-reload)
COPY app ./app
EXPOSE 8001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"]api/requirements.txtfastapi==0.115.0
uvicorn==0.30.6
redis==5.0.7
pydantic==2.9.2api/app/redis_client.pyfrom redis.asyncio import Redis
import os
_redis: Redis | None = None
async def get_redis() -> Redis:
global _redis
if _redis is None:
url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
_redis = Redis.from_url(url, encoding="utf-8", decode_responses=True)
return _redisapi/app/ratelimit.pyimport time
from typing import Tuple
from redis.asyncio import Redis
# ---------- Fixed Window (INCR + EXPIRE) ----------
async def fixed_window_allow(redis: Redis, key: str, limit: int, window_secs: int) -> Tuple[bool, int, int]:
"""
Returns: (allowed, remaining, ttl)
Key pattern: rl:fixed:{key}
"""
rk = f"rl:fixed:{key}"
async with redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(rk)
# If key doesn't exist, start a new window
exists = await redis.exists(rk)
pipe.multi()
pipe.incr(rk, 1)
if not exists:
pipe.expire(rk, window_secs)
res = await pipe.execute()
# res[0] is new count, res[1] is expire set/remaining bool possibly None
new_count = res[0]
ttl = await redis.ttl(rk)
allowed = new_count <= limit
remaining = max(limit - new_count, 0)
return allowed, remaining, ttl
except Exception:
# Retry if concurrent modification
continue
finally:
await pipe.reset()
# ---------- Sliding Window (ZSET timestamps) ----------
async def sliding_window_allow(redis: Redis, key: str, limit: int, window_secs: int) -> Tuple[bool, int]:
"""
Returns: (allowed, remaining)
Uses ZSET with timestamps, evicts older-than-window items.
Key pattern: rl:slide:{key}
"""
rk = f"rl:slide:{key}"
now_ms = int(time.time() * 1000)
window_start_ms = now_ms - window_secs * 1000
async with redis.pipeline(transaction=True) as pipe:
# remove old
pipe.zremrangebyscore(rk, 0, window_start_ms)
# add now
pipe.zadd(rk, {str(now_ms): now_ms})
# count
pipe.zcard(rk)
# set an expire so stale keys go away eventually
pipe.expire(rk, window_secs * 2)
_, _, count, _ = await pipe.execute()
allowed = count <= limit
remaining = max(limit - count, 0)
return allowed, remainingapi/app/main.pyfrom fastapi import FastAPI, Depends, Query
from pydantic import BaseModel
import os
from typing import Optional
from redis.asyncio import Redis
from .redis_client import get_redis
from .ratelimit import fixed_window_allow, sliding_window_allow
app = FastAPI(title="Redis A-to-Z: Atomic Ops & Rate Limiting")
@app.get("/health")
async def health(redis: Redis = Depends(get_redis)):
pong = await redis.ping()
return {"ok": pong}
# -------------------------------------------------------
# INCR demo
# -------------------------------------------------------
@app.get("/counter/incr")
async def incr_counter(
key: str = Query("page_views", description="Counter key"),
redis: Redis = Depends(get_redis)
):
new_val = await redis.incr(key, 1) # atomic increment
return {"key": key, "new_value": new_val}
@app.get("/counter/get")
async def get_counter(
key: str = Query("page_views"),
redis: Redis = Depends(get_redis)
):
val = await redis.get(key)
return {"key": key, "value": int(val) if val is not None else 0}
# -------------------------------------------------------
# Atomic stock transfer (MULTI/EXEC)
# -------------------------------------------------------
class Transfer(BaseModel):
from_item: str
to_item: str
qty: int
@app.post("/stock/seed")
async def seed_stock(
item: str = Query(...),
qty: int = Query(100),
redis: Redis = Depends(get_redis)
):
await redis.set(f"stock:{item}", qty)
return {"seeded": {item: qty}}
@app.get("/stock/get")
async def get_stock(
item: str,
redis: Redis = Depends(get_redis)
):
v = await redis.get(f"stock:{item}")
return {"item": item, "qty": int(v) if v else 0}
@app.post("/stock/transfer")
async def transfer_stock(
body: Transfer,
redis: Redis = Depends(get_redis)
):
s_from = f"stock:{body.from_item}"
s_to = f"stock:{body.to_item}"
async with redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(s_from, s_to)
from_qty_raw = await redis.get(s_from)
from_qty = int(from_qty_raw) if from_qty_raw else 0
if from_qty < body.qty:
await pipe.reset()
return {"ok": False, "reason": "insufficient stock"}
pipe.multi()
pipe.decrby(s_from, body.qty)
pipe.incrby(s_to, body.qty)
await pipe.execute()
break
except Exception:
continue
finally:
await pipe.reset()
a = int(await redis.get(s_from) or 0)
b = int(await redis.get(s_to) or 0)
return {"ok": True, "from": {body.from_item: a}, "to": {body.to_item: b}}
# -------------------------------------------------------
# Lua script demo (single-round-trip atomic check-decrement)
# -------------------------------------------------------
DEBIT_LUA = """
local key = KEYS[1]
local amount = tonumber(ARGV[1])
local bal = tonumber(redis.call('GET', key) or '0')
if bal >= amount then
redis.call('DECRBY', key, amount)
return {1, bal - amount}
else
return {0, bal}
end
"""
@app.post("/wallet/seed")
async def wallet_seed(user: str, amount: int = 100, redis: Redis = Depends(get_redis)):
await redis.set(f"wallet:{user}", amount)
return {"user": user, "balance": amount}
@app.post("/wallet/debit")
async def wallet_debit(user: str, amount: int, redis: Redis = Depends(get_redis)):
key = f"wallet:{user}"
ok, new_bal = await redis.eval(DEBIT_LUA, 1, key, amount)
return {"ok": bool(ok), "user": user, "balance": int(new_bal)}
# -------------------------------------------------------
# Fixed-window rate limit
# -------------------------------------------------------
@app.get("/rate/fixed")
async def rate_fixed(
user: str,
redis: Redis = Depends(get_redis)
):
limit = int(os.getenv("FIXED_WINDOW_LIMIT", "5"))
secs = int(os.getenv("FIXED_WINDOW_SECONDS", "10"))
allowed, remaining, ttl = await fixed_window_allow(redis, user, limit, secs)
status = "OK" if allowed else "RATE_LIMITED"
return {"strategy": "fixed", "user": user, "allowed": allowed, "remaining": remaining, "retry_in": ttl, "limit": limit, "window_seconds": secs, "status": status}
# -------------------------------------------------------
# Sliding-window rate limit
# -------------------------------------------------------
@app.get("/rate/sliding")
async def rate_sliding(
user: str,
redis: Redis = Depends(get_redis)
):
limit = int(os.getenv("SLIDING_WINDOW_LIMIT", "8"))
secs = int(os.getenv("SLIDING_WINDOW_SECONDS", "10"))
allowed, remaining = await sliding_window_allow(redis, user, limit, secs)
status = "OK" if allowed else "RATE_LIMITED"
return {"strategy": "sliding", "user": user, "allowed": allowed, "remaining": remaining, "limit": limit, "window_seconds": secs, "status": status}api/app/scripts/load_test.pyimport asyncio
import aiohttp
import sys
# Usage:
# python scripts/load_test.py http://localhost:8001/rate/fixed?user=sree 10
# or:
# python scripts/load_test.py http://localhost:8001/rate/sliding?user=sree 15
async def hit(url: str, i: int):
async with aiohttp.ClientSession() as sess:
async with sess.get(url) as r:
js = await r.json()
ok = js.get("allowed")
print(f"{i:02d}: {'OK' if ok else 'BLOCKED'} -> {js}")
async def main():
url = sys.argv[1]
n = int(sys.argv[2])
await asyncio.gather(*[hit(url, i) for i in range(n)])
if __name__ == "__main__":
asyncio.run(main())Add to requirements for the script (optional): aiohttp==3.10.5. If you want to run the load test inside the API container, add aiohttp to requirements.txt. Otherwise run it from your host Python environment.
# 1) start everything
docker compose up --build
# 2) Open:
# API: http://localhost:8001/docs
# Redis-Commander: http://localhost:8081 (Keys appear live while you test!)curl -s http://localhost:8001/healthcurl -s "http://localhost:8001/counter/incr?key=page_views"
curl -s "http://localhost:8001/counter/get?key=page_views"Watch page_views change in Redis-Commander under key page_views.
curl -s "http://localhost:8001/stock/seed?item=a&qty=50"
curl -s "http://localhost:8001/stock/seed?item=b&qty=10"
curl -s "http://localhost:8001/stock/transfer" -X POST -H "Content-Type: application/json" -d '{"from_item":"a","to_item":"b","qty":7}'
curl -s "http://localhost:8001/stock/get?item=a"
curl -s "http://localhost:8001/stock/get?item=b"This uses watch-retry + multi/exec so concurrent transfers remain correct.
curl -s "http://localhost:8001/wallet/seed?user=sree&amount=100" -X POST
curl -s "http://localhost:8001/wallet/debit?user=sree&amount=30" -X POST
curl -s "http://localhost:8001/wallet/debit?user=sree&amount=100" -X POSTThe second debit may fail if balance insufficient; logic is entirely atomic inside Redis.
# Try ~7 hits quickly; default is limit=5 / 10s
for i in {1..7}; do curl -s "http://localhost:8001/rate/fixed?user=sree" | jq '.status,.remaining,.retry_in'; doneObserve that after ~5 hits in <10s, youโll get RATE_LIMITED until TTL resets.
# Try ~12 hits quickly; default is limit=8 / 10s
for i in {1..12}; do curl -s "http://localhost:8001/rate/sliding?user=sree" | jq '.status,.remaining'; doneRequests become allowed again as timestamps slide out of the last 10 seconds.
From your host:
python api/app/scripts/load_test.py "http://localhost:8001/rate/fixed?user=sree" 12
python api/app/scripts/load_test.py "http://localhost:8001/rate/sliding?user=sree" 15What is Redis? An in-memory data store: ultra-fast reads/writes, supports strings, hashes, sets, sorted sets, streams, bitmaps, etc. Perfect for counters, sessions, caches, queues, leaderboards, and rate limiting.
Why Redis is important:
INCR, DECRBY, SETNX, HSETโฆ are atomic)WATCH / MULTI / EXEC)Atomicity in practice:
INCR is atomic โ never two writers corrupting the counter.WATCH + MULTI/EXEC โ optimistic locking: if a watched key changes, your tx restarts.Fixed window rate limit (simple):
rl:fixed:{user}INCR and set EXPIRE when first seenSliding window (precise):
rl:slide:{user} (ZSET of timestamps)ZREMRANGEBYSCORE (evict old), ZADD (now), ZCARD (size).env)FIXED_WINDOW_LIMIT, FIXED_WINDOW_SECONDSSLIDING_WINDOW_LIMIT, SLIDING_WINDOW_SECONDSEdit .env and rerun docker compose up -d to test different behaviors.
# Stop & remove containers
docker compose down
# Also clear Redis data (persistent volume)
docker volume rm redis-redislim_redisdata
# Then up again
docker compose up --buildINCR, MULTI/EXEC, and Lua solve real problems.Perfect โ hereโs your full educational article (for blog, Medium, or YouTube description) explaining every folder, file, and line in this Redis-based Docker Compose project, including a professional intro and outro.
In modern distributed systems, speed and control are everything. When thousands (or millions) of requests hit your backend each second, you need two things:
This project is your A-to-Z quickstart to master those two capabilities โ built entirely using open source tools and Python:
Weโll not only implement fixed-window, sliding-window, and token bucket rate limiting but also instrument the API for live metrics and visualize them on Grafana.
By the end, youโll see every request counted, throttled, and charted โ from Redis keyspace to Grafana graph.
redis-redislim/
โโ docker-compose.yml
โโ .env
โโ api/
โ โโ Dockerfile
โ โโ requirements.txt
โ โโ app/
โ โโ main.py
โ โโ redis_client.py
โ โโ ratelimit.py
โ โโ metrics.py
โ โโ scripts/
โ โโ load_test.py
โโ prometheus/
โ โโ prometheus.yml
โโ grafana/
โโ provisioning/
โ โโ datasources/datasource.yml
โ โโ dashboards/dashboards.yml
โโ dashboards/redis_rate_limiter.jsondocker-compose.ymlThis is the heart of orchestration, spinning up all services:
version: "3.9"Specifies Compose file format.
services:
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
ports:
- "6379:6379" redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"http://localhost:8081. api:
build:
context: ./api
dockerfile: Dockerfile
env_file:
- .env
ports:
- "8001:8001"/api folder..env.8001. prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"api:8001/metrics every 5s.http://localhost:9090 to query time-series metrics. grafana:
image: grafana/grafana:latest
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
ports:
- "3000:3000"admin / admin.Finally, a shared volume redisdata persists Redis state between runs.
.envContains app configuration:
REDIS_URL=redis://redis:6379/0
APP_HOST=0.0.0.0
APP_PORT=8001
FIXED_WINDOW_LIMIT=5
FIXED_WINDOW_SECONDS=10
SLIDING_WINDOW_LIMIT=8
SLIDING_WINDOW_SECONDS=10api/DockerfileDefines the build for our FastAPI app:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"]Line by line:
api/requirements.txtLists dependencies:
fastapi==0.115.0
uvicorn==0.30.6
redis==5.0.7
pydantic==2.9.2
aiohttp==3.10.5
prometheus_client==0.20.0Each one plays a role:
fastapi โ Web frameworkuvicorn โ ASGI serverredis โ Async Redis clientaiohttp โ Used for load testingprometheus_client โ Metrics exportapi/app/redis_client.pyfrom redis.asyncio import Redis
import os
_redis: Redis | None = None
async def get_redis() -> Redis:
global _redis
if _redis is None:
url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
_redis = Redis.from_url(url, encoding="utf-8", decode_responses=True)
return _redisA singleton async client.
Ensures only one Redis connection per process, automatically configured via .env.
api/app/ratelimit.pyImplements three strategies:
await redis.incr(key)
await redis.expire(key, window_secs)pipe.zadd(rk, {str(now_ms): now_ms})
pipe.zremrangebyscore(rk, 0, window_start_ms)-- Lua pseudocode
tokens = math.min(capacity, tokens + refill)
if tokens >= cost then
tokens = tokens - cost
allowed = 1
endAll functions return (allowed, remaining) or (allowed, tokens_left).
api/app/metrics.pyAdds Prometheus counters:
REQUESTS_TOTAL = Counter("requests_total", "Total API requests", ["endpoint","status","strategy"])
RATE_LIMITED_TOTAL = Counter("rate_limited_total", "Rate-limited responses", ["strategy"])
REQUEST_LATENCY = Histogram("request_latency_seconds", "Latency of requests")Also defines middleware:
class TrackLatency:
async def __call__(self, scope, receive, send):
start = time.perf_counter()
...
REQUEST_LATENCY.observe(duration)Every requestโs latency gets observed automatically.
api/app/main.pyThis is the brain of the system.
from fastapi import FastAPI, Request, Query, Depends
from .ratelimit import fixed_window_allow, sliding_window_allow, token_bucket_allow
from .metrics import REQUESTS_TOTAL, RATE_LIMITED_TOTAL, TrackLatency, metrics_endpointIntegrates all modules.
app.middleware('http')(TrackLatency(app))Captures latency for every request.
/metricsPrometheus scrapes metrics from here.
/counter/*Simple INCR demo for atomic Redis increments.
/stock/*Shows MULTI/EXEC transaction with optimistic locking.
/wallet/*Uses Lua for atomic debit with โcheck-then-update.โ
/rate/*Implements three rate limit endpoints:
/rate/fixed/rate/sliding/rate/tokenbucketEach:
X-API-Key + client IP.def make_composite_id(request: Request, api_key: Optional[str]) -> str:
ip = request.headers.get("x-forwarded-for") or request.client.host
key = api_key or "anon"
return f"{key}:{ip}"This ensures fair limits per user and per IP.
api/app/scripts/load_test.pyAn async load tester:
python api/app/scripts/load_test.py "http://localhost:8001/rate/fixed?user=sree" 15Sends 15 parallel requests to test throttling behavior.
prometheus/prometheus.ymlScrapes metrics:
scrape_configs:
- job_name: "api"
static_configs:
- targets: ["api:8001"]
metrics_path: /metricsPrometheus polls every 5s.
grafana/ folderAuto-provisions Grafana with:
redis_rate_limiter.jsonDashboard panels include:
Accessible at http://localhost:3000.
docker compose up --build| Service | URL | Purpose |
|---|---|---|
| FastAPI | http://localhost:8001/docs | API & Swagger |
| Redis Commander | http://localhost:8081 | View Redis keys |
| Prometheus | http://localhost:9090 | Metrics database |
| Grafana | http://localhost:3000 | Visualization UI |
for i in {1..7}; do curl -s "http://localhost:8001/rate/fixed?user=sree"; donefor i in {1..12}; do curl -s "http://localhost:8001/rate/sliding?user=sree"; donefor i in {1..15}; do curl -s "http://localhost:8001/rate/tokenbucket?capacity=10&refill_per_sec=2&cost=1"; doneThen visit Grafana to watch metrics update live.
Redis provides:
INCR, ZADD, and Lua are single-threaded = race-free.Itโs the go-to backbone for rate limiting, caching, sessions, and leaderboards.
โ Why Redis is ideal for in-memory counters โ How atomicity keeps your state consistent โ 3 styles of rate limiting (fixed, sliding, token bucket) โ How to use Lua for one-shot atomic updates โ How to expose Prometheus metrics from FastAPI โ How to visualize everything in Grafana
APIs are the highways of digital systems. Without control, they get congested, abused, and invisible. This project teaches you how to:
Redis is your traffic cop, FastAPI your entry gate, and Grafana your control tower. Together, they form a production-ready pattern every modern engineer should master.
Use this project as your personal lab โ tweak, break, and rebuild. From here, you can extend it to:
Once you truly see what Redis, Prometheus, and Grafana are doing together โ youโve stepped into the next level of backend engineering. โ๏ธโจ