Skip to content

Implementing a Circuit Breaker Pattern for MCP Service Resilience

8 minute read
Content level: Expert
2

If you're building an application that calls MCP (Model Context Protocol) servers — say, an AI-powered support portal or an agentic workflow — you've probably hit HTTP 429 errors when traffic spikes. This article walks through how to add a circuit breaker so your app stays functional even when the MCP server is throttling you.

The Problem

Here's what happens without protection: your app sends requests to an MCP server, the server hits its rate limit and starts returning 429s, your app retries, the retries pile up, and now you've got a cascade. Users see timeouts or blank screens. The MCP server is drowning in retry traffic. Everyone loses.

You'll recognize this pattern if you've seen:

  • Sudden HTTP 429 responses from MCP tool calls during peak hours
  • Response times jumping from milliseconds to full timeouts
  • AI features going dark for end users
  • Your retry logic making the problem worse, not better

Why Retries Alone Don't Fix This

Most teams start with exponential backoff. It's fine for the occasional blip — a network hiccup, a momentary spike. But under sustained throttling, retries have three problems:

  1. They still hold threads — your app is burning resources waiting during backoff delays
  2. They pile up — when the MCP server recovers, every backed-up retry fires at once (the "thundering herd")
  3. They offer no alternative — while you're retrying, your user is staring at a spinner

You need a way to fail fast and do something useful instead.

How a Circuit Breaker Helps

The idea is borrowed from electrical engineering. A circuit breaker sits between your app and the MCP server. It watches for failures, and when things go bad, it trips — cutting off requests before they reach the struggling server.

It has three states:

states

CLOSED — Business as usual. Requests flow through. The breaker counts failures quietly in the background.

OPEN — Too many failures in a short window (say, 5 in 30 seconds). The breaker stops sending requests entirely. Instead, your app immediately returns a fallback — a cached answer, a "try again shortly" message, whatever makes sense. This gives the MCP server room to recover.

HALF-OPEN — After a cooldown (say, 60 seconds), the breaker lets one request through as a test. If it succeeds, great — back to CLOSED. If it fails, back to OPEN for another cooldown.

The Code

Here's a straightforward Python implementation example:

import time
from enum import Enum
from threading import Lock


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


class CircuitBreaker:
    def __init__(
        self,
        failure_threshold=5,
        recovery_timeout=60,
        monitoring_window=30
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.monitoring_window = monitoring_window
        self.state = CircuitState.CLOSED
        self.failures = []
        self.last_failure_time = None
        self.lock = Lock()

    def _clean_old_failures(self):
        """Remove failures outside the monitoring window."""
        now = time.time()
        self.failures = [
            f for f in self.failures
            if now - f < self.monitoring_window
        ]

    def can_execute(self):
        """Check if a request should be attempted."""
        with self.lock:
            if self.state == CircuitState.CLOSED:
                return True
            elif self.state == CircuitState.OPEN:
                if time.time() - self.last_failure_time >= self.recovery_timeout:
                    self.state = CircuitState.HALF_OPEN
                    return True
                return False
            elif self.state == CircuitState.HALF_OPEN:
                return True

    def record_success(self):
        """Record a successful call."""
        with self.lock:
            self.state = CircuitState.CLOSED
            self.failures = []

    def record_failure(self):
        """Record a failed call and potentially trip the circuit."""
        with self.lock:
            now = time.time()
            self.failures.append(now)
            self.last_failure_time = now
            self._clean_old_failures()

            if len(self.failures) >= self.failure_threshold:
                self.state = CircuitState.OPEN

            if self.state == CircuitState.HALF_OPEN:
                self.state = CircuitState.OPEN

Wiring It Into Your MCP Calls

Now wrap your MCP calls with the breaker. The key addition: when the circuit is open, serve a fallback instead of waiting.

import json

mcp_circuit = CircuitBreaker(
    failure_threshold=5,
    recovery_timeout=60,
    monitoring_window=30
)

response_cache = {}


def call_mcp_tool(tool_name, parameters):
    """Call an MCP tool with circuit breaker protection."""

    if not mcp_circuit.can_execute():
        return get_fallback_response(tool_name, parameters)

    try:
        response = invoke_mcp_server(tool_name, parameters)

        if response.status_code == 200:
            mcp_circuit.record_success()
            cache_key = f"{tool_name}:{json.dumps(parameters, sort_keys=True)}"
            response_cache[cache_key] = {
                "data": response.json(),
                "timestamp": time.time()
            }
            return response.json()

        elif response.status_code == 429:
            mcp_circuit.record_failure()
            return get_fallback_response(tool_name, parameters)

        else:
            mcp_circuit.record_failure()
            return {"error": "Service temporarily unavailable"}

    except (ConnectionError, TimeoutError):
        mcp_circuit.record_failure()
        return get_fallback_response(tool_name, parameters)


def get_fallback_response(tool_name, parameters):
    """Return something useful when the MCP server is unavailable."""
    cache_key = f"{tool_name}:{json.dumps(parameters, sort_keys=True)}"

    # Try cached response first (5-minute freshness)
    cached = response_cache.get(cache_key)
    if cached and (time.time() - cached["timestamp"] < 300):
        return {
            **cached["data"],
            "_fallback": True,
            "_cache_age_seconds": int(time.time() - cached["timestamp"])
        }

    # Otherwise, tell the user what's happening
    return {
        "message": "AI-assisted response temporarily unavailable. "
                   "Please try again shortly or use manual search.",
        "_fallback": True,
        "_circuit_state": "open"
    }

What to Show Users When the Circuit Is Open

Don't just show an error. Pick a fallback that matches the feature's importance:

StrategyGood forWhat it looks like
Cached responseAnswers that don't change oftenLast-known-good answer with a "cached" label
Static contentCritical paths that must always respondPre-written FAQ or documentation links
Feature toggleNice-to-have AI featuresHide the AI chat, show regular search
Queue itRequests that aren't time-sensitive"We'll process this shortly" with async delivery

The right choice depends on your users. A support portal might show cached KB articles. A chatbot might fall back to keyword search. An analytics tool might queue the request and email results later.

Monitoring: Know When the Circuit Trips

You want to know when this happens in production. Push the circuit state to CloudWatch:

import boto3

cloudwatch = boto3.client("cloudwatch")


def publish_circuit_metric(circuit_breaker, tool_name):
    """Publish circuit state to CloudWatch for alerting."""
    state_value = {
        CircuitState.CLOSED: 0,
        CircuitState.HALF_OPEN: 1,
        CircuitState.OPEN: 2
    }

    cloudwatch.put_metric_data(
        Namespace="MyApp/MCPResilience",
        MetricData=[
            {
                "MetricName": "CircuitState",
                "Dimensions": [
                    {"Name": "ToolName", "Value": tool_name}
                ],
                "Value": state_value[circuit_breaker.state],
                "Unit": "None"
            },
            {
                "MetricName": "FallbackResponses",
                "Dimensions": [
                    {"Name": "ToolName", "Value": tool_name}
                ],
                "Value": 1 if circuit_breaker.state != CircuitState.CLOSED else 0,
                "Unit": "Count"
            }
        ]
    )

Set an alarm on CircuitState >= 2 (OPEN). When it fires, you know your MCP dependency is struggling and users are getting fallback responses.

Bonus: Use Rate Limit Headers to Avoid Tripping at All

AWS MCP servers may include rate limit info in response headers:

def parse_rate_limit_headers(response):
    """Check how close you are to the limit."""
    return {
        "limit": int(response.headers.get("X-RateLimit-Limit", 0)),
        "remaining": int(response.headers.get("X-RateLimit-Remaining", 0)),
        "reset": int(response.headers.get("X-RateLimit-Reset", 0))
    }

If X-RateLimit-Remaining is getting low, slow down your request rate before you hit a 429. This is adaptive rate limiting — it prevents the circuit from tripping in the first place. Think of it as the early warning system that keeps the breaker from ever needing to fire.

Architecture at a Glance

CircuitBreaker

Workflow Steps:

  1. Client sends request to the Circuit Breaker
  2. Circuit Breaker checks state (CLOSED, OPEN, or HALF-OPEN)
  3. If CLOSED or HALF-OPEN → forward request to MCP Server
  4. MCP Server responds — success (200) or throttled (429). Circuit Breaker records the result: success resets the failure count and caches the response; failure increments the count and trips to OPEN if threshold exceeded
  5. If OPEN → Fallback Handler returns a cached response or degraded message directly to Client without calling MCP Server
  6. Circuit Breaker publishes state metrics to CloudWatch (alerts when circuit opens)
  7. After recovery timeout → circuit transitions to HALF-OPEN and allows one test request through

Wrapping Up

Five things to remember:

  1. Retries alone won't save you — they make sustained throttling worse. A circuit breaker fails fast and protects both sides.
  2. Always have a fallback — cached data, static content, or a graceful "try again shortly" message. Something is better than a timeout.
  3. Monitor the circuit — a CloudWatch alarm on circuit state tells you when users are getting degraded responses.
  4. Check rate limit headers first — if the server tells you how close you are to the limit, use that info to slow down before you get throttled.
  5. Tune your thresholds — start with 5 failures in 30 seconds, then adjust based on your traffic and the MCP server's actual limits.

Related Resources