506 lines
20 KiB
Python
506 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
|
|
|
|
|
|
# ============================================================================
|
|
# 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
|