n4nAI

Circuit breaking LLM providers in LangChain apps

Implement circuit breaking and automatic fallback for LLM providers in LangChain with runnable code and verification steps.

n4n Team3 min read759 words

Audio narration

Coming soon — every post will get a voice note here.

LangChain applications that depend on a single LLM provider inherit that provider’s uptime, latency, and rate limits. A langchain circuit breaker llm provider tutorial should show you how to detect degraded providers, trip a circuit, and route traffic to a healthy alternative without rewriting your chain logic. This post walks through a production-ready pattern you can drop into any LangChain app.

Step 1: Define the failure modes you care about

Not every error deserves a circuit trip. Network timeouts, 5xx responses, and explicit rate-limit headers (429 with Retry-After) are signals that a provider is unhealthy. Client errors (400, 401, 404) usually indicate a bug in your request — don’t circuit-break on those.

Create a small classification module so the rest of your code stays clean:

# circuit_classifier.py
from enum import Enum
from typing import TypeVar

import httpx
from langchain_core.runnables import RunnableConfig

class ProviderState(Enum):
    HEALTHY = "healthy"
    DEGRADED = "degraded"
    OPEN = "open"

T = TypeVar("T")

def classify_exception(exc: BaseException) -> ProviderState:
    """Map an exception to a provider state."""
    if isinstance(exc, httpx.TimeoutException):
        return ProviderState.DEGRADED
    if isinstance(exc, httpx.HTTPStatusError):
        status = exc.response.status_code
        if status >= 500:
            return ProviderState.DEGRADED
        if status == 429:
            return ProviderState.DEGRADED
    return ProviderState.HEALTHY  # treat everything else as non-tripping

This keeps your circuit breaker logic focused on infrastructure failures, not prompt engineering mistakes.

Step 2: Build a lightweight circuit breaker

LangChain’s Runnable interface makes it easy to wrap any model call with cross-cutting logic. We’ll implement a state machine with three states: closed (normal), open (tripped), and half-open (testing recovery).

# circuit_breaker.py
import time
import threading
from dataclasses import dataclass, field
from typing import Callable, Any

from circuit_classifier import ProviderState, classify_exception

@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    recovery_timeout: float = 30.0  # seconds
    half_open_max_calls: int = 3

    _state: ProviderState = field(default=ProviderState.HEALTHY, init=False)
    _failure_count: int = field(default=0, init=False)
    _last_failure_time: float = field(default=0.0, init=False)
    _half_open_calls: int = field(default=0, init=False)
    _lock: threading.Lock = field(default_factory=threading.Lock, init=False)

    def call(self, func: Callable[[], Any]) -> Any:
        with self._lock:
            if self._state == ProviderState.OPEN:
                if time.time() - self._last_failure_time >= self.recovery_timeout:
                    self._state = ProviderState.DEGRADED  # half-open
                    self._half_open_calls = 0
                else:
                    raise CircuitOpenError("Circuit breaker is open")

            if self._state == ProviderState.DEGRADED:
                if self._half_open_calls >= self.half_open_max_calls:
                    raise CircuitOpenError("Half-open call limit reached")
                self._half_open_calls += 1

        try:
            result = func()
        except Exception as exc:
            self._record_failure(exc)
            raise
        else:
            self._record_success()
            return result

    def _record_failure(self, exc: BaseException) -> None:
        with self._lock:
            state = classify_exception(exc)
            if state == ProviderState.DEGRADED:
                self._failure_count += 1
                self._last_failure_time = time.time()
                if self._failure_count >= self.failure_threshold:
                    self._state = ProviderState.OPEN
            # HEALTHY exceptions don't increment the counter

    def _record_success(self) -> None:
        with self._lock:
            if self._state == ProviderState.DEGRADED:
                self._state = ProviderState.HEALTHY
                self._failure_count = 0
                self._half_open_calls = 0
            elif self._state == ProviderState.HEALTHY:
                self._failure_count = 0

class CircuitOpenError(Exception):
    """Raised when the circuit is open and the call is rejected."""
    pass

This implementation is deliberately minimal — no external dependencies, thread-safe, and easy to test in isolation.

Step 3: Wrap each provider with its own breaker

Each LLM provider gets an independent circuit breaker. This prevents a single degraded provider from contaminating the others.

# provider_registry.py
from typing import Dict
from langchain_core.language_models import BaseChatModel
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_google_genai import ChatGoogleGenerativeAI

from circuit_breaker import CircuitBreaker, CircuitOpenError

class ProviderRegistry:
    def __init__(self):
        self._models: Dict[str, BaseChatModel] = {}
        self._breakers: Dict[str, CircuitBreaker] = {}

    def register(self, name: str, model: BaseChatModel, breaker: CircuitBreaker | None = None) -> None:
        self._models[name] = model
        self._breakers[name] = breaker or CircuitBreaker()

    def get_model(self, name: str) -> BaseChatModel:
        return self._models[name]

    def get_breaker(self, name: str) -> CircuitBreaker:
        return self._breakers[name]

    def invoke_with_fallback(self, messages: list, preferred: list[str]) -> Any:
        """Try providers in order until one succeeds."""
        last_error = None
        for name in preferred:
            breaker = self._breakers[name]
            model = self._models[name]
            try:
                return breaker.call(lambda: model.invoke(messages))
            except CircuitOpenError:
                continue  # try next provider
            except Exception as exc:
                last_error = exc
                continue
        raise RuntimeError(f"All providers failed. Last error: {last_error}")

# Initialize once at app startup
registry = ProviderRegistry()
registry.register("openai-gpt4", ChatOpenAI(model="gpt-4o", temperature=0))
registry.register("anthropic-claude", ChatAnthropic(model="claude-3-5-sonnet-20241022", temperature=0))
registry.register("google-gemini", ChatGoogleGenerativeAI(model="gemini-1.5-pro", temperature=0))

The invoke_with_fallback method encodes your routing policy: try OpenAI first, then Anthropic, then Google. Adjust the order based on your cost, latency, or quality preferences.

Step 4: Expose a LangChain-compatible runnable

Your chains shouldn’t know about circuit breakers. Wrap the registry in a Runnable that implements the standard interface.

# resilient_chat.py
from typing import Any, Dict, List, Optional
from langchain_core.runnables import Runnable, RunnableConfig
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatResult

from provider_registry import registry

class ResilientChatModel(Runnable[List[BaseMessage], ChatResult]):
    """A chat model that automatically fails over across providers."""

    def __init__(self, provider_order: Optional[List[str]] = None):
        self.provider_order = provider_order or ["openai-gpt4", "anthropic-claude", "google-gemini"]

    def invoke(self, input: List[BaseMessage], config: Optional[RunnableConfig] = None) -> ChatResult:
        # Allow per-request override via config
        order = config.get("configurable", {}).get("provider_order", self.provider_order) if config else self.provider_order
        return registry.invoke_with_fallback(input, order)

    async def ainvoke(self, input: List[BaseMessage], config: Optional[RunnableConfig] = None) -> ChatResult:
        # Async version delegates to sync for simplicity; replace with true async if needed
        return self.invoke(input, config)

    def batch(self, inputs: List[List[BaseMessage]], config: Optional[RunnableConfig] = None) -> List[ChatResult]:
        return [self.invoke(inp, config) for inp in inputs]

Now you can drop ResilientChatModel() anywhere you’d use a regular ChatOpenAI or ChatAnthropic:

# main_chain.py
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

from resilient_chat import ResilientChatModel

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a concise technical assistant."),
    ("human", "{question}"),
])

model = ResilientChatModel()
chain = prompt | model | StrOutputParser()

# Usage
response = chain.invoke({"question": "Explain circuit breakers in three sentences."})
print(response)

Step 5: Add observability so you know when circuits trip

A circuit breaker that trips silently is a debugging nightmare. Emit structured logs and metrics at each state transition.

# observability.py
import logging
import time
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Generator

import structlog

logger = structlog.get_logger(__name__)

@dataclass
class CircuitEvent:
    provider: str
    from_state: str
    to_state: str
    failure_count: int
    timestamp: float = field(default_factory=time.time)

def log_circuit_event(event: CircuitEvent) -> None:
    logger.warning(
        "circuit_breaker_state_change",
        provider=event.provider,
        from_state=event.from_state,
        to_state=event.to_state,
        failure_count=event.failure_count,
    )

@contextmanager
def track_latency(provider: str) -> Generator[None, None, None]:
    start = time.perf_counter()
    try:
        yield
    finally:
        duration_ms = (time.perf_counter() - start) * 1000
        logger.info("provider_latency", provider=provider, duration_ms=duration_ms)

Wire this into CircuitBreaker._record_failure and _record_success:

# Inside CircuitBreaker._record_failure
if self._state == ProviderState.OPEN and previous_state != ProviderState.OPEN:
    log_circuit_event(CircuitEvent(
        provider=provider_name,  # pass this in or store on the breaker
        from_state=previous_state.value,
        to_state=ProviderState.OPEN.value,
        failure_count=self._failure_count,
    ))

If you’re already using n4n.ai as your inference gateway, its per-token usage metering and automatic fallback when a provider is rate-limited or degraded complement this application-level circuit breaker — the gateway handles infrastructure failover while your code handles semantic routing.

Step 6: Write integration tests that verify the behavior

Unit-test the state machine in isolation, then write an integration test that exercises the full fallback chain against a mock server.

# test_circuit_breaker.py
import pytest
import httpx
from unittest.mock import Mock, patch

from circuit_breaker import CircuitBreaker, CircuitOpenError, ProviderState
from circuit_classifier import classify_exception

def test_circuit_opens_after_threshold():
    breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=60)
    mock_func = Mock(side_effect=httpx.HTTPStatusError("500", request=Mock(), response=Mock(status_code=500)))

    for _ in range(3):
        with pytest.raises(httpx.HTTPStatusError):
            breaker.call(mock_func)

    assert breaker._state == ProviderState.OPEN
    with pytest.raises(CircuitOpenError):
        breaker.call(lambda: "success")

def test_half_open_allows_recovery():
    breaker = CircuitBreaker(failure_threshold=2, recovery_timeout=0.01, half_open_max_calls=1)
    mock_func = Mock(side_effect=httpx.HTTPStatusError("500", request=Mock(), response=Mock(status_code=500)))

    # Trip the circuit
    for _ in range(2):
        with pytest.raises(httpx.HTTPStatusError):
            breaker.call(mock_func)

    # Wait for recovery timeout
    import time
    time.sleep(0.02)

    # Half-open call succeeds
    mock_func.side_effect = None
    mock_func.return_value = "ok"
    result = breaker.call(mock_func)
    assert result == "ok"
    assert breaker._state == ProviderState.HEALTHY

For integration testing, spin up a local mock server that returns configurable responses:

# test_integration.py
import pytest
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
import uvicorn
import threading
import time

from provider_registry import ProviderRegistry
from resilient_chat import ResilientChatModel
from langchain_core.messages import HumanMessage

app = FastAPI()

@app.post("/v1/chat/completions")
async def mock_chat(request: Request):
    body = await request.json()
    provider = request.headers.get("x-test-provider", "unknown")
    if provider == "fail":
        return JSONResponse(status_code=500, content={"error": "internal error"})
    return {"choices": [{"message": {"content": f"response from {provider}"}}]}

def start_mock_server():
    config = uvicorn.Config(app, host="127.0.0.1", port=18080, log_level="error")
    server = uvicorn.Server(config)
    thread = threading.Thread(target=server.run, daemon=True)
    thread.start()
    time.sleep(0.5)  # wait for server to start
    return "http://127.0.0.1:18080"

@pytest.fixture(scope="session")
def mock_base_url():
    return start_mock_server()

def test_fallback_on_provider_failure(mock_base_url):
    from langchain_openai import ChatOpenAI

    registry = ProviderRegistry()
    registry.register("primary", ChatOpenAI(
        base_url=f"{mock_base_url}/v1",
        api_key="test",
        model="test",
        default_headers={"x-test-provider": "fail"}
    ))
    registry.register("fallback", ChatOpenAI(
        base_url=f"{mock_base_url}/v1",
        api_key="test",
        model="test",
        default_headers={"x-test-provider": "ok"}
    ))

    model = ResilientChatModel(provider_order=["primary", "fallback"])
    # Monkey-patch the registry used by ResilientChatModel
    import resilient_chat
    resilient_chat.registry = registry

    result = model.invoke([HumanMessage(content="hello")])
    assert "response from ok" in result.generations[0].message.content

Run with pytest -v test_circuit_breaker.py test_integration.py. All tests should pass.

Step 7: Configure timeouts and retries at the HTTP layer

Circuit breaking works best when the underlying HTTP client fails fast. Configure your LangChain model clients with aggressive timeouts so the breaker sees failures quickly.

# http_config.py
import httpx
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic

def create_http_client() -> httpx.Client:
    return httpx.Client(
        timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0),
        limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
    )

# Usage
http_client = create_http_client()

openai_model = ChatOpenAI(
    model="gpt-4o",
    http_client=http_client,
    max_retries=0,  # let the circuit breaker handle retries via fallback
)

anthropic_model = ChatAnthropic(
    model="claude-3-5-sonnet-20241022",
    http_client=http_client,
    max_retries=0,
)

Setting max_retries=0 on the model prevents double-retry behavior — the circuit breaker and fallback logic own the retry policy.

Step 8: Verify success in production

Deploy with feature flags so you can enable the resilient model for a subset of traffic first.

# feature_flags.py
import os

def use_resilient_model() -> bool:
    return os.getenv("USE_RESILIENT_MODEL", "false").lower() == "true"
# In your application entry point
from feature_flags import use_resilient_model
from resilient_chat import ResilientChatModel
from langchain_openai import ChatOpenAI

if use_resilient_model():
    chat_model = ResilientChatModel()
else:
    chat_model = ChatOpenAI(model="gpt-4o")

Monitor these key metrics after rollout:

Metric Target Alert threshold
Circuit open rate (per provider) < 0.1% of requests > 1% for 5 minutes
Fallback latency p99 < 2x primary latency > 3x primary latency
Fallback success rate > 99.9% < 99.5%
Provider error rate (5xx + 429) < 0.5% > 2%

Dashboards in Datadog, Grafana, or your observability stack should show circuit state transitions over time. A healthy system shows brief, rare trips followed by quick recovery. Persistent open circuits indicate a provider issue that needs escalation, not just fallback.

Common pitfalls to avoid

Don’t share a single circuit breaker across providers. Each provider fails independently. A shared breaker would trip all providers when one degrades.

Don’t treat 4xx errors as circuit-breakable. A 400 means your prompt is malformed. A 401 means your API key is invalid. Fix the bug instead of failing over.

Don’t forget to reset failure counts on success. The _record_success method must clear the counter when the provider recovers, otherwise a single success after many failures leaves the breaker primed to trip again immediately.

Don’t hardcode provider order in the chain. Pass it via RunnableConfig so you can A/B test routing policies or implement per-tenant preferences without code changes:

chain.invoke(
    {"question": "..."},
    config={"configurable": {"provider_order": ["anthropic-claude", "openai-gpt4"]}}
)

What this gives you

With ~150 lines of framework code, you now have:

  • Automatic failover when any provider degrades
  • Independent circuit state per provider
  • Observable state transitions for debugging
  • Zero changes to your existing chain logic
  • Configurable routing policy per request

The pattern scales to any number of providers and works with any LangChain-compatible model. When you add a new provider, register it in ProviderRegistry and add it to your preferred order — no other code changes required.

Tagslangchaincircuit-breakerreliabilityfallback

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All langchain multi-model fallback & routing posts →