TradingAgents/tests/unit/test_exceptions.py

508 lines
20 KiB
Python

"""
Test suite for LLM Rate Limit Exception Hierarchy.
This module tests:
1. LLMRateLimitError base class creation with message and retry_after
2. Provider-specific exception classes (OpenAI, Anthropic, OpenRouter)
3. from_provider_error() conversion from native provider exceptions
4. Exception attribute validation (message, retry_after, provider)
5. Exception inheritance chain
"""
import pytest
from unittest.mock import Mock
from typing import Optional
pytestmark = pytest.mark.unit
# ============================================================================
# Test Utilities
# ============================================================================
def create_mock_openai_rate_limit_error(retry_after: Optional[int] = None):
"""Create a mock OpenAI RateLimitError for testing."""
error = Mock()
error.__class__.__name__ = "RateLimitError"
error.message = "Rate limit exceeded for model gpt-4"
# Mock response headers
error.response = Mock()
error.response.headers = {}
if retry_after:
error.response.headers["retry-after"] = str(retry_after)
return error
def create_mock_anthropic_rate_limit_error(retry_after: Optional[int] = None):
"""Create a mock Anthropic RateLimitError for testing."""
error = Mock()
error.__class__.__name__ = "RateLimitError"
error.message = "Your request has exceeded the rate limit"
# Mock response with retry-after header
error.response = Mock()
error.response.headers = {}
if retry_after:
error.response.headers["retry-after"] = str(retry_after)
return error
def create_mock_openrouter_rate_limit_error(retry_after: Optional[int] = None):
"""Create a mock OpenRouter RateLimitError (via OpenAI client) for testing."""
error = Mock()
error.__class__.__name__ = "RateLimitError"
error.message = "Rate limit reached for anthropic/claude-opus-4.5"
error.response = Mock()
error.response.headers = {}
if retry_after:
error.response.headers["retry-after"] = str(retry_after)
return error
# ============================================================================
# Test LLMRateLimitError Base Class
# ============================================================================
class TestLLMRateLimitError:
"""Test the base LLMRateLimitError exception class."""
def test_basic_exception_creation(self):
"""Test creating LLMRateLimitError with just a message."""
# Import will fail initially (TDD RED phase)
from tradingagents.utils.exceptions import LLMRateLimitError
error = LLMRateLimitError("Rate limit exceeded")
assert str(error) == "Rate limit exceeded"
assert error.retry_after is None
assert error.provider is None
def test_exception_with_retry_after(self):
"""Test LLMRateLimitError with retry_after parameter."""
from tradingagents.utils.exceptions import LLMRateLimitError
error = LLMRateLimitError("Rate limit exceeded", retry_after=60)
assert str(error) == "Rate limit exceeded"
assert error.retry_after == 60
assert isinstance(error.retry_after, int)
def test_exception_with_provider(self):
"""Test LLMRateLimitError with provider parameter."""
from tradingagents.utils.exceptions import LLMRateLimitError
error = LLMRateLimitError(
"Rate limit exceeded",
retry_after=120,
provider="openai"
)
assert error.provider == "openai"
assert error.retry_after == 120
def test_exception_inheritance(self):
"""Test that LLMRateLimitError inherits from Exception."""
from tradingagents.utils.exceptions import LLMRateLimitError
error = LLMRateLimitError("Test")
assert isinstance(error, Exception)
assert isinstance(error, LLMRateLimitError)
def test_exception_with_none_retry_after(self):
"""Test that retry_after can be None."""
from tradingagents.utils.exceptions import LLMRateLimitError
error = LLMRateLimitError("Rate limit", retry_after=None)
assert error.retry_after is None
# ============================================================================
# Test Provider-Specific Exceptions
# ============================================================================
class TestOpenAIRateLimitError:
"""Test OpenAI-specific rate limit error."""
def test_openai_exception_creation(self):
"""Test creating OpenAIRateLimitError."""
from tradingagents.utils.exceptions import OpenAIRateLimitError, LLMRateLimitError
error = OpenAIRateLimitError("OpenAI rate limit", retry_after=45)
assert isinstance(error, LLMRateLimitError)
assert str(error) == "OpenAI rate limit"
assert error.retry_after == 45
assert error.provider == "openai"
def test_openai_exception_inherits_base(self):
"""Test that OpenAIRateLimitError inherits from LLMRateLimitError."""
from tradingagents.utils.exceptions import OpenAIRateLimitError, LLMRateLimitError
error = OpenAIRateLimitError("Test")
assert isinstance(error, LLMRateLimitError)
assert isinstance(error, Exception)
class TestAnthropicRateLimitError:
"""Test Anthropic-specific rate limit error."""
def test_anthropic_exception_creation(self):
"""Test creating AnthropicRateLimitError."""
from tradingagents.utils.exceptions import AnthropicRateLimitError, LLMRateLimitError
error = AnthropicRateLimitError("Anthropic rate limit", retry_after=90)
assert isinstance(error, LLMRateLimitError)
assert str(error) == "Anthropic rate limit"
assert error.retry_after == 90
assert error.provider == "anthropic"
def test_anthropic_exception_inherits_base(self):
"""Test that AnthropicRateLimitError inherits from LLMRateLimitError."""
from tradingagents.utils.exceptions import AnthropicRateLimitError, LLMRateLimitError
error = AnthropicRateLimitError("Test")
assert isinstance(error, LLMRateLimitError)
assert isinstance(error, Exception)
class TestOpenRouterRateLimitError:
"""Test OpenRouter-specific rate limit error."""
def test_openrouter_exception_creation(self):
"""Test creating OpenRouterRateLimitError."""
from tradingagents.utils.exceptions import OpenRouterRateLimitError, LLMRateLimitError
error = OpenRouterRateLimitError("OpenRouter rate limit", retry_after=30)
assert isinstance(error, LLMRateLimitError)
assert str(error) == "OpenRouter rate limit"
assert error.retry_after == 30
assert error.provider == "openrouter"
def test_openrouter_exception_inherits_base(self):
"""Test that OpenRouterRateLimitError inherits from LLMRateLimitError."""
from tradingagents.utils.exceptions import OpenRouterRateLimitError, LLMRateLimitError
error = OpenRouterRateLimitError("Test")
assert isinstance(error, LLMRateLimitError)
assert isinstance(error, Exception)
# ============================================================================
# Test from_provider_error() Conversion
# ============================================================================
class TestProviderErrorConversion:
"""Test conversion from native provider errors to unified exceptions."""
def test_convert_openai_error_with_retry_after(self):
"""Test converting OpenAI RateLimitError with retry-after header."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = create_mock_openai_rate_limit_error(retry_after=60)
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after == 60
assert converted.provider == "openai"
assert "Rate limit exceeded" in str(converted)
def test_convert_openai_error_without_retry_after(self):
"""Test converting OpenAI RateLimitError without retry-after header."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = create_mock_openai_rate_limit_error(retry_after=None)
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after is None
assert converted.provider == "openai"
def test_convert_anthropic_error_with_retry_after(self):
"""Test converting Anthropic RateLimitError with retry-after header."""
from tradingagents.utils.exceptions import from_provider_error, AnthropicRateLimitError
mock_error = create_mock_anthropic_rate_limit_error(retry_after=120)
converted = from_provider_error(mock_error, provider="anthropic")
assert isinstance(converted, AnthropicRateLimitError)
assert converted.retry_after == 120
assert converted.provider == "anthropic"
def test_convert_anthropic_error_without_retry_after(self):
"""Test converting Anthropic RateLimitError without retry-after header."""
from tradingagents.utils.exceptions import from_provider_error, AnthropicRateLimitError
mock_error = create_mock_anthropic_rate_limit_error(retry_after=None)
converted = from_provider_error(mock_error, provider="anthropic")
assert isinstance(converted, AnthropicRateLimitError)
assert converted.retry_after is None
def test_convert_openrouter_error_with_retry_after(self):
"""Test converting OpenRouter RateLimitError with retry-after header."""
from tradingagents.utils.exceptions import from_provider_error, OpenRouterRateLimitError
mock_error = create_mock_openrouter_rate_limit_error(retry_after=45)
converted = from_provider_error(mock_error, provider="openrouter")
assert isinstance(converted, OpenRouterRateLimitError)
assert converted.retry_after == 45
assert converted.provider == "openrouter"
def test_convert_openrouter_error_without_retry_after(self):
"""Test converting OpenRouter RateLimitError without retry-after header."""
from tradingagents.utils.exceptions import from_provider_error, OpenRouterRateLimitError
mock_error = create_mock_openrouter_rate_limit_error(retry_after=None)
converted = from_provider_error(mock_error, provider="openrouter")
assert isinstance(converted, OpenRouterRateLimitError)
assert converted.retry_after is None
def test_convert_unknown_provider(self):
"""Test converting error from unknown provider defaults to base class."""
from tradingagents.utils.exceptions import from_provider_error, LLMRateLimitError
mock_error = create_mock_openai_rate_limit_error(retry_after=30)
converted = from_provider_error(mock_error, provider="unknown")
# Should return base LLMRateLimitError for unknown providers
assert isinstance(converted, LLMRateLimitError)
assert converted.provider == "unknown"
def test_convert_non_rate_limit_error(self):
"""Test that non-rate-limit errors are not converted."""
from tradingagents.utils.exceptions import from_provider_error
mock_error = Mock()
mock_error.__class__.__name__ = "APIError"
mock_error.message = "API connection failed"
# Should return None or raise ValueError for non-rate-limit errors
with pytest.raises(ValueError, match="Not a rate limit error"):
from_provider_error(mock_error, provider="openai")
def test_extract_retry_after_from_string(self):
"""Test extracting retry_after when it's a string in headers."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.message = "Rate limit exceeded"
mock_error.response = Mock()
mock_error.response.headers = {"retry-after": "75"}
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after == 75
assert isinstance(converted.retry_after, int)
def test_extract_retry_after_from_int(self):
"""Test extracting retry_after when it's already an int in headers."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.message = "Rate limit exceeded"
mock_error.response = Mock()
mock_error.response.headers = {"retry-after": 90}
converted = from_provider_error(mock_error, provider="openai")
assert converted.retry_after == 90
# ============================================================================
# Edge Cases and Error Handling
# ============================================================================
class TestExceptionEdgeCases:
"""Test edge cases and error handling in exception conversion."""
def test_missing_response_object(self):
"""Test handling error with no response object."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.message = "Rate limit exceeded"
mock_error.response = None
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after is None
def test_missing_headers_object(self):
"""Test handling error with response but no headers."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.message = "Rate limit exceeded"
mock_error.response = Mock()
mock_error.response.headers = None
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after is None
def test_invalid_retry_after_string(self):
"""Test handling invalid retry-after value (non-numeric string)."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.message = "Rate limit exceeded"
mock_error.response = Mock()
mock_error.response.headers = {"retry-after": "invalid"}
converted = from_provider_error(mock_error, provider="openai")
# Should gracefully handle invalid values
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after is None
def test_negative_retry_after(self):
"""Test handling negative retry-after value."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.message = "Rate limit exceeded"
mock_error.response = Mock()
mock_error.response.headers = {"retry-after": "-10"}
converted = from_provider_error(mock_error, provider="openai")
# Should either convert to positive or set to None
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after is None or converted.retry_after >= 0
def test_zero_retry_after(self):
"""Test handling zero retry-after value."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.message = "Rate limit exceeded"
mock_error.response = Mock()
mock_error.response.headers = {"retry-after": "0"}
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after == 0
def test_very_large_retry_after(self):
"""Test handling very large retry-after value."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.message = "Rate limit exceeded"
mock_error.response = Mock()
mock_error.response.headers = {"retry-after": "86400"} # 24 hours
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, OpenAIRateLimitError)
assert converted.retry_after == 86400
def test_message_extraction_from_str(self):
"""Test extracting message when error has __str__ instead of message attribute."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = Mock()
mock_error.__class__.__name__ = "RateLimitError"
mock_error.__str__ = Mock(return_value="Rate limit from __str__")
del mock_error.message # Remove message attribute
mock_error.response = Mock()
mock_error.response.headers = {}
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, OpenAIRateLimitError)
assert "Rate limit from __str__" in str(converted)
# ============================================================================
# Integration Tests
# ============================================================================
class TestExceptionIntegration:
"""Test exception usage in realistic scenarios."""
def test_catch_and_reraise_pattern(self):
"""Test the typical catch-and-reraise pattern."""
from tradingagents.utils.exceptions import from_provider_error, OpenAIRateLimitError
mock_error = create_mock_openai_rate_limit_error(retry_after=60)
try:
converted = from_provider_error(mock_error, provider="openai")
raise converted
except OpenAIRateLimitError as e:
assert e.retry_after == 60
assert e.provider == "openai"
def test_exception_in_except_block(self):
"""Test using from_provider_error in an except block."""
from tradingagents.utils.exceptions import from_provider_error, LLMRateLimitError
mock_error = create_mock_openai_rate_limit_error(retry_after=45)
try:
# Simulate catching a provider error
raise Exception("Simulated OpenAI error")
except Exception:
# Convert to our exception
converted = from_provider_error(mock_error, provider="openai")
assert isinstance(converted, LLMRateLimitError)
def test_multiple_provider_errors(self):
"""Test handling errors from multiple providers in sequence."""
from tradingagents.utils.exceptions import (
from_provider_error,
OpenAIRateLimitError,
AnthropicRateLimitError,
OpenRouterRateLimitError
)
openai_error = create_mock_openai_rate_limit_error(retry_after=30)
anthropic_error = create_mock_anthropic_rate_limit_error(retry_after=60)
openrouter_error = create_mock_openrouter_rate_limit_error(retry_after=90)
openai_converted = from_provider_error(openai_error, provider="openai")
anthropic_converted = from_provider_error(anthropic_error, provider="anthropic")
openrouter_converted = from_provider_error(openrouter_error, provider="openrouter")
assert isinstance(openai_converted, OpenAIRateLimitError)
assert isinstance(anthropic_converted, AnthropicRateLimitError)
assert isinstance(openrouter_converted, OpenRouterRateLimitError)
assert openai_converted.retry_after == 30
assert anthropic_converted.retry_after == 60
assert openrouter_converted.retry_after == 90