system_design / resilience / 03_fallbacks_and_degradation.md

Fallbacks, partial failure and graceful degradation

6 interview angles 5 min read source

Fallbacks, partial failure and graceful degradation

What you return when a dependency has failed and you’ve exhausted retries. The engineering is easy; the hard part is deciding, in advance, what “degraded but useful” means for each feature.

The fallback ladder

Ordered by how much value the user still gets:

Fallback Example Cost
Cached value last known price, stale by minutes staleness
Partial result 2 of 3 providers returned incompleteness
Default / heuristic popular items instead of personalised relevance
Reduced feature search without filters capability
Queue for later accept the write, process async latency
Honest error “temporarily unavailable” the feature

Serving stale data is usually better than serving nothing — but it must be a decision, not an accident. A cached exchange rate from five minutes ago is fine; a cached account balance from five minutes ago may be a compliance problem.

That distinction is the interview point: the right fallback is a business question, and different per feature. Ask which, rather than proposing one universally.

Stale-while-revalidate

The most useful caching shape for resilience:

async def get_price(sku: str) -> Price:
    cached = await cache.get(sku)
    if cached and not cached.is_stale:
        return cached.value

    try:
        fresh = await pricing_service.get(sku, timeout=1.0)
        await cache.set(sku, fresh, ttl=300, stale_ttl=3600)
        return fresh
    except (TimeoutError, ServiceUnavailable):
        if cached:                                   # stale beats nothing
            metrics.increment("price.served_stale")
            return cached.value
        raise

Two TTLs: a fresh window where you serve without asking, and a longer stale window where you’d prefer fresh but will serve old data if the source is down. The dependency being down degrades freshness rather than availability.

Emit a metric when you serve stale. Otherwise a dependency can be down for hours while dashboards look healthy — the failure is invisible precisely because the fallback worked.

Partial failure

When you fan out to several sources, all-or-nothing is usually the wrong contract.

async def search_all(query: str) -> SearchResults:
    async with asyncio.TaskGroup() as tg:
        tasks = {
            name: tg.create_task(with_timeout(provider.search(query), 0.8))
            for name, provider in PROVIDERS.items()
        }
    # gather results, recording which sources failed
    results, failed = [], []
    for name, task in tasks.items():
        try:
            results.extend(task.result())
        except (TimeoutError, ProviderError):
            failed.append(name)

    return SearchResults(items=results, degraded_sources=failed)

Note TaskGroup cancels siblings on an unhandled exception, so each call needs its own error handling if partial success is the goal — otherwise one failure takes the whole fan-out down. See ../../backend/04_async_concurrency/12_taskgroup_structured_concurrency.md.

Make degradation visible in the response. degraded_sources lets the UI say “results may be incomplete” instead of silently showing less. Users tolerate a stated limitation far better than a silent one.

Deciding what’s acceptable

Classify each dependency before the incident, not during it:

Class If it fails Example
Critical the request fails auth, the primary datastore
Important degrade visibly search ranking, recommendations
Optional proceed silently analytics, A/B assignment, telemetry

An optional dependency must never fail the request. A common real bug: an analytics call inside the request path without a timeout, so an analytics outage takes down checkout. Fire it async, cap it hard, and swallow its errors.

asyncio.create_task(track_event(...))     # never awaited on the request path

Write this classification down. During an incident is the wrong time to discover that nobody agreed whether recommendations are critical.

Error budgets

The framing that makes “acceptable failure” concrete. A 99.9% availability target permits roughly 43 minutes of downtime per month — that’s the budget.

It converts reliability from a vague aspiration into a number you spend: budget remaining means you can ship risky changes, budget exhausted means you stop feature work and fix reliability. See ../../backend/15_observability/12_slo_sli_sla.md.

The senior version of “how reliable should this be”: not 100%. Chasing nines past what users notice costs more than it returns, and the budget makes that trade explicit.

Testing it

Fallback paths are the least-tested code in most systems, and they run during your worst incidents.

  • Unit-test the fallback branch directly — inject a failing client and assert the degraded response.
  • Fault injection: a test proxy (toxiproxy) or a mesh fault rule that adds latency and errors.
  • Chaos testing in staging: kill a dependency and confirm the system degrades as designed rather than cascading.
  • Game days: rehearse an outage with the team. Frequently reveals that the fallback was never wired up.

The recurring discovery: the fallback exists, and a config flag disables it, or it calls the same dead dependency.

Interview angle

  • “A dependency is down and retries are exhausted. Now what?” — walk the ladder: cached value, partial result, default or heuristic, reduced feature, queue for later, honest error. Which one is correct is a business decision per feature — stale prices are fine, stale balances may not be.
  • “How do you serve stale data safely?” — two TTLs, fresh and stale. Serve fresh inside the first window, attempt refresh after it, and fall back to stale if the source is down. Emit a metric when you serve stale, or the outage stays invisible while dashboards look fine.
  • “Three providers, one is down. What do you return?” — partial results with the failed sources named in the response, so the UI can say results may be incomplete. Users accept a stated limitation; they don’t accept silently wrong output.
  • “An analytics outage took down checkout. What went wrong?” — an optional dependency was on the critical path, awaited and without a hard timeout. Classify dependencies as critical, important or optional up front, and make optional calls fire-and-forget with errors swallowed.
  • “How do you decide what reliability to target?” — an error budget from the SLO. 99.9% is about 43 minutes a month; while budget remains you ship, when it’s exhausted you stop and fix. It makes “not 100%” an explicit, defensible position.
  • “How do you know the fallback works?” — test it directly with an injected failing client, then fault injection and chaos testing in staging. Fallback code is the least-exercised path and it runs during your worst incidents.