A service does not have to tip over when a dependency fails. Graceful degradation means answering with what you can still compute, missing exactly the piece that is broken, instead of returning nothing. The site stays useful even when a part of it is not.
Fail the piece, not the page
When the backend behind a sidebar, a snippet, or a profile is down or slow, the response can omit that piece and say so, rather than turning the whole page into a blank error. The visitor still gets the content that works, and the failure surface shrinks from the entire request to one bounded region. This is a deliberate template decision: each region knows how to render without its data, and the page falls back to a placeholder with a clear label. Timed out pieces are the quiet leak here, so bound each region with a short deadline so one slow dependency cannot hold the whole page hostage.
Cache the last-known-good
For data that changes slowly, a cached copy from earlier can serve while the live source is unreachable, keeping the page meaningful at the cost of freshness. The freshness trade is the same shape as stale-while-revalidate, applied at the feature level: an explicit maximum age, a refetch on the background, and a rule for what to show when the cache itself is empty. The subtlety is that the cache must be recent enough to be trustworthy, so record the timestamp you are serving from and surface it. A page that returns slightly stale but correct beats a blank error every time.
Make degradation visible
A degraded feature should be loud to the operators even while it looks fine to a visitor. Log the degraded path, the dependency it was missing, and how old the served data was, so a future reader can tell a degradation veil from a fully capable one. A metric that counts degraded responses puts the trade on the dashboards instead of hiding it inside a 200 status. What makes a degreed service durable is not the strategy on paper but the fact that the degraded state is surfaced, reproducible, and measurable like any real incident.
When it breaks
A fallback can fail in four concrete ways. First, an over-broad degradation switch injures the core path: a flag meant to hide recommendations ends up wrapping checkout or article body, so a recommendation outage still takes down checkout. Scope the switch to one region and test the core path with the flag forced on. Second, synchronized cache expiry turns degradation into an avalanche: every entry shares one TTL, the live source blips, all fallbacks miss at once, and the retry flood finishes the job. Stagger TTLs, add jitter, and define an explicit empty-cache answer. Third, silent degradation hides for weeks: the fallback returns 200 with no counter, no log field, and no age header, so dashboards stay green while visitors read week-old prices. Emit a degraded counter, log the missing dependency and data age, and alert on ratio, not only on errors. Fourth, recovery flaps into a storm: the dependency returns, every host closes its breaker together, queued retries arrive as one spike, and the service collapses again. Reopen gradually with one half-open probe, a concurrency limit, and a cooldown.
Check it quickly
Save the following as degrade_probe.py in a scratch directory and run python3 degrade_probe.py. It uses only the Python standard library, touches no network or disk, and keeps all state in memory. The fixture models one page with two regions: recommendations served from a last-known-good cache, and a greeting that has only a static simplified fallback.
"""Simulate normal / degraded / open fallback selection with stdlib only."""
class DependencyDown(Exception):
pass
class PageService:
def __init__(self):
self.cached_recs = ["cached-pick"]
self.cached_age_s = 90
self.failures = 0
self.threshold = 3
self.open = False
self.live_calls = 0
def live_recs(self, fail):
self.live_calls += 1
if fail:
raise DependencyDown("recs down")
return ["fresh-pick"]
def get_recs(self, fail=False, use_cache=True):
if self.open:
return {"mode": "open", "data": ["popular-picks"], "note": "breaker open"}
try:
data = self.live_recs(fail)
self.failures = 0
return {"mode": "fresh", "data": data}
except DependencyDown:
self.failures += 1
if self.failures >= self.threshold:
self.open = True
return {"mode": "open", "data": ["popular-picks"], "note": "breaker open"}
if use_cache and self.cached_recs:
return {"mode": "degraded-cache", "data": list(self.cached_recs),
"note": f"stale age={self.cached_age_s}s"}
return {"mode": "degraded-simplified", "data": ["popular-picks"],
"note": "simplified fallback"}
def half_open_probe(self, fail=False):
# Single trial close: only one live call allowed.
self.open = False
self.failures = 0
return self.get_recs(fail=fail)
def check(cond, msg):
if not cond:
raise AssertionError(msg)
def main():
svc = PageService()
r1 = svc.get_recs(fail=False)
check(r1["mode"] == "fresh" and r1["data"] == ["fresh-pick"], "fresh read must win")
print(f"1 fresh: {r1}")
r2 = svc.get_recs(fail=True)
check(r2["mode"] == "degraded-cache" and "stale" in r2["note"], "must serve stale cache")
print(f"2 degraded-cache: {r2}")
svc.cached_recs = [] # Empty cache forces the simplified answer.
svc.failures = 0
r3 = svc.get_recs(fail=True)
check(r3["mode"] == "degraded-simplified", "empty cache must simplify")
print(f"3 degraded-simplified: {r3}")
svc2 = PageService()
svc2.get_recs(fail=True)
svc2.get_recs(fail=True)
r_open = svc2.get_recs(fail=True)
check(r_open["mode"] == "open", "third failure must open breaker")
calls = svc2.live_calls
r_fast = svc2.get_recs(fail=True)
check(r_fast["mode"] == "open" and svc2.live_calls == calls, "open must fail fast")
print(f"4 open: {r_open} (live_calls={calls}, fast-fail holds)")
r_half = svc2.half_open_probe(fail=False)
check(r_half["mode"] == "fresh", "probe success must close breaker")
print(f"5 half-open probe: {r_half}")
print("PASS: fresh, stale-cache, simplified, open fail-fast, and probe all hold")
import sys
print(sys.version.split()[0])
if __name__ == "__main__":
main()
The six assertions walk through normal, degraded, and open states. Fresh reads return live data, a recommendation failure returns stale cache with its age, an empty cache returns an explicit simplified placeholder, and three consecutive failures open the breaker so the fourth call fails fast without touching the dependency. The output ends with PASS and the Python version. The fixture checks fallback selection and breaker accounting, not TTL tuning, retry jitter, or production dashboards. Before shipping, repeat the same three states against the real dependency with forced failures and a visible degraded counter.
Expected output looks like this, with your Python version on the last line:
1 fresh: {'mode': 'fresh', 'data': ['fresh-pick']}
2 degraded-cache: {'mode': 'degraded-cache', 'data': ['cached-pick'], 'note': 'stale age=90s'}
3 degraded-simplified: {'mode': 'degraded-simplified', 'data': ['popular-picks'], 'note': 'simplified fallback'}
4 open: {'mode': 'open', 'data': ['popular-picks'], 'note': 'breaker open'} (live_calls=3, fast-fail holds)
5 half-open probe: {'mode': 'fresh', 'data': ['fresh-pick']}
PASS: fresh, stale-cache, simplified, open fail-fast, and probe all hold
3.14.4
Further reading
Sources
- AWS: availability and degradation, why partial answers matter, checked 2027-04-20.
- Google SRE: graceful degradation, shedding to keep the useful parts, checked 2027-04-20.
- Martin Fowler: CircuitBreaker, failing fast to protect the partial, checked 2027-04-20.