# Fallback

> Return a pre-decided lesser answer when a dependency cannot - stale cache, static default, partial result - so the journey continues, marked as degraded.

- kind: pattern
- category: resilience
- aliases: Graceful Degradation, Degrade
- canonical: https://aipatterns.dev/design-patterns/fallback

## Intent

Decide in advance what a missing answer costs, and on failure return the substitute that costs least: the last-known-good value, a static default, an empty result, a queue-for-later. The caller's journey continues instead of failing - degraded, never silently wrong.

## Forces

- **Fail honestly** vs **fail usefully**: an error is truthful but stops the journey; a default is useful but must not be mistaken for the real answer.
- **Freshness** vs **availability**: yesterday's recommendations beat an error page, but yesterday's inventory count may not.
- **One substitute** vs **per caller**: only the caller knows whether a missing answer is cosmetic or load-bearing.
- **Degrade** vs **hide**: a fallback that nobody can see turns an outage into a mystery.

## Therefore

**Give each call site a substitute decided in advance, return it when the dependency fails or times out, and mark the response as degraded so monitoring and users can tell.**

The substitute is a product decision, not a code pattern: marketing copy can go stale, a payment cannot. Code exists to make that decision explicit and observable at every call site.

## Structure

```mermaid
classDiagram
  class RecommendationSource {
    <<interface>>
    +recommendationsFor(userId) Recommendations
  }
  class PersonalizationService {
    +recommendationsFor(userId) Recommendations
  }
  class CachedRecommendations {
    -maxAge Duration
    +get(userId) String[]
    +put(userId, items)
  }
  class ResilientRecommendations {
    -inner RecommendationSource
    -cache CachedRecommendations
    +recommendationsFor(userId) Recommendations
  }
  RecommendationSource <|.. PersonalizationService
  RecommendationSource <|.. ResilientRecommendations
  ResilientRecommendations --> RecommendationSource : inner
  ResilientRecommendations --> CachedRecommendations : cache
```

### Participants

- **Resilient wrapper** (`ResilientRecommendations`): tries the real source, and on failure serves the substitute and marks the response degraded.
- **Dependency** (`PersonalizationService`): the remote call that can fail.
- **Substitute** (`CachedRecommendations`): last-known-good values with a freshness bound; the code also holds a static default list for cache misses.
- **Caller**: reads `degraded` and can adjust (hide the section, show generic content, skip the call next time).

## Use it when

- The data is nice-to-have: recommendations, avatars, follower counts, "customers also bought".
- Reads tolerate staleness with a known bound (prices valid for minutes, cached until refresh).
- A [Circuit Breaker](https://aipatterns.dev/design-patterns/circuit-breaker.md) is open: serving cached data beats erroring every request.
- Batch jobs should skip-and-continue: one dead source must not fail a nightly export of twenty.
- Non-critical steps decorate a critical path (notifications, analytics) and must not fail the transaction.

## Do not use it when

- The answer must be correct: payments, inventory commits, authorization checks, anything consumed as a financial record. Fail loudly instead.
- The substitute silently changes meaning. Returning zero as "no discount" when the truth is "unknown discount" is a business bug, not resilience.
- The dependency is the product. A fallback for the primary data of the page is a different, wrong product.
- Nothing marks the degradation. An unmarked fallback hides a chronic outage; pair it with a metric and an alert on fallback rate.
- A queue would do better. If the work must happen eventually (send the email, update the index), defer with [Command](https://aipatterns.dev/design-patterns/command.md) and a durable queue rather than serving a substitute forever.

## Application grill

**Inspect first:** the dependencies whose failure should degrade and what lesser answers already exist (a cache from cache-aside, static defaults, partial data); whether the UI has any degraded-state convention; feature-flag infrastructure.

**Missing pieces:** a fallback needs a source for the lesser answer - usually the cache, a bundled default, or a read replica. Feature flags control degraded UI; a flag service is justified when non-developers must toggle, otherwise environment configuration is enough.

**Ask before you build:**

- **Scale:** which dependencies may degrade, and can every fallback be on at once during a real outage?
- **Performance:** how stale may the fallback answer be before it is worse than an error?
- **Reliability:** how is the degraded state surfaced to users and metrics - a silent fallback is how outages hide?
- **Budget:** build per-dependency fallbacks or standardize one wrapper - who owns the second failure path for each?
- **Implementation:** when was each fallback last exercised in staging - a fallback that never ran is a hope?

## Implementation steps

1. For each call site, decide what a missing answer costs and who can tolerate what. This is a product conversation; the code only encodes its outcome.
2. Pick the substitute per call site: cached last-known-good, static default, empty result, a second provider, or skip-and-continue in a loop.
3. Bound staleness: a cache entry older than `maxAge` counts as a miss, so degradation has a shelf life.
4. Mark degraded responses in the type (`degraded: true`), in metrics, and in logs - never return a substitute unmarked.
5. Wire the trigger: fire the fallback when [Timeout](https://aipatterns.dev/design-patterns/timeout.md) expires, [Retry](https://aipatterns.dev/design-patterns/retry.md) is exhausted, or the [Circuit Breaker](https://aipatterns.dev/design-patterns/circuit-breaker.md) is open - not as a blanket `catch` around everything.
6. Alert on the fallback rate. Degradation that lasts a day is an incident with better UX, not a steady state.
7. Test the fallback path like a feature: stale cache, empty cache, and marked responses.

## Example

A product page shows personalized recommendations. The personalization service fails; the page serves last-known-good recommendations from cache, or a static "popular this week" list for unknown users - every degraded response carries `degraded: true` so the page can label it and the dashboard can count it.

```ts title="recommendations.ts"
type Recommendations = { items: string[]; degraded: boolean };

interface RecommendationSource {
  recommendationsFor(userId: string): Promise<Recommendations>;
}

// The substitute: last-known-good answers with a freshness bound.
class CachedRecommendations {
  private readonly store = new Map<string, { items: string[]; at: number }>();
  constructor(private readonly maxAgeMs: number, private readonly now = Date.now) {}
  put(userId: string, items: string[]) {
    this.store.set(userId, { items, at: this.now() });
  }
  get(userId: string): string[] | null {
    const hit = this.store.get(userId);
    return hit && this.now() - hit.at <= this.maxAgeMs ? hit.items : null;
  }
}

// The static default for users with no cache entry.
const POPULAR_PICKS = ["best-seller-1", "best-seller-2", "new-arrival-1"];

// The dependency: a personalization service that answers once, then breaks.
class PersonalizationService implements RecommendationSource {
  constructor(private successesLeft: number) {}
  async recommendationsFor(userId: string): Promise<Recommendations> {
    if (this.successesLeft-- <= 0) throw new Error("503 unavailable");
    return { items: [`picks-for-${userId}`], degraded: false };
  }
}

class ResilientRecommendations implements RecommendationSource {
  constructor(
    private readonly inner: RecommendationSource,
    private readonly cache: CachedRecommendations,
    private readonly onDegraded: () => void,
  ) {}
  async recommendationsFor(userId: string): Promise<Recommendations> {
    try {
      const fresh = await this.inner.recommendationsFor(userId);
      this.cache.put(userId, fresh.items);
      return fresh;
    } catch (e) {
      this.onDegraded(); // count it; alert on the rate
      const cached = this.cache.get(userId);
      if (cached) return { items: cached, degraded: true };
      return { items: POPULAR_PICKS, degraded: true };
    }
  }
}

const cache = new CachedRecommendations(60_000);
let degradations = 0;
const source = new ResilientRecommendations(
  new PersonalizationService(1), // one good answer, then it fails
  cache,
  () => degradations++,
);

console.log(await source.recommendationsFor("u-1"));
// { items: [ 'picks-for-u-1' ], degraded: false }
console.log(await source.recommendationsFor("u-1"));
// { items: [ 'picks-for-u-1' ], degraded: true }   <- served from cache
console.log(await source.recommendationsFor("u-2"), "degradations:", degradations);
// { items: [ 'best-seller-1', ... ], degraded: true } degradations: 2
```

```python title="recommendations.py"
import time
from typing import Callable, Protocol

class RecommendationSource(Protocol):
    def recommendations_for(self, user_id: str) -> dict: ...

class CachedRecommendations:
    """The substitute: last-known-good answers with a freshness bound."""

    def __init__(self, max_age_ms: int, now: Callable[[], float] = time.monotonic) -> None:
        self._max_age_ms, self._now = max_age_ms, now
        self._store: dict[str, tuple[list[str], float]] = {}

    def put(self, user_id: str, items: list[str]) -> None:
        self._store[user_id] = (items, self._now())

    def get(self, user_id: str) -> list[str] | None:
        hit = self._store.get(user_id)
        if hit and (self._now() - hit[1]) * 1000 <= self._max_age_ms:
            return hit[0]
        return None

POPULAR_PICKS = ["best-seller-1", "best-seller-2", "new-arrival-1"]

class PersonalizationService:
    """The dependency: answers once, then breaks."""

    def __init__(self, successes_left: int) -> None:
        self._successes_left = successes_left

    def recommendations_for(self, user_id: str) -> dict:
        if self._successes_left <= 0:
            raise RuntimeError("503 unavailable")
        self._successes_left -= 1
        return {"items": [f"picks-for-{user_id}"], "degraded": False}

class ResilientRecommendations:
    def __init__(
        self,
        inner: RecommendationSource,
        cache: CachedRecommendations,
        on_degraded: Callable[[], None],
    ) -> None:
        self._inner, self._cache, self._on_degraded = inner, cache, on_degraded

    def recommendations_for(self, user_id: str) -> dict:
        try:
            fresh = self._inner.recommendations_for(user_id)
            self._cache.put(user_id, fresh["items"])
            return fresh
        except RuntimeError:
            self._on_degraded()  # count it; alert on the rate
            cached = self._cache.get(user_id)
            items = cached if cached is not None else POPULAR_PICKS
            return {"items": items, "degraded": True}

cache = CachedRecommendations(max_age_ms=60_000)
degradations = 0

def count() -> None:
    global degradations
    degradations += 1

source = ResilientRecommendations(
    PersonalizationService(successes_left=1),  # one good answer, then it fails
    cache,
    count,
)

print(source.recommendations_for("u-1"))
# {'items': ['picks-for-u-1'], 'degraded': False}
print(source.recommendations_for("u-1"))
# {'items': ['picks-for-u-1'], 'degraded': True}   <- served from cache
print(source.recommendations_for("u-2"), f"degradations: {degradations}")
# {'items': ['best-seller-1', ...], 'degraded': True} degradations: 2
```

```go title="recommendations.go"
package main

import (
	"errors"
	"fmt"
	"time"
)

type Recommendations struct {
	Items    []string
	Degraded bool
}

type RecommendationSource interface {
	RecommendationsFor(userID string) (Recommendations, error)
}

// CachedRecommendations is the substitute: last-known-good answers with a freshness bound.
type CachedRecommendations struct {
	maxAge time.Duration
	now    func() time.Time
	store  map[string]entry
}

type entry struct {
	items []string
	at    time.Time
}

func NewCache(maxAge time.Duration, now func() time.Time) *CachedRecommendations {
	return &CachedRecommendations{maxAge: maxAge, now: now, store: map[string]entry{}}
}

func (c *CachedRecommendations) put(userID string, items []string) {
	c.store[userID] = entry{items, c.now()}
}

func (c *CachedRecommendations) get(userID string) []string {
	h, ok := c.store[userID]
	if !ok || c.now().Sub(h.at) > c.maxAge {
		return nil
	}
	return h.items
}

var popularPicks = []string{"best-seller-1", "best-seller-2", "new-arrival-1"}

// PersonalizationService answers once, then breaks.
type PersonalizationService struct{ successesLeft int }

func (s *PersonalizationService) RecommendationsFor(userID string) (Recommendations, error) {
	if s.successesLeft <= 0 {
		return Recommendations{}, errors.New("503 unavailable")
	}
	s.successesLeft--
	return Recommendations{Items: []string{"picks-for-" + userID}}, nil
}

type ResilientRecommendations struct {
	inner      RecommendationSource
	cache      *CachedRecommendations
	degradations *int
}

func (r ResilientRecommendations) RecommendationsFor(userID string) (Recommendations, error) {
	fresh, err := r.inner.RecommendationsFor(userID)
	if err == nil {
		r.cache.put(userID, fresh.Items)
		return fresh, nil
	}
	*r.degradations++ // count it; alert on the rate
	if cached := r.cache.get(userID); cached != nil {
		return Recommendations{Items: cached, Degraded: true}, nil
	}
	return Recommendations{Items: popularPicks, Degraded: true}, nil
}

func main() {
	cache := NewCache(time.Minute, time.Now)
	degradations := 0
	source := ResilientRecommendations{
		inner:      &PersonalizationService{successesLeft: 1},
		cache:      cache,
		degradations: &degradations,
	}

	first, _ := source.RecommendationsFor("u-1")
	fmt.Println(first.Items, first.Degraded) // [picks-for-u-1] false
	second, _ := source.RecommendationsFor("u-1")
	fmt.Println(second.Items, second.Degraded) // [picks-for-u-1] true (from cache)
	third, _ := source.RecommendationsFor("u-2")
	fmt.Println(third.Items, third.Degraded, degradations) // [best-seller-1 ...] true 2
}
```

```java title="Recommendations.java"
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;

public class Recommendations {
    record Answers(List<String> items, boolean degraded) {}

    interface RecommendationSource {
        Answers recommendationsFor(String userId);
    }

    /** The substitute: last-known-good answers with a freshness bound. */
    static final class CachedRecommendations {
        private final long maxAgeMs;
        private final Supplier<Instant> now;
        private final Map<String, Timed> store = new HashMap<>();

        private record Timed(List<String> items, Instant at) {}

        CachedRecommendations(long maxAgeMs, Supplier<Instant> now) {
            this.maxAgeMs = maxAgeMs;
            this.now = now;
        }

        void put(String userId, List<String> items) {
            store.put(userId, new Timed(items, now.get()));
        }

        List<String> get(String userId) {
            Timed hit = store.get(userId);
            return hit != null && now.get().toEpochMilli() - hit.at().toEpochMilli() <= maxAgeMs
                    ? hit.items() : null;
        }
    }

    static final List<String> POPULAR_PICKS =
            List.of("best-seller-1", "best-seller-2", "new-arrival-1");

    /** The dependency: answers once, then breaks. */
    static final class PersonalizationService implements RecommendationSource {
        private int successesLeft;
        PersonalizationService(int successesLeft) { this.successesLeft = successesLeft; }

        public Answers recommendationsFor(String userId) {
            if (successesLeft-- <= 0) throw new IllegalStateException("503 unavailable");
            return new Answers(List.of("picks-for-" + userId), false);
        }
    }

    record ResilientRecommendations(
            RecommendationSource inner, CachedRecommendations cache, Runnable onDegraded)
            implements RecommendationSource {
        public Answers recommendationsFor(String userId) {
            try {
                Answers fresh = inner.recommendationsFor(userId);
                cache.put(userId, fresh.items());
                return fresh;
            } catch (RuntimeException e) {
                onDegraded.run(); // count it; alert on the rate
                List<String> cached = cache.get(userId);
                return new Answers(cached != null ? cached : POPULAR_PICKS, true);
            }
        }
    }

    public static void main(String[] args) {
        int[] degradations = {0};
        ResilientRecommendations source = new ResilientRecommendations(
                new PersonalizationService(1),
                new CachedRecommendations(60_000, Instant::now),
                () -> degradations[0]++);

        System.out.println(source.recommendationsFor("u-1")); // Answers[items=[picks-for-u-1], degraded=false]
        System.out.println(source.recommendationsFor("u-1")); // degraded=true, served from cache
        System.out.println(source.recommendationsFor("u-2") + " degradations=" + degradations[0]);
    }
}
```

The wrapper has the same interface as the service, so it is also where a [Timeout](https://aipatterns.dev/design-patterns/timeout.md) or retry would live - the fallback fires after those give up.

## Trade-offs

**Gains**
- The journey continues during an outage; users see slightly worse content, not an error page.
- Non-critical failures stop failing critical paths.
- The `degraded` flag turns silent rot into a visible, alertable signal.

**Costs**
- Every substitute is a product decision with a staleness or correctness risk attached.
- Unmonitored fallbacks hide chronic outages behind a calm UI.
- The wrapper adds a layer and a cache to own and to test.
- Wrong substitutes (zero, empty, "unknown") can corrupt decisions downstream.

## Relations

- [Retry](https://aipatterns.dev/design-patterns/retry.md) exhausts the transient attempts first; the fallback answers when retry gives up.
- [Circuit Breaker](https://aipatterns.dev/design-patterns/circuit-breaker.md) makes fallback a steady companion: while the breaker is open, every call serves the substitute immediately.
- [Timeout](https://aipatterns.dev/design-patterns/timeout.md) provides the trigger - without deadlines, the fallback path never fires because the call never ends.
- [Decorator](https://aipatterns.dev/design-patterns/decorator.md): the wrapper is a decorator; several resilience wrappers (timeout, retry, fallback) stack around one interface.
- [Strategy](https://aipatterns.dev/design-patterns/strategy.md): when several substitutes exist (cache, second provider, static list), the choice of substitute is a runtime strategy the composition root picks.

## Agent checklist

- [ ] Each call site's substitute is a deliberate product decision, not a default `catch`.
- [ ] Staleness has a bound (`maxAge`), and expired entries count as misses.
- [ ] Degraded responses are marked in the type and counted in metrics.
- [ ] Correctness-critical paths (payments, authorization, inventory commits) have no fallback.
- [ ] The fallback path has tests: stale cache, cold cache, and the marked response.
- [ ] An alert exists on the fallback rate, so degradation cannot become invisible.
