🔥 0
0
Lesson 9 of 10 20 min +175 XP

Error Handling & Fallbacks

Things will go wrong. Payment APIs time out. Inventory services return errors. External APIs rate limit you. The difference between a good AI agent and a great one is how gracefully it handles failures.

The Failure Mindset

Every external call can fail. Design your tools assuming they will - then you'll be ready when they do. The goal isn't to prevent all errors; it's to handle them so well that users barely notice.

Error Categories

NON-RETRYABLE

  • Invalid input parameters
  • Authentication failures
  • Insufficient funds
  • Business rule violations
  • Resource not found

RETRYABLE

  • Network timeouts
  • Rate limiting (429)
  • Service unavailable (503)
  • Database connection errors
  • Temporary API outages

FALLBACK CANDIDATES

  • Shipping rate calculation
  • Inventory checks
  • Analytics queries
  • Non-critical lookups
  • Recommendation services

Error Response Structure

Standardize your error responses so AI models can understand and handle them:

from dataclasses import dataclass, asdict
from typing import Optional, List
from enum import Enum

class ErrorCode(Enum):
    # Input errors
    INVALID_INPUT = "INVALID_INPUT"
    MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD"
    INVALID_FORMAT = "INVALID_FORMAT"

    # Authentication/Authorization
    UNAUTHORIZED = "UNAUTHORIZED"
    FORBIDDEN = "FORBIDDEN"

    # Business logic
    INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS"
    INSUFFICIENT_INVENTORY = "INSUFFICIENT_INVENTORY"
    ORDER_NOT_FOUND = "ORDER_NOT_FOUND"
    CUSTOMER_NOT_FOUND = "CUSTOMER_NOT_FOUND"

    # External service
    PAYMENT_DECLINED = "PAYMENT_DECLINED"
    SHIPPING_UNAVAILABLE = "SHIPPING_UNAVAILABLE"
    SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"

    # Rate limiting
    RATE_LIMITED = "RATE_LIMITED"

    # Unknown
    INTERNAL_ERROR = "INTERNAL_ERROR"

@dataclass
class ToolError:
    code: ErrorCode
    message: str
    details: Optional[dict] = None
    retryable: bool = False
    retry_after_seconds: Optional[int] = None
    suggestions: Optional[List[str]] = None

def error_response(error: ToolError) -> dict:
    """Format error for tool response"""
    response = {
        "success": False,
        "error": {
            "code": error.code.value,
            "message": error.message,
            "retryable": error.retryable
        }
    }

    if error.details:
        response["error"]["details"] = error.details
    if error.retry_after_seconds:
        response["error"]["retry_after_seconds"] = error.retry_after_seconds
    if error.suggestions:
        response["error"]["suggestions"] = error.suggestions

    return response

Implementing Retry Logic

import asyncio
from functools import wraps
import random

def with_retry(
    max_attempts: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
    exponential_base: float = 2.0,
    retryable_exceptions: tuple = (TimeoutError, ConnectionError)
):
    """Decorator for automatic retry with exponential backoff"""
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            last_exception = None

            for attempt in range(max_attempts):
                try:
                    return await func(*args, **kwargs)

                except retryable_exceptions as e:
                    last_exception = e

                    if attempt < max_attempts - 1:
                        # Calculate delay with jitter
                        delay = min(
                            base_delay * (exponential_base ** attempt),
                            max_delay
                        )
                        jitter = random.uniform(0, delay * 0.1)
                        actual_delay = delay + jitter

                        logger.warning(
                            f"Attempt {attempt + 1} failed: {e}. "
                            f"Retrying in {actual_delay:.2f}s"
                        )
                        await asyncio.sleep(actual_delay)

            # All retries exhausted
            raise last_exception

        return wrapper
    return decorator

# Usage
@with_retry(max_attempts=3, retryable_exceptions=(TimeoutError, RateLimitError))
async def call_payment_api(amount: int, customer_id: str):
    return await stripe.PaymentIntent.create(
        amount=amount,
        customer=customer_id
    )

Smart Retry for HTTP Requests

import httpx
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    retry_if_result
)

def is_retryable_status(response: httpx.Response) -> bool:
    """Check if HTTP status indicates a retryable error"""
    return response.status_code in (429, 500, 502, 503, 504)

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=30),
    retry=(
        retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)) |
        retry_if_result(is_retryable_status)
    )
)
async def fetch_shipping_rates(origin: str, destination: str, weight: float):
    async with httpx.AsyncClient(timeout=10.0) as client:
        response = await client.post(
            "https://api.shipping.com/rates",
            json={
                "origin": origin,
                "destination": destination,
                "weight_oz": weight
            }
        )

        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 60))
            raise RateLimitError(f"Rate limited. Retry after {retry_after}s")

        response.raise_for_status()
        return response.json()

Circuit Breaker Pattern

Prevent cascading failures by stopping calls to failing services:

from datetime import datetime, timedelta
from enum import Enum
from dataclasses import dataclass
import asyncio

class CircuitState(Enum):
    CLOSED = "closed"      # Normal operation
    OPEN = "open"          # Failing, reject requests
    HALF_OPEN = "half_open"  # Testing if service recovered

@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    recovery_timeout: int = 30
    half_open_max_calls: int = 3

    def __post_init__(self):
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.last_failure_time = None
        self.half_open_calls = 0

    async def call(self, func, *args, **kwargs):
        if self.state == CircuitState.OPEN:
            if self._should_attempt_recovery():
                self.state = CircuitState.HALF_OPEN
                self.half_open_calls = 0
            else:
                raise CircuitOpenError("Service unavailable (circuit open)")

        if self.state == CircuitState.HALF_OPEN:
            if self.half_open_calls >= self.half_open_max_calls:
                raise CircuitOpenError("Circuit in half-open state, max calls reached")
            self.half_open_calls += 1

        try:
            result = await func(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise

    def _should_attempt_recovery(self) -> bool:
        if not self.last_failure_time:
            return False
        elapsed = (datetime.now() - self.last_failure_time).total_seconds()
        return elapsed >= self.recovery_timeout

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

    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = datetime.now()

        if self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN

# Usage
shipping_circuit = CircuitBreaker(failure_threshold=5, recovery_timeout=30)

async def get_shipping_rates_with_breaker(destination: str, weight: float):
    try:
        return await shipping_circuit.call(
            fetch_shipping_rates,
            destination=destination,
            weight=weight
        )
    except CircuitOpenError:
        # Return cached/default rates
        return get_fallback_rates(destination, weight)

Fallback Strategies

When primary tools fail, provide alternatives:

async def calculate_shipping_with_fallbacks(
    destination: str,
    weight: float,
    carriers: list = None
):
    """Try multiple shipping providers with fallbacks"""

    # Strategy 1: Try real-time rates
    try:
        rates = await get_live_shipping_rates(destination, weight, carriers)
        return {"source": "live", "rates": rates}
    except (TimeoutError, ServiceUnavailableError) as e:
        logger.warning(f"Live rates failed: {e}")

    # Strategy 2: Use cached rates (less than 1 hour old)
    cached_rates = await get_cached_rates(destination, weight)
    if cached_rates and cached_rates["cached_at"] > datetime.now() - timedelta(hours=1):
        return {
            "source": "cached",
            "rates": cached_rates["rates"],
            "cached_at": cached_rates["cached_at"].isoformat(),
            "warning": "Using cached rates - prices may have changed"
        }

    # Strategy 3: Use estimated rates based on zone/weight
    estimated = calculate_estimated_rates(destination, weight)
    return {
        "source": "estimated",
        "rates": estimated,
        "warning": "Using estimated rates - actual shipping cost calculated at checkout"
    }

Inventory Fallback

async def check_inventory_with_fallback(product_id: str, warehouse_id: str = None):
    """Check inventory with graceful degradation"""

    try:
        # Try real-time inventory
        inventory = await inventory_service.get_stock(product_id, warehouse_id)
        return {
            "source": "real_time",
            "product_id": product_id,
            "available": inventory["available"],
            "status": "in_stock" if inventory["available"] > 0 else "out_of_stock"
        }

    except InventoryServiceError as e:
        logger.error(f"Inventory service failed: {e}")

        # Fallback to last known state
        cached = await cache.get(f"inventory:{product_id}")
        if cached:
            age_seconds = (datetime.now() - cached["timestamp"]).total_seconds()
            return {
                "source": "cached",
                "product_id": product_id,
                "available": cached["available"],
                "status": "likely_available" if cached["available"] > 5 else "check_availability",
                "cache_age_seconds": int(age_seconds),
                "warning": "Real-time inventory unavailable - showing last known state"
            }

        # Ultimate fallback - assume available but verify at checkout
        return {
            "source": "unknown",
            "product_id": product_id,
            "status": "verify_at_checkout",
            "warning": "Cannot confirm availability - will verify when you checkout"
        }

Error Handling in Tool Calls

from mcp import types
import json
import traceback

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    """Central error handling for all tool calls"""

    try:
        # Input validation
        validation_error = validate_tool_input(name, arguments)
        if validation_error:
            return format_tool_error(validation_error)

        # Route to handler
        result = await route_tool_call(name, arguments)
        return [types.TextContent(type="text", text=json.dumps(result))]

    except ValidationError as e:
        return format_tool_error(ToolError(
            code=ErrorCode.INVALID_INPUT,
            message=str(e),
            retryable=False
        ))

    except RateLimitError as e:
        return format_tool_error(ToolError(
            code=ErrorCode.RATE_LIMITED,
            message="Too many requests. Please wait before trying again.",
            retryable=True,
            retry_after_seconds=e.retry_after
        ))

    except InsufficientFundsError as e:
        return format_tool_error(ToolError(
            code=ErrorCode.INSUFFICIENT_FUNDS,
            message="Payment method declined. Please try a different payment method.",
            retryable=False,
            suggestions=["Use a different card", "Check card balance", "Contact your bank"]
        ))

    except ExternalServiceError as e:
        logger.error(f"External service error: {e}", exc_info=True)
        return format_tool_error(ToolError(
            code=ErrorCode.SERVICE_UNAVAILABLE,
            message="An external service is temporarily unavailable.",
            retryable=True,
            retry_after_seconds=30
        ))

    except Exception as e:
        # Log unexpected errors with full traceback
        logger.error(f"Unexpected error in {name}: {e}\n{traceback.format_exc()}")
        return format_tool_error(ToolError(
            code=ErrorCode.INTERNAL_ERROR,
            message="An unexpected error occurred. Please try again.",
            retryable=True
        ))

def format_tool_error(error: ToolError) -> list[types.TextContent]:
    """Format error response for tool result"""
    return [types.TextContent(
        type="text",
        text=json.dumps(error_response(error))
    )]

Real-World Error Scenarios

Scenario 1: Payment Timeout

Customer: "Please charge my card for order #12345"

AI Agent: [Calls process_payment]
  process_payment(amount=9999, customer_id="cus_abc", idempotency_key="ord_12345_pay")

Tool Response:
{
  "success": false,
  "error": {
    "code": "SERVICE_UNAVAILABLE",
    "message": "Payment processing timed out",
    "retryable": true,
    "retry_after_seconds": 5
  }
}

AI Agent: [Waits 5 seconds, retries with SAME idempotency key]
  process_payment(amount=9999, customer_id="cus_abc", idempotency_key="ord_12345_pay")

Tool Response:
{
  "success": true,
  "payment_id": "pi_xyz789",
  "status": "succeeded"
}

AI Agent: "Your payment of $99.99 has been processed successfully!
Order #12345 is confirmed and will ship within 2 business days."

Scenario 2: Inventory Service Down

Customer: "Is the blue jacket in stock?"

AI Agent: [Calls check_inventory]
  check_inventory(product_id="JACKET-BLUE-M")

Tool Response:
{
  "success": true,
  "source": "cached",
  "product_id": "JACKET-BLUE-M",
  "available": 23,
  "status": "likely_available",
  "cache_age_seconds": 180,
  "warning": "Real-time inventory unavailable - showing last known state"
}

AI Agent: "The blue jacket appears to be in stock - we showed 23 units
about 3 minutes ago. I'll confirm the exact availability when you
proceed to checkout. Would you like to add it to your cart?"

Key Takeaways

  • Return errors in results so AI can understand and handle them
  • Distinguish retryable from non-retryable errors
  • Implement exponential backoff with jitter for retries
  • Use circuit breakers to prevent cascading failures
  • Provide fallbacks for non-critical operations
  • Always use idempotency keys for financial operations

Next up: MCP Assessment Quiz - Test your knowledge of everything you've learned about MCP and tool use for e-commerce.

🧠 Quick Quiz

Test your understanding of this lesson.

1

Where should tool errors be reported in MCP?

2

What is the purpose of an idempotency key in payment retries?

3

When should a tool indicate that an error is 'retryable'?

Building MCP Servers