Module 7: Reliability Patterns & Production Checklist
6. Fallbacks and Health Checks
Description
Retry, circuit breaker, and rate limiting reduce errors — but they don't eliminate them. When all your retries are exhausted, when the circuit is open and the service is still down, you need something that serves the user anyway. Fallbacks are that final layer: degrading your service in a controlled way is better than an error 500. And health checks are the early warning signal that tells you something is about to fail before the users arrive. In this capsule you'll implement a complete fallback chain, a cache-based backup system, and real health checks that Kubernetes can consume.
Types of fallback and when to use each one
Fallback hierarchy (from highest to lowest quality):
1. Secondary Provider (same type of service, different provider)
├── Example: OpenAI fails → Anthropic
├── Quality: ~equivalent
├── Cost: may be different
└── When: you have a contract with multiple providers
2. Model Downgrade (same provider, smaller/cheaper model)
├── Example: gpt-4o fails → gpt-4o-mini
├── Quality: lower (but functional)
├── Cost: lower
└── When: the primary is the largest model, mini as fallback
3. Cached Response (response from a similar previous request)
├── Example: "What is the sentiment of 'I like it'?" → cache hit
├── Quality: exact if the context is the same
├── Cost: 0
└── When: requests repeat frequently
4. Simplified Processing (simpler algorithm without LLM)
├── Example: sentiment with keyword matching instead of LLM
├── Quality: lower, but deterministic
├── Cost: 0 (doesn't call any API)
└── When: you have a rule-based version of the algorithm
5. Static Default (predefined generic response)
├── Example: {"sentiment": "unknown", "score": 0.0, "confidence": 0.0}
├── Quality: minimal (only says "I don't know")
├── Cost: 0
└── When: last resort when everything fails
6. Graceful Error (honest error with a clear message)
├── Example: {"error": "Service unavailable", "retry_after": 60}
├── Quality: there's no useful response, but it's honest
└── When: when no fallback can give a reasonable response
Complete FallbackProvider
# src/infrastructure/fallback_provider.py
from typing import Optional, Callable
import time
import structlog
from src.infrastructure.llm_provider import LLMProvider, LLMProviderError
from src.infrastructure.error_classifier import ErrorCategory
log = structlog.get_logger()
class FallbackExhaustedError(LLMProviderError):
"""All providers in the fallback chain failed."""
def __init__(self, failures: list[dict]):
super().__init__(
message="All providers in fallback chain failed",
category=ErrorCategory.OUTAGE,
should_retry=False
)
self.failures = failures
class FallbackProvider:
"""
Fallback chain: tries multiple providers in order.
The first provider that succeeds wins.
If all fail, it raises FallbackExhaustedError.
Lets you distinguish "degraded" responses (using a fallback)
from normal responses.
"""
def __init__(
self,
providers: list[LLMProvider],
names: list[str] = None, # Names for logging (optional)
static_fallback: str = None, # Static response if all fail
on_fallback: Callable = None # Callback when a fallback is triggered
):
if len(providers) < 2:
raise ValueError("FallbackProvider requires at least 2 providers")
self._providers = providers
self._names = names or [type(p).__name__ for p in providers]
self._static_fallback = static_fallback
self._on_fallback = on_fallback
# Metrics
self._fallback_counts = {name: 0 for name in self._names}
self._total_calls = 0
self._degraded_calls = 0 # Calls that used a fallback
def complete(self, messages: list[dict], **kwargs) -> str:
"""
Try each provider in order.
Return the result of the first provider that works.
"""
self._total_calls += 1
failures = []
primary_failed = False
for i, (provider, name) in enumerate(zip(self._providers, self._names)):
try:
result = provider.complete(messages, **kwargs)
if primary_failed:
# It's not the primary — we're using a fallback
self._degraded_calls += 1
self._fallback_counts[name] += 1
log.warning(
"fallback_provider_used",
primary_failed=self._names[0],
fallback_used=name,
fallback_index=i,
failures_before=[f["provider"] for f in failures]
)
if self._on_fallback:
self._on_fallback(name, failures)
return result
except Exception as e:
primary_failed = True
failures.append({
"provider": name,
"error_type": type(e).__name__,
"error_message": str(e)[:200]
})
log.warning(
"provider_failed_trying_next",
provider=name,
next_provider=self._names[i + 1] if i + 1 < len(self._names) else "static_fallback",
error_type=type(e).__name__
)
# All providers failed
if self._static_fallback is not None:
log.error(
"all_providers_failed_using_static",
failures=[f["provider"] for f in failures]
)
self._degraded_calls += 1
return self._static_fallback
raise FallbackExhaustedError(failures=failures)
@property
def is_degraded(self) -> bool:
"""True if most recent calls used a fallback."""
if self._total_calls == 0:
return False
return (self._degraded_calls / self._total_calls) > 0.5
def get_metrics(self) -> dict:
return {
"total_calls": self._total_calls,
"degraded_calls": self._degraded_calls,
"degraded_percent": round(
(self._degraded_calls / self._total_calls * 100) if self._total_calls > 0 else 0, 1
),
"fallback_counts": self._fallback_counts
}
Communicating degradation to the user
# src/app/routers/sentiment.py
from fastapi import Depends
from src.infrastructure.fallback_provider import FallbackProvider
from src.domain.sentiment_service import analyze_sentiment
import structlog
log = structlog.get_logger()
@router.post("/analyze")
async def analyze_sentiment_endpoint(
body: AnalyzeRequest,
provider: LLMProvider = Depends(get_llm_provider)
):
result = analyze_sentiment(body.text, provider)
# Detect whether a fallback was used
degraded = False
degraded_reason = None
if isinstance(provider, FallbackProvider) and provider.is_degraded:
degraded = True
degraded_reason = "Service with reduced capacity — using backup model"
response = {
**result,
"degraded": degraded,
"degraded_reason": degraded_reason if degraded else None
}
# Log for degradation metrics
if degraded:
log.info("response_served_degraded", degraded_reason=degraded_reason)
return response
# Why communicate the degradation?
# 1. The user can decide whether to trust the response
# 2. The product team can decide when to disable degraded features
# 3. Integration tests can verify that the fallback works
# 4. It's not ethical to return a lower-quality response as if it were normal
Response caching as a fallback
# src/infrastructure/cache_provider.py
from typing import Optional
import time
import hashlib
import json
import structlog
from src.infrastructure.llm_provider import LLMProvider
log = structlog.get_logger()
class CachedProvider:
"""
Wraps an LLMProvider with response caching.
In normal mode: returns from cache on a hit, calls the provider on a miss.
In fallback mode: if the provider fails, returns the last cached value.
"""
def __init__(
self,
inner: LLMProvider,
ttl_seconds: int = 300, # 5 minutes by default
fallback_ttl_seconds: int = 3600, # In fallback, accepts cache up to 1h old
max_cache_size: int = 1000
):
self._inner = inner
self._ttl = ttl_seconds
self._fallback_ttl = fallback_ttl_seconds
self._cache: dict[str, dict] = {}
self._max_size = max_cache_size
# Metrics
self._hits = 0
self._misses = 0
self._fallback_hits = 0
def _cache_key(self, messages: list[dict]) -> str:
"""Generate a cache key based on the messages."""
content = json.dumps(messages, sort_keys=True)
return hashlib.sha256(content.encode()).hexdigest()[:16]
def _get_from_cache(self, key: str, max_age: int) -> Optional[str]:
"""Return the cached value if it exists and hasn't expired."""
if key in self._cache:
entry = self._cache[key]
age = time.time() - entry["timestamp"]
if age < max_age:
return entry["value"]
return None
def _set_cache(self, key: str, value: str) -> None:
"""Save to the cache, evicting if necessary."""
if len(self._cache) >= self._max_size:
# Evict the oldest (simple LRU)
oldest_key = min(self._cache, key=lambda k: self._cache[k]["timestamp"])
del self._cache[oldest_key]
self._cache[key] = {
"value": value,
"timestamp": time.time()
}
def complete(self, messages: list[dict], **kwargs) -> str:
key = self._cache_key(messages)
# Try fresh cache
cached = self._get_from_cache(key, self._ttl)
if cached is not None:
self._hits += 1
log.debug("cache_hit", key=key)
return cached
self._misses += 1
try:
result = self._inner.complete(messages, **kwargs)
self._set_cache(key, result)
return result
except Exception as e:
# Provider failed — try older cache as a fallback
stale_cached = self._get_from_cache(key, self._fallback_ttl)
if stale_cached is not None:
self._fallback_hits += 1
log.warning(
"cache_stale_fallback_used",
key=key,
provider_error=type(e).__name__
)
return stale_cached
raise # If there's no cache, propagate the error
Real health checks (not cosmetic)
# src/health/checks.py
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import time
import structlog
from src.config import get_settings
log = structlog.get_logger()
router = APIRouter(prefix="/health", tags=["health"])
@router.get("/live")
async def liveness():
"""
Liveness probe: is the process alive and able to respond to HTTP?
Kubernetes uses this to decide whether to restart the pod.
It must be VERY simple — if this fails, the process is corrupt.
Do NOT check external dependencies here.
"""
return {"status": "alive", "timestamp": time.time()}
@router.get("/ready")
async def readiness():
"""
Readiness probe: can the app receive traffic now?
Kubernetes uses this to decide whether to send traffic to the pod.
It does check dependencies — if OpenAI doesn't respond, don't send traffic.
"""
checks = {}
all_ready = True
# Check configuration
try:
settings = get_settings()
checks["config"] = {"status": "ok"}
except Exception as e:
checks["config"] = {"status": "error", "detail": str(e)[:100]}
all_ready = False
# Check circuit breakers
try:
from src.app.dependencies import get_circuit_breaker_metrics
cb_metrics = get_circuit_breaker_metrics()
any_open = any(m["state"] == "open" for m in cb_metrics.values())
checks["circuit_breakers"] = {
"status": "degraded" if any_open else "ok",
"details": cb_metrics
}
if any_open:
all_ready = False # Don't send traffic if the circuit is open
except Exception as e:
checks["circuit_breakers"] = {"status": "unknown", "detail": str(e)[:100]}
status_code = 200 if all_ready else 503
return JSONResponse(
content={
"status": "ready" if all_ready else "not_ready",
"checks": checks,
"timestamp": time.time()
},
status_code=status_code
)
@router.get("/deps")
async def dependency_check():
"""
Dependency health check: are the external dependencies responding?
Used for monitoring and alerts.
NOT used by Kubernetes for routing (that's /ready).
It can be slower and make real calls to dependencies.
"""
settings = get_settings()
checks = {}
# Check OpenAI
start = time.time()
try:
client = settings.create_openai_client()
# Lightweight call: list models (much cheaper than a completion)
models = client.models.list()
latency_ms = (time.time() - start) * 1000
checks["openai"] = {
"status": "ok",
"latency_ms": round(latency_ms, 1),
"models_available": len(list(models.data)) > 0
}
except Exception as e:
latency_ms = (time.time() - start) * 1000
checks["openai"] = {
"status": "error",
"latency_ms": round(latency_ms, 1),
"error": type(e).__name__,
"detail": str(e)[:200]
}
# Check rate limit status
try:
from src.app.dependencies import get_rate_limiter_metrics
rate_metrics = get_rate_limiter_metrics()
checks["rate_limits"] = {
"status": "ok",
"details": rate_metrics
}
except Exception as e:
checks["rate_limits"] = {"status": "unknown"}
all_ok = all(c.get("status") == "ok" for c in checks.values())
log.info(
"dependency_check_completed",
all_ok=all_ok,
checks={k: v.get("status") for k, v in checks.items()}
)
return JSONResponse(
content={"checks": checks, "timestamp": time.time()},
status_code=200 if all_ok else 503
)
Integration in main.py
# src/app/main.py (health checks integration fragment)
from fastapi import FastAPI
from src.health.checks import router as health_router
def create_app() -> FastAPI:
app = FastAPI(title="Sentiment Analysis API")
# Health checks before any authentication middleware
# so Kubernetes can access without auth
app.include_router(health_router)
# ... rest of the configuration
return app
Exercises
Exercise 1: Deciding the fallback type
For an app that generates summaries of news articles:
- OpenAI gpt-4o fails → which fallback to use?
- All the LLM APIs are down → what to return?
- The same article was processed 30 minutes ago → use cache?
See solution
- gpt-4o fails: Model downgrade to gpt-4o-mini first. If it also fails, secondary provider (Anthropic claude-haiku if available). Communicate to the user: "Using backup model - quality may vary"
- All APIs down: Static default + graceful error. Don't invent a summary. Return:
{"summary": null, "error": "Summarization service unavailable", "retry_after": 300} - 30-min cache: YES use cache for articles (the content doesn't change). A 1h TTL is reasonable. In fallback mode, accept cache up to 24h old.
Exercise 2: Health check for Kubernetes
What's the difference between GET /health/live and GET /health/ready? Why does Kubernetes need both?
See guide
- Liveness (
/health/live): "Is the process alive?" If it fails, K8s restarts the pod. Very simple: only check that the process can respond to HTTP. If you check OpenAI here and OpenAI is down, K8s restarts all pods uselessly. - Readiness (
/health/ready): "Is the pod ready to receive traffic?" If it fails, K8s stops sending traffic to the pod (but doesn't restart it). Here you DO check dependencies. If OpenAI is down, the pod is "not ready" and K8s sends traffic to other pods or uses the fallback.
You need both because "alive" and "ready" are different questions.
Exercise 3: Designing the fallback chain
Your app has a /summarize endpoint that summarizes news articles. Design the complete fallback chain, indicating what you return at each level:
See solution
# Level 1: Primary provider (gpt-4o)
# → Complete and detailed summary
# Level 2: Secondary provider (gpt-4o-mini)
# → Shorter but functional summary
# → Communicate: "Using backup model"
# Level 3: Cache of previous responses
# → If this article was already summarized, return the cache
# → Communicate: "Showing previous summary"
# Level 4: Simplified processing
# → Extract the first 3 sentences of the article as a "summary"
# → No LLM, just text processing
# → Communicate: "Basic summary generated automatically"
# Level 5: Static default
# → {"summary": null, "status": "unavailable",
# "message": "The summarization service is not available"}
The key is that each level degrades quality but never returns an error 500.
Exercise 4: Implementing a degraded flag in the response
Modify the endpoint to communicate to the frontend when it's serving a degraded response:
See solution
@router.post("/summarize")
async def summarize(body: SummarizeRequest, provider = Depends(get_llm_provider)):
result = summarize_article(body.text, provider)
response = {
**result,
"degraded": False,
"degraded_reason": None,
"provider_used": "primary"
}
if isinstance(provider, FallbackProvider):
if provider.is_degraded:
response["degraded"] = True
response["degraded_reason"] = provider.degraded_reason
response["provider_used"] = provider.last_used_provider
return response
The frontend can use the degraded flag to show a banner: "Reduced-quality results — retry in a few minutes."
Troubleshooting
"My fallback never triggers"
Verify that the FallbackProvider receives the correct exceptions. If your RetryProvider uses reraise=True, it propagates the original exception. If it uses reraise=False, it propagates tenacity.RetryError. The FallbackProvider needs to catch the correct type. Add a temporary log in the catch to see what exception type arrives.
"The readiness health check always returns 503"
Check which checks are failing. The response includes a checks dict with the status of each dependency. If circuit_breakers shows "state": "open", it's because the circuit is open — normal during an outage. If config fails, you have a configuration problem. Call GET /health/deps to see the full detail of each dependency.
"Should I cache all LLM responses?"
Not necessarily. Cache when the input is repetitive and the response is deterministic (or acceptably similar). For sentiment analysis with the same texts, the cache is very effective. For chatbots with unique conversations, the cache rarely matches. Use a short TTL (5-15 minutes) to start and adjust based on your hit rate.
"Kubernetes restarts my pod constantly"
You're probably checking OpenAI in your liveness probe. If OpenAI has problems, your liveness fails → K8s restarts the pod → the new pod has the same problem → infinite loop. Rule of thumb: liveness only checks that the Python process is running. External dependencies go in readiness.
Summary
- Fallback hierarchy: secondary provider → model downgrade → cached response → simplified processing → static default → graceful error
- Always communicate degradation: the user and the monitoring system must know when a fallback is used
- Liveness vs Readiness: liveness is "am I alive?", readiness is "can I receive traffic?"
- Real health checks: verify that OpenAI responds, not just that the HTTP server works
- Cache as a fallback: useful for repetitive requests, with an extended TTL in fallback mode
Additional resources
- Kubernetes Probes — Liveness/readiness documentation
- Graceful Degradation (MDN) — The concept
- Caching Strategies — Cache patterns
- Microsoft — Health Endpoint Monitoring Pattern — The health checks pattern