Module 4: Pub/Sub and FastAPI Integration
redis.asyncio and Connection Pooling
Overview
This is module 4's most important technical capsule from a production perspective. You'll learn to use redis.asyncio correctly — not just "putting an await on everything," but configuring the connection pool with the right parameters, handling reconnection, and sharing the pool across your whole application with the singleton pattern. Without proper pooling, an API with 100 concurrent users can exhaust Redis's connections and go down completely. With proper pooling, you handle thousands of concurrent requests with 50 reused physical connections.
Conceptually it's simple: a connection pool is a pre-created group of connections your application reuses. Instead of opening a new connection for every operation (with its round-trip of TCP handshake + auth), you take one from the pool, use it, and give it back. Under the hood, redis-py does this automatically when you create a client — but the defaults are conservative, and you need to tune them for your use case. An app with 4 workers and 1000 default connections = 4000 connections against Redis (which has a default limit of 10,000).
You'll cover how to configure max_connections appropriately, what to do when the pool is exhausted (ConnectionError: max number of clients reached), how to implement automatic reconnection for resilience, and the singleton pattern so your whole application shares the same pool. By the end you'll have the technical scaffolding that capsule 04 (FastAPI integration) and all of module 5 take for granted.
redis.asyncio: the modern client
The correct import
# ✅ Correct: the modern client (redis-py 4.2+, 5.x)
from redis.asyncio import Redis, ConnectionPool
# ❌ Incorrect: a library deprecated since 2021
import aioredis
redis.asyncio is a module inside redis-py. It isn't a separate library. If your pip show redis shows version >= 4.2, you already have redis.asyncio available.
An API identical to the sync one — you just add await
import asyncio
from redis.asyncio import Redis
async def main():
r = Redis(host='localhost', port=6379, decode_responses=True)
# Every operation is the same — just with await
await r.set("key", "value")
value = await r.get("key")
print(value)
# Hashes
await r.hset("user:1", mapping={"name": "Alice", "age": "30"})
profile = await r.hgetall("user:1")
print(profile)
# Sorted sets
await r.zadd("leaderboard", {"alice": 1500, "bob": 2300})
top = await r.zrevrange("leaderboard", 0, 4, withscores=True)
print(top)
# Pub/Sub (you saw it in capsule 02)
await r.publish("news", "hello")
# Cleanup
await r.aclose()
asyncio.run(main())
There's no new API. Every command from module 1 works identically, just with await.
aclose() vs close()
await r.aclose()— closes the async connection (preferred forredis.asyncio)r.close()(sync) — available but deprecated inredis.asyncio
Use aclose() to avoid warnings.
Async pipelines
Pipelines also work with await:
async with r.pipeline() as pipe:
pipe.set("key1", "v1")
pipe.set("key2", "v2")
pipe.get("key1")
results = await pipe.execute()
print(results) # [True, True, "v1"]
Useful for batch operations without multiple round-trips. Just like the sync version from module 1's capsule 5.
The Connection Pool: why it's critical
The problem without pooling
When you create Redis(...) directly, a pool is created internally — but the defaults are:
max_connections: unlimited (it will create as many as you ask for)
Imagine 1,000 concurrent requests each doing await r.get():
- Without sharing a pool: each
Redis(...)creates its own pool. 1000 instances = 1000 pools = potentially 1000+ connections - With a single unlimited pool: 1000 simultaneous connections to Redis
Redis has a default limit of 10,000 max clients (maxclients in redis.conf). An app with 4 uvicorn workers and 1000 reqs/sec each can saturate Redis fast.
Solving it with max_connections
from redis.asyncio import Redis, ConnectionPool
# A pool with an explicit limit
pool = ConnectionPool(
host='localhost',
port=6379,
db=0,
decode_responses=True,
max_connections=50,
)
r = Redis(connection_pool=pool)
Now a maximum of 50 physical connections are kept in the pool. When 100 requests arrive at the same time:
- The first 50 take connections from the pool
- The next 50 wait briefly until a connection is released
- Total: 50 physical connections, not 100
How many connections you need
The calculation rule:
max_connections = (average_concurrent_requests / requests_per_connection_per_second) × safety_factor
With real numbers:
- The API handles 1000 req/sec
- Each request does 2-3 Redis ops (get + set)
- Each op takes ~1 ms
- One connection can do ~500 ops/sec
- requests_per_connection = 500 / 3 = 166
max_connections = (1000 / 166) × 1.5 (safety) = ~10
For most typical apps, 50-100 max_connections is enough. If you see "pool exhausted" errors, raise it. But max_connections=10000 is ridiculous (you're probably caching badly or you have a leak).
Symptoms of a badly configured pool
A pool that's too small:
redis.exceptions.ConnectionError: Too many connections
It means: a lot of concurrent requests waiting for a connection. The solution: raise max_connections or reduce usage (caching).
A pool that's too large:
redis.exceptions.ConnectionError: max number of clients reached
It means: your app OPENED more connections than Redis accepts. The solution: lower max_connections or raise maxclients in redis.conf.
The singleton pattern for sharing the pool
In a FastAPI application with multiple files, the pool MUST be a singleton. If every module creates its own Redis(...), the pools pile up.
Implementing the singleton
Create redis_client.py:
"""
A singleton for the async Redis client.
"""
from redis.asyncio import Redis, ConnectionPool
_pool: ConnectionPool | None = None
_client: Redis | None = None
def init_pool(
host: str = "localhost",
port: int = 6379,
db: int = 0,
max_connections: int = 50,
socket_timeout: float = 2.0,
socket_connect_timeout: float = 2.0,
):
"""Initializes the global pool (call it once at startup)."""
global _pool, _client
_pool = ConnectionPool(
host=host,
port=port,
db=db,
decode_responses=True,
max_connections=max_connections,
socket_timeout=socket_timeout,
socket_connect_timeout=socket_connect_timeout,
)
_client = Redis(connection_pool=_pool)
def get_redis() -> Redis:
"""Returns the global instance of the Redis client."""
if _client is None:
raise RuntimeError("Redis pool not initialized. Call init_pool() first.")
return _client
async def close_pool():
"""Closes the pool when the app shuts down."""
global _pool, _client
if _client:
await _client.aclose()
_client = None
if _pool:
await _pool.aclose()
_pool = None
Using it from other modules
# auth.py
from redis_client import get_redis
async def create_session(user_id: int):
r = get_redis() # the same pool, the same client
await r.hset(f"session:{...}", ...)
# rate_limiter.py
from redis_client import get_redis
async def check_rate_limit(user_id: str):
r = get_redis() # the SAME pool — no extra overhead
# ...
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from redis_client import init_pool, close_pool
@asynccontextmanager
async def lifespan(app: FastAPI):
init_pool(max_connections=50)
yield
await close_pool()
app = FastAPI(lifespan=lifespan)
The singleton's advantages
- A single connection pool for the whole app — efficient
- A clean shutdown — no connection leaks
- Easy to test — you mock
get_redis()in tests - Centralized configuration — a single place to change the parameters
A connection pool with a URL
In production, you configure Redis with a URL (from environment variables). redis-py accepts URLs:
from redis.asyncio import ConnectionPool, Redis
import os
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
# Create the pool from a URL
pool = ConnectionPool.from_url(
REDIS_URL,
max_connections=50,
decode_responses=True,
)
r = Redis(connection_pool=pool)
URL formats
redis://localhost:6379/0 # local, no auth
redis://default:password@redis-host:6379/0 # with a password
rediss://default:password@redis-host:6379/0 # with TLS (SSL)
redis://localhost:6379/0?max_connections=100 # query params
In the cloud, typically:
# AWS ElastiCache
REDIS_URL="redis://my-cluster.abc123.use1.cache.amazonaws.com:6379"
# Upstash
REDIS_URL="rediss://default:abc123@my-redis.upstash.io:6379"
# Redis Cloud
REDIS_URL="redis://default:abc123@redis-12345.cloud.redislabs.com:12345"
from_url() parses the format automatically.
Health checks
In production, verify that Redis is available. This catches problems before they affect users.
async def check_redis_health() -> dict:
"""A health check for Redis. It returns a detailed status."""
r = get_redis()
try:
# Ping with a timeout
result = await asyncio.wait_for(r.ping(), timeout=1.0)
if result:
# Check additional info
info = await r.info("server")
return {
"status": "healthy",
"version": info.get("redis_version"),
"uptime_seconds": info.get("uptime_in_seconds"),
}
return {"status": "unhealthy", "reason": "ping returned False"}
except asyncio.TimeoutError:
return {"status": "unhealthy", "reason": "ping timeout"}
except Exception as e:
return {"status": "unhealthy", "reason": str(e)}
A health check endpoint in FastAPI
@app.get("/health")
async def health():
redis_health = await check_redis_health()
overall = "healthy" if redis_health["status"] == "healthy" else "degraded"
return {
"status": overall,
"services": {
"redis": redis_health,
}
}
Health checks in production
Your orchestrator (Kubernetes, ECS, etc.) polls /health periodically. If it returns a 503, the orchestrator can:
- Restart the container
- Pull the container out of the load balancer
- Alert the team
@app.get("/health")
async def health():
redis_health = await check_redis_health()
if redis_health["status"] != "healthy":
# Return a 503 so the orchestrator detects it
raise HTTPException(503, "Redis unavailable")
return {"status": "healthy"}
Automatic reconnection
If Redis is temporarily down, your pool will try to reconnect automatically. But there are settings that make this behavior more resilient.
Retry on timeout
from redis.backoff import ExponentialBackoff
from redis.asyncio.retry import Retry
pool = ConnectionPool.from_url(
REDIS_URL,
max_connections=50,
retry=Retry(ExponentialBackoff(cap=10, base=1), 3), # 3 retries with backoff
retry_on_timeout=True,
)
If an operation fails with a timeout:
- It waits 1 second, retries
- It waits 2 seconds, retries
- It waits 4 seconds, retries
- If all 3 fail, it raises
The pool's health check
pool = ConnectionPool.from_url(
REDIS_URL,
max_connections=50,
health_check_interval=30, # ping every 30s to keep the connections alive
)
health_check_interval=30 makes every idle connection in the pool get "pinged" every 30 seconds. It detects and discards dead connections (e.g., NAT timeouts in the cloud).
Graceful degradation
When Redis fails, your app should degrade gracefully — not die.
import logging
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
async def get_cached_or_fallback(key: str, fallback_fn):
"""
Tries the cache; if Redis fails, it goes to the fallback (the DB) silently.
"""
r = get_redis()
try:
cached = await r.get(key)
if cached:
return json.loads(cached)
except RedisError as e:
logger.warning(f"Redis read failed for {key}: {e}")
# No raise — degrade to the fallback
# A cache miss or Redis is down: go to the source
data = await fallback_fn()
try:
await r.set(key, json.dumps(data), ex=300)
except RedisError as e:
logger.warning(f"Redis write failed for {key}: {e}")
# No raise — the data is returned anyway
return data
The pattern in endpoints
@app.get("/products/{id}")
async def get_product(id: int):
return await get_cached_or_fallback(
key=f"product:{id}",
fallback_fn=lambda: db.get_product(id),
)
If Redis goes down:
- ✗ The latency climbs (every request goes to the DB)
- ✓ The API KEEPS WORKING
The logs show warnings, but the user doesn't see errors.
Testing the pool under concurrency
Let's verify the pool works under real load.
"""
Test: 200 concurrent requests with max_connections=10.
If the pool works, they all finish successfully.
"""
import asyncio
import time
from redis.asyncio import Redis, ConnectionPool
async def make_request(r, i):
try:
await r.set(f"test:{i}", f"value-{i}")
v = await r.get(f"test:{i}")
return v == f"value-{i}"
except Exception as e:
return f"error: {e}"
async def main():
pool = ConnectionPool(
host='localhost',
port=6379,
decode_responses=True,
max_connections=10, # only 10!
)
r = Redis(connection_pool=pool)
# 200 concurrent requests with only 10 connections
start = time.time()
tasks = [make_request(r, i) for i in range(200)]
results = await asyncio.gather(*tasks)
elapsed = time.time() - start
success = sum(1 for r in results if r is True)
errors = [r for r in results if r is not True]
print(f"Total: 200 requests")
print(f"Success: {success}")
print(f"Errors: {len(errors)}")
print(f"Time: {elapsed:.2f}s")
if errors:
print(f"First error: {errors[0]}")
await r.aclose()
await pool.aclose()
asyncio.run(main())
Expected output:
Total: 200 requests
Success: 200
Errors: 0
Time: 0.45s
The 200 requests ran successfully with only 10 physical connections. The pool handles the concurrency transparently.
Decode responses: when to and when not to
# decode_responses=True (the recommended default)
r = Redis(decode_responses=True)
v = await r.get("key") # str
# decode_responses=False (rare, for special cases)
r = Redis(decode_responses=False)
v = await r.get("key") # bytes
Use decode_responses=True for:
- Normal strings
- Serialized JSON
- Numbers
- Any text
Use decode_responses=False for:
- Binary data (files, images)
- Pickle bytes
- Compression (gzip, etc.)
For 99% of cases, decode_responses=True is what you want.
Troubleshooting
Problem 1: RuntimeError: This event loop is already running
Cause: You're calling asyncio.run() inside a context that already has an event loop (Jupyter, a misconfigured FastAPI test client).
Solution:
# In Jupyter:
import nest_asyncio
nest_asyncio.apply()
await main() # await directly, not asyncio.run()
Problem 2: ConnectionError: Too many connections
Cause: Your app exceeds max_connections or Redis's maxclients.
Solution:
-
Raise the pool's
max_connections:pool = ConnectionPool(..., max_connections=100) -
Check Redis's
maxclients:redis-cli CONFIG GET maxclients # 1) "maxclients" # 2) "10000" -
Check the current connections:
redis-cli INFO clients | grep connected_clients # connected_clients:5 -
If you have a leak (more connections than expected), audit it:
redis-cli CLIENT LIST
Problem 3: The pool works locally but fails in production
Cause: Network latency. On localhost: 0.1 ms. In the cloud: 1-50 ms.
Solution:
-
Raise the timeouts:
pool = ConnectionPool( socket_timeout=5.0, # more tolerant (the default is 2) socket_connect_timeout=5.0, ) -
A health check interval:
pool = ConnectionPool(health_check_interval=30) -
Consider a Redis local to the server or a regional cluster to cut latency
Problem 4: An await r.aclose() warning
Cause: You're using r.close() (sync) instead of await r.aclose() (async).
Solution:
# ❌ Wrong
r.close()
# ✅ Right
await r.aclose()
Problem 5: The pool works in one test but not another
Cause: The singleton isn't initialized between tests.
Solution: Initialize the pool in a shared fixture:
import pytest
@pytest.fixture(scope="session")
async def redis_client():
init_pool()
yield get_redis()
await close_pool()
@pytest.mark.asyncio
async def test_something(redis_client):
await redis_client.set("test", "value")
# ...
Problem 6: Connections that close after minutes without use
Cause: A NAT timeout (in the cloud), firewalls, or an idle connection timeout in Redis.
Solution:
pool = ConnectionPool(
socket_keepalive=True,
health_check_interval=30, # ping idle connections
)
Exercises
Exercise 1: A basic async client (Easy)
Write a script that connects with redis.asyncio, does set/get/hset/hgetall/zadd/zrange, and shuts down cleanly with aclose().
See solution
import asyncio
from redis.asyncio import Redis
async def main():
r = Redis(host='localhost', port=6379, decode_responses=True)
# Verify the connection
pong = await r.ping()
print(f"Ping: {pong}")
# String ops
await r.set("greeting", "hello")
print(f"Get: {await r.get('greeting')}")
# Hash ops
await r.hset("user:1", mapping={"name": "Alice", "age": "30"})
print(f"Hash: {await r.hgetall('user:1')}")
# Sorted set ops
await r.zadd("scores", {"alice": 100, "bob": 200, "charlie": 150})
top = await r.zrevrange("scores", 0, -1, withscores=True)
print(f"Top: {top}")
# Cleanup
await r.aclose()
asyncio.run(main())
Output:
Ping: True
Get: hello
Hash: {'name': 'Alice', 'age': '30'}
Top: [('bob', 200.0), ('charlie', 150.0), ('alice', 100.0)]
Exercise 2: A pool with max_connections=5 (Medium)
Create a pool with max_connections=5 and fire 50 concurrent requests. Verify all 50 finish successfully (the pool handles the queueing).
See solution
import asyncio
import time
from redis.asyncio import Redis, ConnectionPool
async def operation(r, i):
await r.set(f"key:{i}", f"value-{i}")
val = await r.get(f"key:{i}")
return val == f"value-{i}"
async def main():
pool = ConnectionPool(
host='localhost',
port=6379,
decode_responses=True,
max_connections=5,
)
r = Redis(connection_pool=pool)
start = time.time()
tasks = [operation(r, i) for i in range(50)]
results = await asyncio.gather(*tasks)
elapsed = time.time() - start
print(f"Successful: {sum(results)}/50")
print(f"Time: {elapsed:.2f}s with max_connections=5")
await r.aclose()
await pool.aclose()
asyncio.run(main())
Output:
Successful: 50/50
Time: 0.15s with max_connections=5
Explanation: the 50 requests queue up in groups of 5. The pool works transparently.
Exercise 3: The singleton pattern (Medium)
Implement the redis_client.py singleton (init_pool, get_redis, close_pool). Create 3 files (auth.py, rate.py, cache.py) that use get_redis(). Verify they all share the same client.
See solution
# redis_client.py
from redis.asyncio import Redis, ConnectionPool
_pool: ConnectionPool | None = None
_client: Redis | None = None
def init_pool(host="localhost", port=6379, max_connections=50):
global _pool, _client
_pool = ConnectionPool(
host=host, port=port, decode_responses=True,
max_connections=max_connections,
)
_client = Redis(connection_pool=_pool)
def get_redis() -> Redis:
if _client is None:
raise RuntimeError("Pool not initialized")
return _client
async def close_pool():
global _pool, _client
if _client:
await _client.aclose()
if _pool:
await _pool.aclose()
_pool = None
_client = None
# auth.py
from redis_client import get_redis
async def auth_op():
r = get_redis()
await r.set("auth:test", "ok")
print(f"Auth uses client id: {id(r)}")
# rate.py
from redis_client import get_redis
async def rate_op():
r = get_redis()
await r.set("rate:test", "ok")
print(f"Rate uses client id: {id(r)}")
# cache.py
from redis_client import get_redis
async def cache_op():
r = get_redis()
await r.set("cache:test", "ok")
print(f"Cache uses client id: {id(r)}")
# main.py
import asyncio
from redis_client import init_pool, close_pool, get_redis
from auth import auth_op
from rate import rate_op
from cache import cache_op
async def main():
init_pool(max_connections=20)
# All use same client
await auth_op()
await rate_op()
await cache_op()
# Verify it's the same one
r1 = get_redis()
r2 = get_redis()
print(f"\nSame client: {r1 is r2}")
print(f"Pool max_connections: {r1.connection_pool.max_connections}")
await close_pool()
asyncio.run(main())
Output:
Auth uses client id: 4378298992
Rate uses client id: 4378298992
Cache uses client id: 4378298992
Same client: True
Pool max_connections: 20
Exercise 4: A health check endpoint (Medium)
Implement check_redis_health() that returns the status with version and uptime info. Integrate it into a FastAPI /health endpoint. Test it with Redis up and down.
See solution
# health.py
import asyncio
from redis.asyncio import Redis
from redis.exceptions import RedisError
async def check_redis_health(r: Redis) -> dict:
try:
result = await asyncio.wait_for(r.ping(), timeout=1.0)
if not result:
return {"status": "unhealthy", "reason": "ping returned False"}
info = await r.info("server")
return {
"status": "healthy",
"version": info.get("redis_version"),
"uptime_seconds": info.get("uptime_in_seconds"),
"connected_clients": (await r.info("clients")).get("connected_clients"),
}
except asyncio.TimeoutError:
return {"status": "unhealthy", "reason": "ping timeout"}
except RedisError as e:
return {"status": "unhealthy", "reason": f"redis error: {e}"}
except Exception as e:
return {"status": "unhealthy", "reason": f"unknown: {e}"}
# app.py
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from redis_client import init_pool, close_pool, get_redis
from health import check_redis_health
@asynccontextmanager
async def lifespan(app: FastAPI):
init_pool()
yield
await close_pool()
app = FastAPI(lifespan=lifespan)
@app.get("/health")
async def health():
redis_status = await check_redis_health(get_redis())
if redis_status["status"] != "healthy":
raise HTTPException(503, detail={"redis": redis_status})
return {
"status": "healthy",
"services": {"redis": redis_status},
}
The test:
uvicorn app:app --reload &
sleep 2
curl -s http://localhost:8000/health | jq
# {
# "status": "healthy",
# "services": {
# "redis": {
# "status": "healthy",
# "version": "7.2.4",
# "uptime_seconds": 12345,
# "connected_clients": 1
# }
# }
# }
# Stop Redis
docker stop redis-dev
curl -s http://localhost:8000/health
# 503 Service Unavailable
# {"detail":{"redis":{"status":"unhealthy","reason":"ping timeout"}}}
docker start redis-dev
Exercise 5: Graceful degradation (Medium-Hard)
Implement get_cached_or_fallback(key, fallback_fn) that uses Redis when it's up and falls back when Redis fails. Test: stop Redis, and verify the endpoint keeps working (more slowly).
See solution
import json
import logging
from redis.exceptions import RedisError
from redis_client import get_redis
logger = logging.getLogger(__name__)
async def get_cached_or_fallback(key: str, fallback_fn, ttl: int = 300):
r = get_redis()
# Try cache
try:
cached = await r.get(key)
if cached:
return json.loads(cached)
except RedisError as e:
logger.warning(f"Redis read failed for {key}: {e}")
# Continue to fallback
# Cache miss or Redis down: fallback
data = await fallback_fn()
# Try caching (best-effort)
try:
await r.set(key, json.dumps(data), ex=ttl)
except RedisError as e:
logger.warning(f"Redis write failed for {key}: {e}")
# No raise
return data
# Test
import asyncio
from redis_client import init_pool, close_pool
# We simulate the DB
async def db_get_product(pid):
print(f" [DB query for product:{pid}]")
await asyncio.sleep(0.05) # simulate DB latency
return {"id": pid, "name": f"Product {pid}", "price": 100 + pid}
async def main():
init_pool()
# 1. With Redis up
print("=== Redis UP ===")
p1 = await get_cached_or_fallback(
"product:1",
lambda: db_get_product(1),
)
print(f" Result: {p1}")
# A second call: a cache hit
p1_again = await get_cached_or_fallback(
"product:1",
lambda: db_get_product(1),
)
print(f" Result (cached): {p1_again}")
# 2. Stop Redis by hand:
# docker stop redis-dev
print("\n=== After stopping Redis (do it now) ===")
print("Press Enter when Redis is down...")
input()
p2 = await get_cached_or_fallback(
"product:2",
lambda: db_get_product(2),
)
print(f" Result (Redis down): {p2}")
# Notice: the call SUCCEEDED even with Redis down
await close_pool()
asyncio.run(main())
Output:
=== Redis UP ===
[DB query for product:1]
Result: {'id': 1, 'name': 'Product 1', 'price': 101}
Result (cached): {'id': 1, 'name': 'Product 1', 'price': 101}
=== After stopping Redis ===
Press Enter when Redis is down...
[Enter]
WARNING: Redis read failed for product:2: Connection refused
[DB query for product:2]
WARNING: Redis write failed for product:2: Connection refused
Result (Redis down): {'id': 2, 'name': 'Product 2', 'price': 102}
The critical part: product:2 was fetched correctly even though Redis was down. The app degraded gracefully.
Exercise 6: Pool exhaustion (Hard)
Create a pool with max_connections=2. Fire 100 concurrent requests, each doing 100 ms of operations. Measure the total time. Compare with max_connections=50.
See solution
import asyncio
import time
from redis.asyncio import Redis, ConnectionPool
async def slow_op(r, i):
"""A slow operation: a pipeline + a simulated sleep."""
await r.set(f"slow:{i}", f"data-{i}")
await asyncio.sleep(0.1) # 100ms work
val = await r.get(f"slow:{i}")
return val == f"data-{i}"
async def benchmark(max_connections):
pool = ConnectionPool(
host='localhost',
port=6379,
decode_responses=True,
max_connections=max_connections,
)
r = Redis(connection_pool=pool)
start = time.time()
tasks = [slow_op(r, i) for i in range(100)]
await asyncio.gather(*tasks)
elapsed = time.time() - start
await r.aclose()
await pool.aclose()
return elapsed
async def main():
# With a very small pool
print("Benchmark with max_connections=2...")
t1 = await benchmark(2)
print(f" Time: {t1:.2f}s")
# With a reasonable pool
print("\nBenchmark with max_connections=50...")
t2 = await benchmark(50)
print(f" Time: {t2:.2f}s")
print(f"\nSpeedup: {t1/t2:.1f}x with the bigger pool")
asyncio.run(main())
Expected output:
Benchmark with max_connections=2...
Time: 5.50s
Benchmark with max_connections=50...
Time: 0.45s
Speedup: 12.2x with the bigger pool
Analysis:
- With
max_connections=2: only 2 ops run concurrently. 100 ops × 100 ms / 2 = 5 seconds. - With
max_connections=50: 50 ops concurrently. 100 ops × 100 ms / 50 = 0.2s + overhead = 0.45s. - A ~12x speedup.
The rule: size the pool for your concurrent load. A default of 50 is reasonable for medium-sized apps. If your p95 latency on Redis operations is high and you profile connection contention, raise it.
Summary
In this capsule you learned:
redis.asyncio:
- The modern client (redis-py 4.2+)
- An API identical to the sync one, with
await aclose()instead ofclose()- Pipelines and every operation work asynchronously
The Connection Pool:
- It reuses connections efficiently
max_connectionstunable to the expected concurrency- Shared across the whole app with the singleton pattern
- A common error: a pool that's too small = "Too many connections"
The singleton pattern:
init_pool()on startup,close_pool()on shutdownget_redis()returns the shared client- Centralized configuration
- Easy to test
Advanced configuration:
from_url()for configuration from environment variableshealth_check_intervalto ping idle connectionssocket_keepalivefor long-lived connectionsRetrywithExponentialBackofffor resilience
Health checks:
r.ping()to verify connectivity- A
/healthendpoint that reports a detailed status - A 503 status code if Redis is down (so the orchestrator detects it)
Graceful degradation:
- Try/except around Redis operations
- A fallback to the DB when Redis fails
- Logging warnings without crashing the API
- The app works (more slowly) without Redis
The 2026 stack: redis>=7.4, redis.asyncio.Redis, redis.asyncio.ConnectionPool — NOT the deprecated aioredis.
Additional resources
- redis.asyncio API Reference — Official docs with examples
- Connection Pooling in Production — Best practices from Redis Labs
- redis-py Migration from aioredis — The official migration guide
- FastAPI Lifespan Events — The pattern for initializing resources
- The Twelve-Factor App: Config — Config via environment variables
- Redis Best Practices: Connection Management — The official recommendations
What's next?
In Capsule 04 you integrate everything you've learned into FastAPI professionally: dependency injection with Depends(get_redis), lifespan events, automatic caching middleware that intercepts and caches responses, and full graceful degradation. It's the integration pattern module 5 (the capstone project) assumes from day 1.
Keep Redis running. Let's go.