97e91a83f3
Ruff / Ruff (push) Has been cancelled
Test / Core Tests (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.10) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.11) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.12) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.13) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.9) (push) Has been cancelled
Test / Full Coverage (Python 3.11) (push) Has been cancelled
Test / Core Provider Tests (OpenAI) (push) Has been cancelled
Test / Core Provider Tests (Anthropic) (push) Has been cancelled
Test / Core Provider Tests (Google) (push) Has been cancelled
Test / Core Provider Tests (Other) (push) Has been cancelled
Test / Anthropic Tests (push) Has been cancelled
Test / Gemini Tests (push) Has been cancelled
Test / Google GenAI Tests (push) Has been cancelled
Test / Vertex AI Tests (push) Has been cancelled
Test / OpenAI Tests (push) Has been cancelled
Test / Writer Tests (push) Has been cancelled
Test / Auto Client Tests (push) Has been cancelled
ty / type-check (push) Has been cancelled
522 lines
17 KiB
Python
522 lines
17 KiB
Python
"""
|
|
Tenacity Retry Logic Benchmarks with Instructor
|
|
|
|
This script demonstrates and benchmarks different retry patterns for LLM processing:
|
|
- Basic retry with exponential backoff
|
|
- Conditional retries for specific errors
|
|
- Validation error retries
|
|
- Custom retry conditions
|
|
- Rate limit handling
|
|
- Network error recovery
|
|
- Logging and monitoring
|
|
- Circuit breaker patterns
|
|
|
|
Run this script to see retry behavior and verify all code examples work.
|
|
"""
|
|
|
|
import instructor
|
|
from tenacity import (
|
|
retry,
|
|
stop_after_attempt,
|
|
wait_exponential,
|
|
retry_if_exception_type,
|
|
retry_if_result,
|
|
before_log,
|
|
after_log,
|
|
wait_random_exponential,
|
|
)
|
|
from pydantic import BaseModel, field_validator, ValidationError
|
|
from openai import OpenAI, RateLimitError, APIError
|
|
import time
|
|
import logging
|
|
import random
|
|
import os
|
|
from functools import lru_cache
|
|
import httpx
|
|
|
|
# Set up logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Set up the client with Instructor
|
|
client = instructor.from_openai(OpenAI())
|
|
|
|
|
|
class UserInfo(BaseModel):
|
|
name: str
|
|
age: int
|
|
email: str
|
|
|
|
@field_validator("age")
|
|
@classmethod
|
|
def validate_age(cls, v):
|
|
if v < 0 or v > 150:
|
|
raise ValueError(f"Age {v} is invalid")
|
|
return v
|
|
|
|
@field_validator("email")
|
|
@classmethod
|
|
def validate_email(cls, v):
|
|
if "@" not in v:
|
|
raise ValueError(f"Invalid email: {v}")
|
|
return v.lower()
|
|
|
|
|
|
# Sample data for testing
|
|
test_texts = [
|
|
"John is 30 years old with email john@example.com",
|
|
"Sarah is 25 with email sarah@test.com",
|
|
"Mike is 35 and his email is mike@demo.org",
|
|
"Alice is 28 with email alice@example.com",
|
|
"Bob is 32 with email bob@test.com",
|
|
]
|
|
|
|
|
|
# Error simulation for testing
|
|
class MockError:
|
|
def __init__(self):
|
|
self.call_count = 0
|
|
self.fail_until = 2 # Fail first 2 calls, succeed on 3rd
|
|
|
|
def maybe_fail(self):
|
|
self.call_count += 1
|
|
if self.call_count <= self.fail_until:
|
|
# Simulate different types of errors
|
|
error_type = random.choice(
|
|
[ValidationError, RateLimitError, APIError, Exception]
|
|
)
|
|
if error_type == ValidationError:
|
|
raise ValidationError.from_exception_data("UserInfo", [])
|
|
elif error_type == RateLimitError:
|
|
# Create a simple mock response for RateLimitError
|
|
mock_response = httpx.Response(
|
|
status_code=429, headers={}, content=b"Rate limit exceeded"
|
|
)
|
|
raise RateLimitError(
|
|
"Rate limit exceeded",
|
|
response=mock_response,
|
|
body="Rate limit exceeded",
|
|
)
|
|
elif error_type == APIError:
|
|
# Create a simple mock request for APIError
|
|
mock_request = httpx.Request(
|
|
"POST", "https://api.openai.com/v1/chat/completions"
|
|
)
|
|
raise APIError(
|
|
"API error occurred",
|
|
request=mock_request,
|
|
body="API error occurred",
|
|
)
|
|
else:
|
|
raise Exception("Generic error occurred")
|
|
|
|
|
|
mock_error = MockError()
|
|
|
|
|
|
def extract_user_info_with_mock_errors(text: str) -> UserInfo:
|
|
"""Extract user info with simulated errors for testing."""
|
|
if not os.getenv("OPENAI_API_KEY"):
|
|
# Simulate errors for testing when no API key
|
|
mock_error.maybe_fail()
|
|
# Return mock data if no errors
|
|
return UserInfo(name="Mock User", age=30, email="mock@example.com")
|
|
|
|
return client.chat.completions.create(
|
|
model="gpt-4o-mini",
|
|
response_model=UserInfo,
|
|
messages=[{"role": "user", "content": f"Extract user info: {text}"}],
|
|
)
|
|
|
|
|
|
# Method 1: Basic Retry with Exponential Backoff
|
|
@retry(
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential(multiplier=1, min=1, max=5), # Shorter waits for demo
|
|
)
|
|
def extract_user_info(text: str) -> UserInfo:
|
|
"""Extract user information with basic retry logic."""
|
|
print(f" Attempting extraction for: {text[:30]}...")
|
|
if not os.getenv("OPENAI_API_KEY"):
|
|
mock_error.maybe_fail()
|
|
return UserInfo(name="Test User", age=25, email="test@example.com")
|
|
|
|
return client.chat.completions.create(
|
|
model="gpt-4o-mini",
|
|
response_model=UserInfo,
|
|
messages=[{"role": "user", "content": f"Extract user info: {text}"}],
|
|
)
|
|
|
|
|
|
# Method 2: Conditional Retries for Specific Errors
|
|
@retry(
|
|
retry=retry_if_exception_type((RateLimitError, APIError)),
|
|
stop=stop_after_attempt(5),
|
|
wait=wait_exponential(multiplier=1, min=1, max=5),
|
|
)
|
|
def robust_extraction(text: str) -> UserInfo:
|
|
"""Retry only on specific API errors."""
|
|
print(f" Robust extraction for: {text[:30]}...")
|
|
return extract_user_info_with_mock_errors(text)
|
|
|
|
|
|
# Method 3: Validation Error Retries
|
|
@retry(
|
|
retry=retry_if_exception_type(ValidationError),
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential(multiplier=1, min=1, max=3),
|
|
)
|
|
def extract_with_validation(text: str) -> UserInfo:
|
|
"""Retry when Pydantic validation fails."""
|
|
print(f" Validation retry for: {text[:30]}...")
|
|
return extract_user_info_with_mock_errors(text)
|
|
|
|
|
|
# Method 4: Custom Retry Conditions
|
|
def should_retry(result: UserInfo) -> bool:
|
|
"""Custom retry logic based on result content."""
|
|
# Retry if age is invalid or email is missing
|
|
return result.age < 18 or result.age > 100 or not result.email
|
|
|
|
|
|
@retry(
|
|
retry=retry_if_result(should_retry),
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential(multiplier=1, min=1, max=3),
|
|
)
|
|
def extract_valid_user(text: str) -> UserInfo:
|
|
"""Retry based on result validation."""
|
|
print(f" Custom retry for: {text[:30]}...")
|
|
# Simulate returning invalid data first time
|
|
if not hasattr(extract_valid_user, "call_count"):
|
|
extract_valid_user.call_count = 0
|
|
extract_valid_user.call_count += 1
|
|
|
|
if extract_valid_user.call_count == 1:
|
|
# Return invalid data first time
|
|
return UserInfo(name="Invalid User", age=200, email="invalid")
|
|
else:
|
|
# Return valid data on retry
|
|
return UserInfo(name="Valid User", age=30, email="valid@example.com")
|
|
|
|
|
|
# Method 5: Rate Limit Specific Retry
|
|
@retry(
|
|
retry=retry_if_exception_type(RateLimitError),
|
|
stop=stop_after_attempt(5),
|
|
wait=wait_exponential(multiplier=2, min=1, max=10),
|
|
before_sleep=lambda retry_state: print(
|
|
f" Rate limited, waiting... (attempt {retry_state.attempt_number})"
|
|
),
|
|
)
|
|
def rate_limit_safe_extraction(text: str) -> UserInfo:
|
|
"""Handle rate limits with longer delays."""
|
|
print(f" Rate limit safe for: {text[:30]}...")
|
|
return extract_user_info_with_mock_errors(text)
|
|
|
|
|
|
# Method 6: Network Error Retry
|
|
@retry(
|
|
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
|
|
stop=stop_after_attempt(4),
|
|
wait=wait_random_exponential(multiplier=1, min=1, max=5),
|
|
)
|
|
def network_resilient_extraction(text: str) -> UserInfo:
|
|
"""Handle network issues with random exponential backoff."""
|
|
print(f" Network resilient for: {text[:30]}...")
|
|
return extract_user_info_with_mock_errors(text)
|
|
|
|
|
|
# Method 7: Logging and Monitoring
|
|
@retry(
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential(multiplier=1, min=1, max=5),
|
|
before=before_log(logger, logging.INFO),
|
|
after=after_log(logger, logging.ERROR),
|
|
)
|
|
def logged_extraction(text: str) -> UserInfo:
|
|
"""Extract with comprehensive logging."""
|
|
print(f" Logged extraction for: {text[:30]}...")
|
|
return extract_user_info_with_mock_errors(text)
|
|
|
|
|
|
# Method 8: Circuit Breaker Pattern
|
|
@lru_cache(maxsize=1)
|
|
def get_client():
|
|
"""Cache the client to avoid repeated initialization."""
|
|
return instructor.from_openai(OpenAI())
|
|
|
|
|
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=5))
|
|
def circuit_breaker_extraction(text: str) -> UserInfo:
|
|
"""Extract with circuit breaker pattern."""
|
|
print(f" Circuit breaker for: {text[:30]}...")
|
|
client = get_client()
|
|
return extract_user_info_with_mock_errors(text)
|
|
|
|
|
|
# Method 9: Performance Monitoring
|
|
@retry(stop=stop_after_attempt(3))
|
|
def monitored_extraction(text: str) -> UserInfo:
|
|
"""Extract with performance monitoring."""
|
|
start_time = time.time()
|
|
|
|
try:
|
|
print(f" Monitored extraction for: {text[:30]}...")
|
|
result = extract_user_info_with_mock_errors(text)
|
|
|
|
end_time = time.time()
|
|
print(f" Extraction took {end_time - start_time:.2f} seconds")
|
|
return result
|
|
|
|
except Exception as e:
|
|
end_time = time.time()
|
|
print(f" Extraction failed after {end_time - start_time:.2f} seconds: {e}")
|
|
raise
|
|
|
|
|
|
def benchmark_retry_methods():
|
|
"""Test all retry methods and measure their behavior."""
|
|
print("=== Python Tenacity Retry Logic with Instructor Benchmarks ===\n")
|
|
|
|
if not os.getenv("OPENAI_API_KEY"):
|
|
print("⚠️ OPENAI_API_KEY not set. Using mock responses for demonstration.\n")
|
|
|
|
# Test different retry strategies
|
|
strategies = [
|
|
("Basic Retry", extract_user_info),
|
|
("Conditional Retry", robust_extraction),
|
|
("Validation Retry", extract_with_validation),
|
|
("Custom Retry", extract_valid_user),
|
|
("Rate Limit Retry", rate_limit_safe_extraction),
|
|
("Network Retry", network_resilient_extraction),
|
|
("Logged Retry", logged_extraction),
|
|
("Circuit Breaker", circuit_breaker_extraction),
|
|
("Monitored Retry", monitored_extraction),
|
|
]
|
|
|
|
results = {}
|
|
test_text = test_texts[0] # Use first text for all tests
|
|
|
|
for name, strategy in strategies:
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Testing: {name}")
|
|
print("=" * 60)
|
|
|
|
# Reset mock error for each test
|
|
global mock_error
|
|
mock_error = MockError()
|
|
|
|
# Reset call count for custom retry
|
|
if hasattr(extract_valid_user, "call_count"):
|
|
delattr(extract_valid_user, "call_count")
|
|
|
|
start_time = time.time()
|
|
try:
|
|
user = strategy(test_text)
|
|
end_time = time.time()
|
|
duration = end_time - start_time
|
|
|
|
results[name] = {
|
|
"success": True,
|
|
"duration": duration,
|
|
"user": user,
|
|
"attempts": getattr(mock_error, "call_count", 1),
|
|
}
|
|
|
|
print(f"✓ Success: {user.name} ({duration:.2f}s)")
|
|
print(f" Age: {user.age}, Email: {user.email}")
|
|
print(f" Attempts made: {results[name]['attempts']}")
|
|
|
|
except Exception as e:
|
|
end_time = time.time()
|
|
duration = end_time - start_time
|
|
|
|
results[name] = {
|
|
"success": False,
|
|
"duration": duration,
|
|
"error": str(e),
|
|
"attempts": getattr(mock_error, "call_count", 1),
|
|
}
|
|
|
|
print(f"✗ Failed: {e} ({duration:.2f}s)")
|
|
print(f" Attempts made: {results[name]['attempts']}")
|
|
|
|
# Print summary table
|
|
print(f"\n{'=' * 80}")
|
|
print("RETRY STRATEGY SUMMARY")
|
|
print("=" * 80)
|
|
print(
|
|
f"{'Strategy':<20} {'Status':<10} {'Time (s)':<10} {'Attempts':<10} {'Result'}"
|
|
)
|
|
print("-" * 80)
|
|
|
|
for name, result in results.items():
|
|
status = "✓ Success" if result["success"] else "✗ Failed"
|
|
attempts = result["attempts"]
|
|
|
|
if result["success"]:
|
|
result_text = f"{result['user'].name}"
|
|
else:
|
|
result_text = "Failed"
|
|
|
|
print(
|
|
f"{name:<20} {status:<10} {result['duration']:<10.2f} {attempts:<10} {result_text}"
|
|
)
|
|
|
|
# Show retry efficiency
|
|
print(f"\nRetry Efficiency Analysis:")
|
|
successful_strategies = {k: v for k, v in results.items() if v["success"]}
|
|
|
|
if successful_strategies:
|
|
avg_attempts = sum(r["attempts"] for r in successful_strategies.values()) / len(
|
|
successful_strategies
|
|
)
|
|
avg_duration = sum(r["duration"] for r in successful_strategies.values()) / len(
|
|
successful_strategies
|
|
)
|
|
|
|
print(f" Average attempts: {avg_attempts:.1f}")
|
|
print(f" Average duration: {avg_duration:.2f}s")
|
|
|
|
# Find most efficient strategy
|
|
most_efficient = min(
|
|
successful_strategies.items(),
|
|
key=lambda x: x[1]["attempts"] * x[1]["duration"],
|
|
)
|
|
print(
|
|
f" Most efficient: {most_efficient[0]} ({most_efficient[1]['attempts']} attempts, {most_efficient[1]['duration']:.2f}s)"
|
|
)
|
|
|
|
|
|
def test_batch_processing():
|
|
"""Test batch processing with retries."""
|
|
print(f"\n{'=' * 60}")
|
|
print("Batch Processing Test")
|
|
print("=" * 60)
|
|
|
|
@retry(stop=stop_after_attempt(2))
|
|
def process_batch(texts: list[str]) -> list[UserInfo]:
|
|
"""Process multiple texts with retry logic."""
|
|
results = []
|
|
|
|
for text in texts:
|
|
try:
|
|
# Reset mock error for each item
|
|
global mock_error
|
|
mock_error = MockError()
|
|
|
|
result = extract_user_info_with_mock_errors(text)
|
|
results.append(result)
|
|
print(f" ✓ Processed: {result.name}")
|
|
except Exception as e:
|
|
print(f" ✗ Failed to process: {text[:30]}... - {e}")
|
|
continue
|
|
|
|
return results
|
|
|
|
start_time = time.time()
|
|
try:
|
|
results = process_batch(test_texts[:3]) # Process first 3 texts
|
|
end_time = time.time()
|
|
duration = end_time - start_time
|
|
|
|
print(f"\nBatch processing completed:")
|
|
print(f" Successfully processed: {len(results)}/{len(test_texts[:3])} items")
|
|
print(f" Total time: {duration:.2f} seconds")
|
|
print(f" Average time per item: {duration / len(test_texts[:3]):.2f} seconds")
|
|
|
|
except Exception as e:
|
|
print(f"Batch processing failed: {e}")
|
|
|
|
|
|
def demonstrate_error_types():
|
|
"""Demonstrate handling different error types."""
|
|
print(f"\n{'=' * 60}")
|
|
print("Error Type Demonstration")
|
|
print("=" * 60)
|
|
|
|
# Simulate different error scenarios
|
|
error_scenarios = [
|
|
("Validation Error", ValidationError),
|
|
("Rate Limit Error", RateLimitError),
|
|
("API Error", APIError),
|
|
("Generic Error", Exception),
|
|
]
|
|
|
|
for error_name, error_type in error_scenarios:
|
|
print(f"\nTesting {error_name}:")
|
|
|
|
def create_error_handler(error_type):
|
|
@retry(
|
|
retry=retry_if_exception_type(error_type),
|
|
stop=stop_after_attempt(3),
|
|
wait=wait_exponential(multiplier=1, min=0.5, max=2),
|
|
)
|
|
def handle_specific_error():
|
|
# Simulate the specific error type
|
|
if error_type == ValidationError:
|
|
raise ValidationError.from_exception_data("UserInfo", [])
|
|
elif error_type == RateLimitError:
|
|
# Create a simple mock response for RateLimitError
|
|
mock_response = httpx.Response(
|
|
status_code=429, headers={}, content=b"Rate limit exceeded"
|
|
)
|
|
raise RateLimitError(
|
|
"Rate limit exceeded",
|
|
response=mock_response,
|
|
body="Rate limit exceeded",
|
|
)
|
|
elif error_type == APIError:
|
|
# Create a simple mock request for APIError
|
|
mock_request = httpx.Request(
|
|
"POST", "https://api.openai.com/v1/chat/completions"
|
|
)
|
|
raise APIError(
|
|
"API error occurred",
|
|
request=mock_request,
|
|
body="API error occurred",
|
|
)
|
|
else:
|
|
raise Exception("Generic error occurred")
|
|
|
|
return handle_specific_error
|
|
|
|
error_handler = create_error_handler(error_type)
|
|
|
|
try:
|
|
error_handler()
|
|
except Exception as e:
|
|
print(f" Expected failure: {type(e).__name__}: {e}")
|
|
|
|
|
|
def main():
|
|
"""Main function to run all benchmarks and demonstrations."""
|
|
try:
|
|
benchmark_retry_methods()
|
|
test_batch_processing()
|
|
demonstrate_error_types()
|
|
|
|
print(f"\n{'=' * 80}")
|
|
print("🎉 All tenacity retry patterns demonstrated successfully!")
|
|
print("💡 Key takeaways:")
|
|
print(" - Different retry strategies serve different purposes")
|
|
print(" - Exponential backoff prevents overwhelming APIs")
|
|
print(" - Conditional retries optimize for specific error types")
|
|
print(" - Monitoring helps debug and optimize retry behavior")
|
|
print("=" * 80)
|
|
|
|
except KeyboardInterrupt:
|
|
print("\n⚠️ Interrupted by user")
|
|
except Exception as e:
|
|
print(f"❌ Error: {e}")
|
|
logger.exception("Unexpected error occurred")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("🚀 Starting tenacity retry benchmarks with Instructor...")
|
|
print("💡 This script demonstrates retry patterns with simulated errors")
|
|
print("⏱️ Each test includes artificial delays and error scenarios\n")
|
|
|
|
main()
|