""" Tests for models/exceptions.py - Exception Hierarchy Tests the comprehensive exception system including: - HTTP status code assignment - Rich error context - to_http_exception() conversion - Retry-After headers - Error inheritance hierarchy """ import time import pytest from fastapi import HTTPException from models.exceptions import ( AIStudioError, # Base AIStudioProxyError, BrowserCrashedError, # Browser errors BrowserError, BrowserInitError, # Client errors ClientDisconnectedError, # Configuration errors ConfigurationError, EmptyResponseError, InvalidConfigError, InvalidModelError, InvalidParameterError, MissingConfigError, MissingParameterError, # Model errors ModelError, ModelListError, ModelSwitchError, PageNotReadyError, ProcessingTimeoutError, ProxyConnectionError, QueueFullError, QuotaExceededError, # Resource errors ResourceError, ResponseTimeoutError, SelectorNotFoundError, # Stream errors StreamError, StreamTimeoutError, # Timeout errors TimeoutError, # Upstream errors UpstreamError, # Validation errors ValidationError, ) # ==================== BASE EXCEPTION TESTS ==================== def test_base_exception_basic(): """Test basic AIStudioProxyError attributes.""" error = AIStudioProxyError(message="Test error", req_id="test123", http_status=500) assert error.message == "Test error" assert error.req_id == "test123" assert error.http_status == 500 assert error.retry_after is None assert isinstance(error.timestamp, float) assert str(error) == "[test123] Test error" def test_base_exception_with_context(): """Test AIStudioProxyError with custom context.""" error = AIStudioProxyError( message="Error", req_id="abc", custom_field="value", another_field=123 ) assert error.context == {"custom_field": "value", "another_field": 123} def test_base_exception_to_http(): """Test conversion to HTTPException.""" error = AIStudioProxyError( message="Server error", req_id="req1", http_status=503, retry_after=30 ) http_exc = error.to_http_exception() assert isinstance(http_exc, HTTPException) assert http_exc.status_code == 503 assert "[req1] Server error" in http_exc.detail assert http_exc.headers == {"Retry-After": "30"} def test_base_exception_repr(): """Test __repr__ for debugging.""" error = AIStudioProxyError( message="Test", req_id="id1", http_status=400, extra_data="value" ) repr_str = repr(error) assert "AIStudioProxyError" in repr_str assert "message='Test'" in repr_str assert "req_id='id1'" in repr_str assert "http_status=400" in repr_str assert "extra_data" in repr_str # ==================== BROWSER ERROR TESTS ==================== def test_browser_error_defaults(): """Test BrowserError default status codes.""" error = BrowserError("Page crashed") assert error.http_status == 503 assert error.retry_after == 30 def test_page_not_ready_error(): """Test PageNotReadyError with request ID.""" error = PageNotReadyError("Page lost connection", req_id="req123") assert isinstance(error, BrowserError) assert error.message == "Page lost connection" assert error.http_status == 503 def test_browser_crashed_error(): """Test BrowserCrashedError with default message.""" error = BrowserCrashedError(req_id="req456") assert error.message == "Browser crashed unexpectedly" assert error.http_status == 503 def test_selector_not_found_error(): """Test SelectorNotFoundError with selector context.""" error = SelectorNotFoundError(selector="button#submit", req_id="req789") assert "button#submit" in error.message assert error.context["selector"] == "button#submit" # ==================== MODEL ERROR TESTS ==================== def test_model_error_defaults(): """Test ModelError default status code.""" error = ModelError("Model issue") assert error.http_status == 422 def test_invalid_model_error_with_alternatives(): """Test InvalidModelError with available models list.""" error = InvalidModelError( model_id="gemini-invalid", available_models=["gemini-1.5-pro", "gemini-1.5-flash"], req_id="req1", ) assert "gemini-invalid" in error.message assert "gemini-1.5-pro" in error.message assert error.context["model_id"] == "gemini-invalid" assert error.http_status == 422 def test_model_switch_error(): """Test ModelSwitchError with source and target models.""" error = ModelSwitchError( target_model="gemini-2.0", current_model="gemini-1.5-pro", req_id="req2" ) assert "gemini-2.0" in error.message assert "gemini-1.5-pro" in error.message assert error.context["target_model"] == "gemini-2.0" # ==================== CLIENT ERROR TESTS ==================== def test_client_disconnected_error_with_stage(): """Test ClientDisconnectedError with processing stage.""" error = ClientDisconnectedError(stage="model_switching", req_id="req3") assert error.stage == "model_switching" assert error.http_status == 499 def test_client_disconnected_error_no_stage(): """Test ClientDisconnectedError without stage.""" error = ClientDisconnectedError(req_id="req4") assert "Client disconnected" in error.message assert error.stage == "" # ==================== VALIDATION ERROR TESTS ==================== def test_validation_error_defaults(): """Test ValidationError default status code.""" error = ValidationError("Invalid data") assert error.http_status == 400 def test_missing_parameter_error(): """Test MissingParameterError with parameter name.""" error = MissingParameterError(parameter="temperature", req_id="req5") assert "temperature" in error.message assert error.context["parameter"] == "temperature" def test_invalid_parameter_error(): """Test InvalidParameterError with value and reason.""" error = InvalidParameterError( parameter="temperature", value=3.5, reason="must be between 0.0 and 2.0", req_id="req6", ) assert "temperature" in error.message assert "3.5" in error.message assert "must be between 0.0 and 2.0" in error.message # ==================== STREAM ERROR TESTS ==================== def test_stream_error_defaults(): """Test StreamError default status code.""" error = StreamError("Stream failed") assert error.http_status == 502 def test_proxy_connection_error(): """Test ProxyConnectionError with proxy URL.""" error = ProxyConnectionError(proxy_url="http://127.0.0.1:3120", req_id="req7") assert "127.0.0.1:3120" in error.message assert error.context["proxy_url"] == "http://127.0.0.1:3120" def test_stream_timeout_error(): """Test StreamTimeoutError with timeout duration.""" error = StreamTimeoutError(timeout_seconds=30.0, req_id="req8") assert "30.0s" in error.message assert error.context["timeout_seconds"] == 30.0 # ==================== RESOURCE ERROR TESTS ==================== def test_resource_error_defaults(): """Test ResourceError default status and retry.""" error = ResourceError("Resource exhausted") assert error.http_status == 503 assert error.retry_after == 60 def test_queue_full_error(): """Test QueueFullError with queue size.""" error = QueueFullError(queue_size=100, req_id="req9") assert "100" in error.message assert error.context["queue_size"] == 100 def test_browser_init_error(): """Test BrowserInitError with custom message.""" error = BrowserInitError(message="Playwright installation missing", req_id="req10") assert "Playwright installation missing" in error.message # ==================== UPSTREAM ERROR TESTS ==================== def test_upstream_error_defaults(): """Test UpstreamError default status and retry.""" error = UpstreamError("Upstream issue") assert error.http_status == 502 assert error.retry_after == 10 def test_ai_studio_error(): """Test AIStudioError with AI Studio status code.""" error = AIStudioError( error_message="Internal server error", status_code=500, req_id="req11" ) assert "Internal server error" in error.message assert error.context["ai_studio_status"] == 500 def test_quota_exceeded_error(): """Test QuotaExceededError with extended retry.""" error = QuotaExceededError(req_id="req12") assert error.retry_after == 3600 # 1 hour assert "quota exceeded" in error.message.lower() def test_empty_response_error(): """Test EmptyResponseError default message.""" error = EmptyResponseError(req_id="req13") assert "empty response" in error.message.lower() # ==================== TIMEOUT ERROR TESTS ==================== def test_timeout_error_defaults(): """Test TimeoutError default status code.""" error = TimeoutError("Operation timeout") assert error.http_status == 504 def test_response_timeout_error(): """Test ResponseTimeoutError with duration.""" error = ResponseTimeoutError(timeout_seconds=300.0, req_id="req14") assert "300.0s" in error.message assert error.context["timeout_seconds"] == 300.0 def test_processing_timeout_error(): """Test ProcessingTimeoutError without duration.""" error = ProcessingTimeoutError(req_id="req15") assert "processing timeout" in error.message.lower() # ==================== CONFIGURATION ERROR TESTS ==================== def test_configuration_error_defaults(): """Test ConfigurationError default status code.""" error = ConfigurationError("Config missing") assert error.http_status == 500 def test_missing_config_error(): """Test MissingConfigError with config key.""" error = MissingConfigError(config_key="API_KEY", req_id="req16") assert "API_KEY" in error.message assert error.context["config_key"] == "API_KEY" def test_invalid_config_error(): """Test InvalidConfigError with value and reason.""" error = InvalidConfigError( config_key="PORT", value="invalid", reason="must be a number", req_id="req17" ) assert "PORT" in error.message assert "invalid" in error.message assert "must be a number" in error.message # ==================== INHERITANCE TESTS ==================== def test_error_hierarchy(): """Test that exception hierarchy is correct.""" # Browser errors inherit from BrowserError assert issubclass(PageNotReadyError, BrowserError) assert issubclass(SelectorNotFoundError, BrowserError) # All errors inherit from AIStudioProxyError assert issubclass(BrowserError, AIStudioProxyError) assert issubclass(ModelError, AIStudioProxyError) assert issubclass(ValidationError, AIStudioProxyError) assert issubclass(StreamError, AIStudioProxyError) # All errors inherit from Exception assert issubclass(AIStudioProxyError, Exception) def test_error_catchable_by_base(): """Test that specific errors can be caught by base class.""" try: raise PageNotReadyError("Test", req_id="test") except BrowserError as e: assert isinstance(e, PageNotReadyError) except Exception: pytest.fail("Should have caught as BrowserError") # ==================== HTTP EXCEPTION CONVERSION TESTS ==================== def test_to_http_exception_preserves_status(): """Test that to_http_exception() uses correct HTTP status.""" test_cases = [ (BrowserError("Browser error"), 503), (ModelError("Model error"), 422), (ValidationError("Validation error"), 400), (StreamError("Stream error"), 502), (TimeoutError("Timeout error"), 504), ] for error, expected_status in test_cases: http_exc = error.to_http_exception() assert http_exc.status_code == expected_status def test_to_http_exception_includes_retry_after(): """Test that retry_after is included in headers.""" error = ResourceError("Resource issue", retry_after=120) http_exc = error.to_http_exception() assert http_exc.headers == {"Retry-After": "120"} def test_to_http_exception_without_retry(): """Test that headers are None when no retry_after.""" error = ValidationError("Bad request") http_exc = error.to_http_exception() # Should be None, not empty dict assert http_exc.headers is None # ==================== CONTEXT PRESERVATION TESTS ==================== def test_context_preservation(): """Test that custom context is preserved.""" error = AIStudioProxyError( message="Error", req_id="test", custom_key="custom_value", another_key=42, nested={"a": 1, "b": 2}, ) assert error.context["custom_key"] == "custom_value" assert error.context["another_key"] == 42 assert error.context["nested"] == {"a": 1, "b": 2} def test_timestamp_is_recent(): """Test that timestamp is set to current time.""" before = time.time() error = AIStudioProxyError("Test") after = time.time() assert before <= error.timestamp <= after # ==================== BACKWARD COMPATIBILITY TESTS ==================== def test_client_disconnected_error_backward_compat(): """Test that ClientDisconnectedError works like old implementation.""" # Old usage (just message) error = ClientDisconnectedError("test_stage", req_id="req1") assert isinstance(error, Exception) assert "test_stage" in error.message assert error.req_id == "req1" # ==================== EDGE CASES ==================== def test_error_without_req_id(): """Test errors work without req_id.""" error = BrowserError("No request ID") assert error.req_id is None assert "No request ID" == error.message assert "[" not in str(error) # Should not have [req_id] prefix def test_error_with_empty_context(): """Test error with no additional context.""" error = AIStudioProxyError("Simple error") assert error.context == {} def test_custom_http_status_override(): """Test that custom http_status overrides default.""" error = BrowserError("Custom status", http_status=418) # I'm a teapot assert error.http_status == 418 def test_custom_retry_after_override(): """Test that custom retry_after overrides default.""" error = ResourceError("Custom retry", retry_after=5) assert error.retry_after == 5 def test_processing_timeout_error_with_duration(): """Test ProcessingTimeoutError with timeout_seconds (covers line 427).""" error = ProcessingTimeoutError(timeout_seconds=30.0, req_id="req16") # Verify timeout is included in message (line 427) assert "30.0s" in error.message assert "processing timeout" in error.message.lower() assert error.context["timeout_seconds"] == 30.0 def test_model_list_error(): """Test ModelListError initialization (covers line 196).""" error = ModelListError(message="Failed to parse models", req_id="req17") # Verify super().__init__ was called (line 196) assert error.message == "Failed to parse models" assert error.req_id == "req17" assert error.http_status == 422 # Inherits from ModelError