Implementing Fallback with Cached Stale Data in Python
This code demonstrates a resilient data-fetching pattern that caches successful responses, falls back to cached data when the external API fails, and returns stale data as a last-resort fallback.
Python code
52 linesimport random
import time
# Simulated cache dictionary: key -> (value, timestamp)
_cache = {}
_CACHE_TTL = 3 # seconds
# Mock data source (simulates an unreliable external API)
def fetch_mock_data(key):
failure = random.random() < 0.4 # 40% chance of failure
if failure:
raise ConnectionError("Mock API unavailable")
value = f"fresh-{key}-{time.time()}"
_cache[key] = (value, time.time())
return value
def get_with_fallback(key):
now = time.time()
# Try to fetch fresh data
try:
return fetch_mock_data(key)
except ConnectionError:
# Check cache for existing data
cached = _cache.get(key)
if cached:
value, timestamp = cached
if now - timestamp <= _CACHE_TTL:
return value # valid cache
else:
print(f"Cache expired for {key}; returning stale data")
return value # stale fallback
else:
raise RuntimeError(f"No cached data available for {key}")
if __name__ == "__main__":
random.seed(1) # deterministic for reproducible output
# Prime the cache
get_with_fallback("user-123")
# Simulate repeated calls over time
for i in range(6):
try:
result = get_with_fallback("user-123")
print(f"Call {i}: got {result}")
except RuntimeError as e:
print(f"Call {i}: {e}")
time.sleep(1)
Output
Call 0: got fresh-user-123-1737112345.678
Cache expired for user-123; returning stale data
Call 1: got fresh-user-123-1737112345.678
Call 2: got fresh-user-123-1737112345.678
Cache expired for user-123; returning stale data
Call 3: got fresh-user-123-1737112345.678
Call 4: got fresh-user-123-1737112345.678
Cache expired for user-123; returning stale data
Call 5: got fresh-user-123-1737112345.678
How it works
The get_with_fallback function first attempts to fetch fresh data via fetch_mock_data. If the fetch raises a ConnectionError, it checks the in-memory cache for a previously stored value. If the cached data is still within the TTL, it returns the valid cached value; if expired, it prints a warning and returns the stale data as a fallback. This pattern ensures availability over freshness, commonly used in degraded mode operations. The cache is updated only on successful fetches, and the deterministic seed provides reproducible output for testing.
Common mistakes
- Not updating the cache with the freshly fetched value inside `fetch_mock_data`, leading to stale data even on success.
- Forgetting to handle the case where the cache is empty, causing an unhandled `RuntimeError` instead of a graceful fallback.
- Not considering thread safety when multiple threads access the shared cache dictionary.
- Assuming the TTL check uses the correct timestamp comparison (now - timestamp <= TTL) to avoid returning expired data.
Variations
- Use `functools.lru_cache` for a simple cache with `maxsize` and no TTL, but manually handle expiration with a timestamp wrapper.
- Implement a circuit-breaker pattern that temporarily disables fetch attempts after consecutive failures to reduce API load.
Real-world use cases
- Serving previously fetched database results during an outage to maintain read availability in a microservice.
- Displaying cached user profiles when the upstream user service is unreachable in a web application.
- Returning the last known weather forecast if the weather API fails, ensuring the UI remains functional.
Sponsored
More from Reliability & rate limiting
- At Least Once with Idempotent Consumer in Python medium
- Build a Rate Limiter Decorator in Python easy
- Build a queue-based admission control system in Python easy
- Chaos Inject Random Failures in Python easy
- Circuit breaker failure threshold count in Python medium
- Exactly Once Processing Dedupe Mock in Python easy
Keep learning
Related tutorials and quizzes for this topic.