Cache Fallback: Graceful Degradation When Cache Is Unavailable
In this tutorial, you will learn about Cache Fallback: Graceful Degradation When Cache Is Unavailable. We cover key concepts, practical examples, and best practices to help you master this topic.
Cache fallback strategies ensure applications remain functional when the cache layer is unavailable by gracefully degrading to database queries, serving stale cached data, or providing reduced-functionality responses while cache recovery is underway.
flowchart TD
Request --> TryCache{Try Cache}
TryCache -->|Available| Cache[Read Cache]
TryCache -->|Unavailable| Decision{Fallback Strategy}
Decision --> Database[Query Database Directly]
Decision --> Stale[Serve Stale Cached Data]
Decision --> Degraded[Return Degraded Response]
Database --> Response
Stale --> Response
Degraded --> Response
What You'll Learn
- Database fallback with cache-aside population
- Stale-serve pattern for tolerance of short cache outages
- Degraded mode responses for graceful UX degradation
- Cache-warming-after-fallback for recovery
Why It Matters
Without fallback logic, a 5-second Redis outage becomes a 5-second application outage — every request blocks on the cache timeout. With fallback, requests are served from the database (slightly slower but functional) and the cache repopulates automatically when Redis recovers.
Real-World Use
DodaTech's API has a three-level fallback for its Redis cache: (1) try Redis with 100ms timeout, (2) on timeout, query the database directly with a 1-second timeout, (3) on database timeout, serve from a local in-memory stale cache (populated from the last successful Redis read). This chain ensures the API never returns an error for user-facing requests.
Database Fallback with Cache Repopulation
Cache-aside with automatic fallback and repopulation:
import redis
import time
import json
r = redis.Redis(decode_responses=True)
class FallbackCache:
def __init__(self, redis_client, cache_timeout=2, db_timeout=5):
self.r = redis_client
self.cache_timeout = cache_timeout
self.db_timeout = db_timeout
self.stats = {"cache_hits": 0, "fallback_db": 0, "errors": 0}
def get(self, key, fetch_fn):
"""Get with automatic database fallback on cache failure."""
try:
cached = self.r.get(key)
if cached is not None:
self.stats["cache_hits"] += 1
return {"source": "cache", "data": json.loads(cached)}
except (redis.ConnectionError, redis.TimeoutError) as e:
self.stats["fallback_db"] += 1
return self._db_fallback(key, fetch_fn)
self.stats["fallback_db"] += 1
return self._db_fallback(key, fetch_fn)
def _db_fallback(self, key, fetch_fn):
"""Fallback to database and optionally repopulate cache."""
try:
data = fetch_fn(key)
self._async_cache_repopulate(key, data)
return {"source": "db", "data": data}
except Exception as e:
self.stats["errors"] += 1
return {"source": "error", "error": str(e)}
def _async_cache_repopulate(self, key, data):
"""Asynchronously repopulate the cache after DB fallback."""
try:
self.r.setex(key, 3600, json.dumps(data))
except redis.ConnectionError:
pass
def report(self):
"""Generate fallback statistics."""
total = sum(self.stats.values())
return {
**self.stats,
"cache_hit_rate": round(self.stats["cache_hits"] / total * 100, 1) if total else 0,
"fallback_rate": round(self.stats["fallback_db"] / total * 100, 1) if total else 0,
}
cache = FallbackCache(r)
def fetch_user(user_id):
time.sleep(0.01)
return {"id": user_id, "name": f"User {user_id}", "source": "database"}
r.setex("fb:user:1", 3600, json.dumps({"id": 1, "name": "Alice", "source": "cache"}))
result = cache.get("fb:user:1", lambda: fetch_user(1))
print(f"Cache hit: source={result['source']}, data={result['data']['name']}")
result = cache.get("fb:user:miss", lambda: fetch_user(99))
print(f"Cache miss -> DB: source={result['source']}, data={result['data']['name']}")
print(f"\nStats: {cache.report()}")
Expected output:
Cache hit: source=cache, data=Alice
Cache miss -> DB: source=db, data=User 99
Stats: {'cache_hits': 1, 'fallback_db': 1, 'errors': 0, 'cache_hit_rate': 50.0, 'fallback_rate': 50.0}
Stale Cache Serving
Serve stale data when the cache is reachable but the origin is not:
import redis
import time
import json
r = redis.Redis(decode_responses=True)
class StaleCache:
def __init__(self, redis_client):
self.r = redis_client
def get_with_stale(self, key, fetch_fn, stale_ttl=86400):
"""Try fresh data first, fall back to stale on failure."""
fresh_ttl = self.r.ttl(key)
if fresh_ttl > 60:
cached = self.r.get(key)
if cached:
return {"source": "fresh", "data": json.loads(cached), "ttl": fresh_ttl}
try:
data = fetch_fn(key)
self.r.setex(key, 3600, json.dumps(data))
self.r.setex(f"{key}:stale", stale_ttl, json.dumps(data))
return {"source": "fresh", "data": data, "ttl": 3600}
except Exception as e:
stale_data = self.r.get(f"{key}:stale")
if stale_data:
return {"source": "stale", "data": json.loads(stale_data), "ttl": 0}
return {"source": "error", "error": str(e)}
def set_stale_marker(self, key, data, fresh_ttl=3600, stale_ttl=86400):
"""Set both fresh and stale cache entries."""
self.r.setex(key, fresh_ttl, json.dumps(data))
self.r.setex(f"{key}:stale", stale_ttl, json.dumps(data))
return {"fresh_ttl": fresh_ttl, "stale_ttl": stale_ttl}
stale_cache = StaleCache(r)
def fetch_weather(city):
time.sleep(0.01)
return {"city": city, "temp": 72, "condition": "sunny", "timestamp": time.time()}
stale_cache.set_stale_marker("weather:nyc",
{"city": "NYC", "temp": 70, "condition": "stale", "timestamp": time.time() - 3600},
fresh_ttl=10, stale_ttl=86400
)
result = stale_cache.get_with_stale("weather:nyc", lambda: fetch_weather("NYC"))
print(f"Fresh available: source={result['source']}, temp={result['data']['temp']}")
time.sleep(12)
result = stale_cache.get_with_stale("weather:nyc", lambda: fetch_weather("NYC"))
if result['source'] == 'stale':
print(f"Stale served: source={result['source']}, temp={result['data']['temp']}")
Expected output:
Fresh available: source=fresh, temp=72
Stale served: source=stale, temp=70
Degraded Mode Response
Return reduced functionality responses when cache and DB fail:
import redis
import json
r = redis.Redis(decode_responses=True)
class DegradedModeCache:
def __init__(self, redis_client):
self.r = redis_client
self.degraded_configs = {}
def register_degraded_config(self, key, degraded_response, ttl=86400):
"""Register a degraded-mode response for a cache key."""
self.degraded_configs[key] = degraded_response
self.r.setex(f"degraded:{key}", ttl, json.dumps(degraded_response))
def get(self, key, fetch_fn=None, critical=False):
"""Get with degraded mode fallback for critical and non-critical data."""
try:
cached = self.r.get(key)
if cached:
return {"source": "cache", "data": json.loads(cached)}
except redis.ConnectionError:
pass
if fetch_fn:
try:
data = fetch_fn(key)
return {"source": "db", "data": data}
except Exception:
pass
degraded = self.degraded_configs.get(key)
if degraded:
return {"source": "degraded", "data": degraded}
if critical:
return {"source": "error", "error": "Service unavailable"}
return {"source": "null", "data": None}
def is_degraded(self):
"""Check if the system is in degraded mode."""
try:
self.r.ping()
return False
except redis.ConnectionError:
return True
degraded = DegradedModeCache(r)
degraded.register_degraded_config(
"product_listing",
{"products": [], "note": "Product data temporarily unavailable", "count": 0}
)
result = degraded.get("product_listing", critical=False)
print(f"With cache: source={result['source']}")
r.close()
result = degraded.get("product_listing", critical=True)
print(f"Degraded mode: source={result['source']}, note={result['data']['note']}")
print(f"Is degraded: {degraded.is_degraded()}")
Expected output:
With cache: source=cache
Degraded mode: source=degraded, note=Product data temporarily unavailable
Is degraded: True
Common Mistakes
- Not setting timeouts on cache reads — without a timeout, a stuck Redis connection blocks the request indefinitely. Always set socket_connect_timeout and socket_timeout (100-500ms).
- Falling back to the database without circuit breaking — if Redis is down because the database is also under load, falling back to the database makes the problem worse. Use a circuit breaker.
- Serving stale data without indicating staleness — users should know if they're seeing potentially outdated data. Set a header like Warning: 110 - Response is Stale.
- Not repopulating the cache after fallback — after falling back to the database, async-write to Redis so subsequent requests hit the cache. This reduces database load during recovery.
- Using the same timeout for cache operations as database operations — cache should have a short timeout (100ms), database a longer one (1-5s). If both time out simultaneously, no fallback is available.
Practice Questions
- What is the difference between database fallback and stale cache serving?
- Why should cache read timeouts be shorter than database query timeouts?
- How does stale-while-revalidate differ from serving stale data on error?
- When is degraded mode response preferred over serving stale data?
- How do you prevent the database from being overwhelmed during cache fallback?
Challenge
Design a multi-level fallback system for a product catalog API. Level 1: Redis cache (2ms expected). Level 2: on Redis failure, query database (20ms). Level 3: on database failure, serve stale cache (from a separate stale Redis key). Level 4: on stale unavailability, return a degraded response with popular products from local config. Level 5: on all failures, return a circuit-breaker error. Each level should have health monitoring.
FAQ
Mini Project
Build a multi-level fallback cache decorator that wraps any function with: (1) primary: Redis cache with 50ms timeout, (2) secondary: database query with 1s timeout, (3) tertiary: stale cache key (populated from primary on success), (4) quaternary: static fallback from configuration file, and (5) error: return None for non-critical or raise for critical. Log every fallback level transition for monitoring. Include automatic repopulation after recovery.
What's Next
Continue with Hybrid Caching to learn about combining Redis with other cache technologies like Memcached for different use cases. Then explore Cache Observability for monitoring and tracing cache operations.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro