Skip to content
Back to Blog
1 min read

Retry Strategies for AI Applications: Beyond Simple Backoff

Intelligent Retry Framework

from dataclasses import dataclass, field
from typing import Callable, Optional, List, Any
from enum import Enum
import time
import random
import logging

logger = logging.getLogger(__name__)

class RetryDecision(Enum):
    RETRY = "retry"
    FAIL = "fail"
    MODIFY_AND_RETRY = "modify_and_retry"

@dataclass
class RetryConfig:
    """Configuration for retry behavior"""
    max_attempts: int = 3
    base_delay: float = 1.0
    max_delay: float = 60.0
    exponential_base: float = 2.0
    jitter: bool = True
    retry_on: List[type] = field(default_factory=list)

@dataclass
class RetryContext:
    """Context for making retry decisions"""
    attempt: int
    error: Exception
    elapsed_time: float
    previous_errors: List[Exception]

class SmartRetry:
    """Intelligent retry with adaptive behavior"""

    def __init__(self, config: RetryConfig = None):
        self.config = config or RetryConfig()
        self.decision_handlers: List[Callable[[RetryContext], RetryDecision]] = []

    def add_decision_handler(self, handler: Callable[[RetryContext], RetryDecision]):
        """Add custom logic for retry decisions"""
        self.decision_handlers.append(handler)
        return self

    def execute(self, func: Callable, *args, **kwargs) -> Any:
        """Execute with smart retry logic"""

        errors: List[Exception] = []
        start_time = time.time()

        for attempt in range(1, self.config.max_attempts + 1):
            try:
                return func(*args, **kwargs)

            except Exception as e:
                errors.append(e)
                elapsed = time.time() - start_time

                context = RetryContext(
                    attempt=attempt,
                    error=e,
                    elapsed_time=elapsed,
                    previous_errors=errors[:-1]
                )

                decision = self._make_decision(context)

                if decision == RetryDecision.FAIL:
                    logger.error(f"Failing after {attempt} attempts")
                    raise

                if decision == RetryDecision.MODIFY_AND_RETRY:
                    # Modification happens via kwargs update
                    pass

                if attempt < self.config.max_attempts:
                    delay = self._calculate_delay(attempt, context)
                    logger.info(f"Attempt {attempt} failed. Retrying in {delay:.2f}s")
                    time.sleep(delay)

        # Should not reach here, but just in case
        raise errors[-1] if errors else RuntimeError("Retry failed")

    def _make_decision(self, context: RetryContext) -> RetryDecision:
        """Determine whether to retry"""

        # Check custom handlers first
        for handler in self.decision_handlers:
            decision = handler(context)
            if decision != RetryDecision.RETRY:
                return decision

        # Default logic
        error = context.error

        # Don't retry certain errors
        non_retryable = [
            ValueError,
            TypeError,
            KeyError,
        ]

        if any(isinstance(error, t) for t in non_retryable):
            return RetryDecision.FAIL

        # Check if error type is in retry list
        if self.config.retry_on:
            if not any(isinstance(error, t) for t in self.config.retry_on):
                return RetryDecision.FAIL

        return RetryDecision.RETRY

    def _calculate_delay(self, attempt: int, context: RetryContext) -> float:
        """Calculate delay with adaptive behavior"""

        # Base exponential backoff
        delay = self.config.base_delay * (self.config.exponential_base ** (attempt - 1))

        # Cap at max delay
        delay = min(delay, self.config.max_delay)

        # Add jitter
        if self.config.jitter:
            delay = delay * (0.5 + random.random())

        # Adaptive: if same error repeating, increase delay
        if len(context.previous_errors) > 0:
            same_error_count = sum(
                1 for e in context.previous_errors
                if type(e) == type(context.error)
            )
            if same_error_count > 1:
                delay *= 1.5  # Longer delay for repeated errors

        return delay

LLM-Specific Retry Strategies

from openai import APIError, RateLimitError, APIConnectionError

class LLMRetryStrategy:
    """Retry strategies specific to LLM APIs"""

    @staticmethod
    def rate_limit_handler(context: RetryContext) -> RetryDecision:
        """Handle rate limit errors specially"""
        if isinstance(context.error, RateLimitError):
            # Check if we have retry-after header
            retry_after = getattr(context.error, 'retry_after', None)
            if retry_after and retry_after > 120:
                # Too long to wait
                return RetryDecision.FAIL
            return RetryDecision.RETRY
        return RetryDecision.RETRY

    @staticmethod
    def context_length_handler(context: RetryContext) -> RetryDecision:
        """Handle context length errors"""
        error_code = getattr(context.error, 'code', None)
        if error_code == 'context_length_exceeded':
            # Can't retry with same input
            return RetryDecision.MODIFY_AND_RETRY
        return RetryDecision.RETRY

    @staticmethod
    def content_filter_handler(context: RetryContext) -> RetryDecision:
        """Handle content filter errors"""
        error_code = getattr(context.error, 'code', None)
        if error_code == 'content_filter':
            # Retrying won't help
            return RetryDecision.FAIL
        return RetryDecision.RETRY

# Build LLM-optimized retry
def create_llm_retry() -> SmartRetry:
    config = RetryConfig(
        max_attempts=5,
        base_delay=1.0,
        max_delay=60.0,
        retry_on=[APIError, APIConnectionError, TimeoutError]
    )

    retry = SmartRetry(config)
    retry.add_decision_handler(LLMRetryStrategy.rate_limit_handler)
    retry.add_decision_handler(LLMRetryStrategy.context_length_handler)
    retry.add_decision_handler(LLMRetryStrategy.content_filter_handler)

    return retry

# Usage
llm_retry = create_llm_retry()

def call_with_retry(prompt: str) -> str:
    def _call():
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": prompt}]
        )
        return response.choices[0].message.content

    return llm_retry.execute(_call)

Request Modification on Retry

class AdaptiveRetry:
    """Retry with automatic request modification"""

    def __init__(self):
        self.modifiers: List[Callable[[dict, Exception], dict]] = []

    def add_modifier(self, modifier: Callable[[dict, Exception], dict]):
        """Add a request modifier for retries"""
        self.modifiers.append(modifier)
        return self

    def execute(self, func: Callable, request: dict, max_attempts: int = 3) -> Any:
        """Execute with adaptive modifications"""

        current_request = request.copy()

        for attempt in range(max_attempts):
            try:
                return func(**current_request)

            except Exception as e:
                if attempt == max_attempts - 1:
                    raise

                # Apply modifiers
                for modifier in self.modifiers:
                    current_request = modifier(current_request, e)

                logger.info(f"Modified request for retry {attempt + 2}")
                time.sleep(2 ** attempt)

def truncate_on_context_error(request: dict, error: Exception) -> dict:
    """Truncate content if context length exceeded"""
    if getattr(error, 'code', None) == 'context_length_exceeded':
        messages = request.get('messages', [])
        if messages:
            # Truncate user message
            for msg in messages:
                if msg['role'] == 'user':
                    content = msg['content']
                    msg['content'] = content[:len(content)//2] + "\n[Content truncated]"
    return request

def reduce_max_tokens(request: dict, error: Exception) -> dict:
    """Reduce max_tokens on certain errors"""
    if 'max_tokens' in request:
        request['max_tokens'] = request['max_tokens'] // 2
    return request

def switch_model_on_failure(request: dict, error: Exception) -> dict:
    """Switch to a different model on repeated failures"""
    fallback_models = {
        'gpt-4o': 'gpt-4o-mini',
        'gpt-4-turbo': 'gpt-4o',
    }
    current_model = request.get('model')
    if current_model in fallback_models:
        request['model'] = fallback_models[current_model]
        logger.info(f"Switching model from {current_model} to {request['model']}")
    return request

# Build adaptive retry
adaptive = AdaptiveRetry()
adaptive.add_modifier(truncate_on_context_error)
adaptive.add_modifier(reduce_max_tokens)
adaptive.add_modifier(switch_model_on_failure)

# Usage
result = adaptive.execute(
    client.chat.completions.create,
    {
        "model": "gpt-4o",
        "messages": [{"role": "user", "content": long_prompt}],
        "max_tokens": 4096
    }
)

Retry Budget

from datetime import datetime, timedelta
from collections import defaultdict

class RetryBudget:
    """Limit retries across the application"""

    def __init__(self, max_retries_per_minute: int = 100):
        self.max_per_minute = max_retries_per_minute
        self.retry_counts: defaultdict = defaultdict(list)

    def can_retry(self, key: str = "default") -> bool:
        """Check if retry budget allows another retry"""
        now = datetime.now()
        cutoff = now - timedelta(minutes=1)

        # Clean old entries
        self.retry_counts[key] = [
            t for t in self.retry_counts[key] if t > cutoff
        ]

        return len(self.retry_counts[key]) < self.max_per_minute

    def record_retry(self, key: str = "default"):
        """Record a retry attempt"""
        self.retry_counts[key].append(datetime.now())

    def get_usage(self, key: str = "default") -> dict:
        """Get current usage stats"""
        now = datetime.now()
        cutoff = now - timedelta(minutes=1)

        recent = [t for t in self.retry_counts[key] if t > cutoff]

        return {
            "used": len(recent),
            "limit": self.max_per_minute,
            "percentage": (len(recent) / self.max_per_minute) * 100
        }

# Global retry budget
retry_budget = RetryBudget(max_retries_per_minute=50)

def retry_with_budget(func: Callable, *args, **kwargs) -> Any:
    """Execute with retry budget enforcement"""

    if not retry_budget.can_retry():
        raise RuntimeError("Retry budget exhausted")

    try:
        return func(*args, **kwargs)
    except Exception as e:
        retry_budget.record_retry()
        raise

Smart retry strategies balance reliability with resource efficiency. The key is adapting behavior based on error type, system state, and business requirements.

Michael John Peña

Michael John Peña

Data & AI Director and Microsoft MVP based in Sydney. Writing about Microsoft Fabric, Azure, data engineering, and AI.