Starphix
Systems Engineering / Sources
The Lab / Sources / Systems Engineering / Reliability/Circuit-Breakers-And-Fallbacks.md

Circuit Breakers and Fallbacks

Reliability patterns for AI systems: circuit breakers, fallback models, retry budgets, and graceful degradation when models fail or degrade.

Reliability Patterns

Pattern Purpose When to Use
Circuit Breaker Stop calling a failing model/dependency Transient or persistent model failures
Fallback Model Smaller/cheaper model takes over Primary model unavailable or slow
Retry with Backoff Retry transient failures with delay Network blips, rate limits
Error Budget Allowed failures before remediation SLO-driven reliability
Graceful Degradation Return partial/cached response When no model can serve
Bulkhead Isolate failures to one component Prevent cascade failures

Circuit Breaker States

stateDiagram-v2
    [*] --> Closed: normal operation

    Closed --> Open: failure threshold reached
    Closed --> Closed: success (reset failure count)

    Open --> Half-Open: timeout elapsed
    Open --> Open: failure (reset timeout)

    Half-Open --> Closed: success (close circuit)
    Half-Open --> Open: failure (re-open)

    note right of Closed: Requests flow normally
    note right of Open: Requests fail fast, fallback used
    note right of Half-Open: Test if service recovered

Circuit Breaker Implementation

from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import time
import asyncio
import logging

logger = logging.getLogger(__name__)

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

@dataclass
class CircuitBreakerConfig:
    failure_threshold: int = 5
    recovery_timeout_seconds: int = 30
    expected_exception: type = Exception
    fallback_model: Optional[str] = None

@dataclass
class CircuitBreakerState:
    state: CircuitState = CircuitState.CLOSED
    failure_count: int = 0
    last_failure_time: Optional[datetime] = None
    half_open_calls: int = 0

class CircuitBreaker:
    def __init__(self, name: str, config: CircuitBreakerConfig):
        self.name = name
        self.config = config
        self.state = CircuitBreakerState()

    def _record_success(self):
        self.state.failure_count = 0
        self.state.state = CircuitState.CLOSED

    def _record_failure(self):
        self.state.failure_count += 1
        self.state.last_failure_time = datetime.now()
        if self.state.failure_count >= self.config.failure_threshold:
            self.state.state = CircuitState.OPEN

    def _can_attempt(self) -> bool:
        if self.state.state == CircuitState.CLOSED:
            return True
        if self.state.state == CircuitState.OPEN:
            if (self.state.last_failure_time and 
                datetime.now() - self.state.last_failure_time > 
                timedelta(seconds=self.config.recovery_timeout_seconds)):
                self.state.state = CircuitState.HALF_OPEN
                self.state.half_open_calls = 0
                return True
            return False
        if self.state.state == CircuitState.HALF_OPEN:
            self.state.half_open_calls += 1
            return self.state.half_open_calls <= 1
        return False

    async def call(self, func, *args, fallback=None, **kwargs):
        if not self._can_attempt():
            logger.warning(f"Circuit {self.name} is OPEN — using fallback")
            if fallback:
                return await fallback(*args, **kwargs)
            raise CircuitOpenError(f"Circuit {self.name} is open")

        try:
            result = await func(*args, **kwargs)
            self._record_success()
            return result
        except self.config.expected_exception as e:
            self._record_failure()
            logger.error(f"Circuit {self.name} recorded failure: {e}")
            if fallback:
                return await fallback(*args, **kwargs)
            raise

class CircuitOpenError(Exception):
    pass

# Usage
breaker = CircuitBreaker(
    "llm-inference",
    CircuitBreakerConfig(
        failure_threshold=5,
        recovery_timeout_seconds=30,
        fallback_model="smaller-fallback-model",
    ),
)

async def generate_with_fallback(prompt):
    async def primary():
        return await llm_client.generate(prompt, model="large-model")

    async def fallback():
        return await llm_client.generate(prompt, model="smaller-model")

    return await breaker.call(primary, fallback=fallback)

Fallback Model Strategy

Scenario Primary Fallback Trigger
High-quality reasoning Large model (70B) Medium model (8B) Circuit open, SLO breach
Cost-sensitive Small model (3B) Cached response Cost budget exceeded
Safety-critical Model + human review Safe default response Guardrail violation
Edge / offline Local SLM Rule-based response No connectivity

Retry with Exponential Backoff

import asyncio
import random
from functools import wraps

def retry_with_backoff(
    max_retries: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 60.0,
    exponential_base: float = 2.0,
    jitter: bool = True,
):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            delay = base_delay
            last_exception = None

            for attempt in range(max_retries + 1):
                try:
                    return await func(*args, **kwargs)
                except (RateLimitError, TimeoutError, ServiceUnavailable) as e:
                    last_exception = e
                    if attempt == max_retries:
                        break

                    sleep_time = min(delay, max_delay)
                    if jitter:
                        sleep_time *= random.uniform(0.5, 1.5)

                    logger.warning(
                        f"Attempt {attempt + 1} failed: {e}. Retrying in {sleep_time:.1f}s"
                    )
                    await asyncio.sleep(sleep_time)
                    delay *= exponential_base

            raise last_exception
        return wrapper
    return decorator

@retry_with_backoff(max_retries=3, base_delay=1.0)
async def generate_with_retry(prompt):
    return await llm_client.generate(prompt)

Error Budget

# Error budget for AI inference
error_budget:
  window: 1h
  max_error_rate: 0.01  # 1% of requests may fail
  max_latency_violation_rate: 0.05  # 5% may exceed p99 latency
  max_cost_violation_rate: 0.0  # No cost budget overruns

  # When budget exhausted
  action_on_exhausted:
    - route_to_fallback_model
    - alert_oncall
    - pause_non_critical_traffic

Anti-Patterns

Anti-Pattern Fix
Retrying forever on bad outputs Circuit breaker; stop calling consistently failing models
No fallback when model fails Route to smaller model or cached response
Circuit breaker never closes Set recovery timeout; half-open state to test recovery
Fallback model also fails Chain fallbacks; ultimate safe default response
No error budget Define SLOs; alert when budget exhausted
  • Guardrails And Safety — Guardrails, incident response
  • Reliability — Retries, fallbacks, circuit breakers, SLOs
  • Observability — Monitoring and alerting
  • Canary And Rollback — Canary deployment, rollback strategies
  • Observability — Monitoring AI systems for reliability

Sources

  • Google SRE (circuit breakers, error budgets), Release It! (Nygard), Circuit breaker pattern (Martin Fowler), AWS Fault Injection, Azure Circuit Breaker, Hystrix/Resilience4j, AI incident response playbooks.*

From the Systems Engineering research vault · CC BY 4.0 · all notes · raw markdown