import logging from datetime import datetime, timezone from unittest.mock import Mock, patch import httpx import requests from arcade_core.errors import ( ErrorKind, FatalToolError, NetworkTransportError, UpstreamError, UpstreamRateLimitError, ) from arcade_tdk.providers.http.error_adapter import BaseHTTPErrorMapper, HTTPErrorAdapter class TestBaseHTTPErrorMapper: """Test the base HTTP error mapper functionality.""" def setup_method(self): self.mapper = BaseHTTPErrorMapper() def test_parse_retry_ms_with_retry_after_seconds(self): """Test parsing retry-after header with seconds value.""" headers = {"retry-after": "60"} result = self.mapper._parse_retry_ms(headers) assert result == 60_000 def test_parse_retry_ms_with_x_ratelimit_reset(self): """Test parsing x-ratelimit-reset header with seconds value.""" headers = {"x-ratelimit-reset": "120"} result = self.mapper._parse_retry_ms(headers) assert result == 120_000 def test_parse_retry_ms_with_x_ratelimit_reset_ms(self): """Test parsing x-ratelimit-reset-ms header with milliseconds value.""" headers = {"x-ratelimit-reset-ms": "5000"} result = self.mapper._parse_retry_ms(headers) assert result == 5_000 def test_parse_retry_ms_with_date_format(self): """Test parsing retry header with absolute date format.""" future_date = "Mon, 01 Jan 2029 12:00:00 GMT" headers = {"retry-after": future_date} with patch("arcade_tdk.providers.http.error_adapter.datetime") as mock_datetime: parsed_date = datetime(2029, 1, 1, 12, 0, 0, tzinfo=timezone.utc) mock_datetime.strptime.return_value = parsed_date # Mock datetime.now() to return a time before the parsed date current_time = datetime(2029, 1, 1, 11, 59, 0, tzinfo=timezone.utc) mock_datetime.now.return_value = current_time mock_datetime.timezone = timezone result = self.mapper._parse_retry_ms(headers) assert result == 60_000 # 1 minute diff def test_parse_retry_ms_no_headers(self): """Test parsing retry when no rate limit headers are present.""" headers = {"content-type": "application/json"} result = self.mapper._parse_retry_ms(headers) assert result == 1_000 def test_parse_retry_ms_invalid_date(self): """Test parsing retry with invalid date format falls back to default.""" headers = {"retry-after": "invalid-date"} result = self.mapper._parse_retry_ms(headers) assert result == 1_000 def test_sanitize_uri_removes_query_params(self): """Test URI sanitization removes query parameters.""" uri = "https://api.example.com/users/123?token=secret&filter=active" result = self.mapper._sanitize_uri(uri) assert result == "https://api.example.com/users/123" def test_sanitize_uri_removes_fragments(self): """Test URI sanitization removes fragments.""" uri = "https://api.example.com/users#section" result = self.mapper._sanitize_uri(uri) assert result == "https://api.example.com/users" def test_sanitize_uri_handles_trailing_slashes(self): """Test URI sanitization handles trailing slashes.""" uri = "https://api.example.com///path///" result = self.mapper._sanitize_uri(uri) assert result == "https://api.example.com/path" def test_build_extra_metadata_basic(self): """Test building extra metadata without request info.""" result = self.mapper._build_extra_metadata() assert result == {"service": "_http"} def test_build_extra_metadata_with_url_and_method(self): """Test building extra metadata with URL and method.""" result = self.mapper._build_extra_metadata( request_url="https://api.example.com/test?secret=123", request_method="post" ) expected = { "service": "_http", "endpoint": "https://api.example.com/test", "http_method": "POST", } assert result == expected def test_map_status_to_error_rate_limit(self): """Test mapping 429 status to rate limit error.""" headers = {"retry-after": "30"} result = self.mapper._map_status_to_error( status=429, headers=headers, msg="Rate limit exceeded", request_url="https://api.example.com/test", request_method="GET", ) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 30_000 assert result.message == "Rate limit exceeded" assert result.extra["service"] == "_http" assert result.extra["endpoint"] == "https://api.example.com/test" assert result.extra["http_method"] == "GET" def test_map_status_to_error_generic(self): """Test mapping generic HTTP status to upstream error.""" headers = {} result = self.mapper._map_status_to_error( status=500, headers=headers, msg="Internal server error", request_url="https://api.example.com/test", request_method="POST", ) assert isinstance(result, UpstreamError) assert not isinstance(result, UpstreamRateLimitError) assert result.status_code == 500 assert result.message == "Internal server error" assert result.extra["service"] == "_http" assert result.extra["endpoint"] == "https://api.example.com/test" assert result.extra["http_method"] == "POST" class TestHTTPErrorAdapter: """Test the main HTTP error adapter.""" def setup_method(self): self.adapter = HTTPErrorAdapter() def test_httpx_not_installed(self): """Test handling when httpx is not installed.""" with patch.object(self.adapter._httpx_handler, "handle_exception") as mock_handle: # Simulate what happens when httpx is not installed (returns None) mock_handle.return_value = None mock_exc = Exception("test exception") result = self.adapter.from_exception(mock_exc) assert result is None def test_requests_not_installed(self): """Test handling when requests is not installed.""" with patch.object(self.adapter._requests_handler, "handle_exception") as mock_handle: # Simulate what happens when requests is not installed (returns None) mock_handle.return_value = None mock_exc = Exception("test exception") result = self.adapter.from_exception(mock_exc) assert result is None def test_httpx_http_status_error_handling(self): """Test handling httpx HTTPStatusError.""" # Create a mock HTTPStatusError class and make our exception inherit from it class MockHTTPStatusError(Exception): pass # Create the exception instance that inherits from our mock class mock_response = Mock() mock_response.status_code = 404 mock_response.headers = {"content-type": "application/json"} mock_request = Mock() mock_request.url = "https://api.example.com/users/123" mock_request.method = "GET" mock_exc = MockHTTPStatusError("404 Client Error: Not Found") mock_exc.response = mock_response mock_exc.request = mock_request with patch("httpx.HTTPStatusError", MockHTTPStatusError): result = self.adapter.from_exception(mock_exc) assert isinstance(result, UpstreamError) assert result.status_code == 404 assert result.message == "Upstream HTTP request failed (Not Found, client error)." assert result.developer_message == "404 Client Error: Not Found" assert result.extra["service"] == "_http" assert result.extra["endpoint"] == "https://api.example.com/users/123" assert result.extra["http_method"] == "GET" def test_httpx_rate_limit_handling(self): """Test handling httpx 429 rate limit.""" # Create a mock HTTPStatusError class class MockHTTPStatusError(Exception): pass mock_response = Mock() mock_response.status_code = 429 mock_response.headers = {"retry-after": "60", "content-type": "application/json"} mock_request = Mock() mock_request.url = "https://api.example.com/upload" mock_request.method = "POST" mock_exc = MockHTTPStatusError("429 Too Many Requests") mock_exc.response = mock_response mock_exc.request = mock_request with patch("httpx.HTTPStatusError", MockHTTPStatusError): result = self.adapter.from_exception(mock_exc) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 60_000 assert result.message == ( "Upstream HTTP request failed (Too Many Requests, client error). " "Retry after 60 second(s)." ) assert result.developer_message == "429 Too Many Requests" assert result.extra["service"] == "_http" assert result.extra["endpoint"] == "https://api.example.com/upload" assert result.extra["http_method"] == "POST" def test_requests_http_error_handling(self): """Test handling requests HTTPError.""" # Create a mock HTTPError class class MockHTTPError(Exception): pass mock_response = Mock() mock_response.status_code = 403 mock_response.headers = {"www-authenticate": "Bearer"} mock_request = Mock() mock_request.url = "https://api.example.com/protected" mock_request.method = "GET" mock_response.request = mock_request mock_exc = MockHTTPError("403 Forbidden") mock_exc.response = mock_response with patch("requests.exceptions.HTTPError", MockHTTPError): result = self.adapter.from_exception(mock_exc) assert isinstance(result, UpstreamError) assert result.status_code == 403 assert result.message == "Upstream HTTP request failed (Forbidden, client error)." assert result.developer_message == "403 Forbidden" assert result.extra["service"] == "_http" assert result.extra["endpoint"] == "https://api.example.com/protected" assert result.extra["http_method"] == "GET" def test_requests_http_error_with_url_fallback(self): """Test handling requests HTTPError when request is not available but response.url is.""" # Create a mock HTTPError class class MockHTTPError(Exception): pass mock_response = Mock() mock_response.status_code = 500 mock_response.headers = {} mock_response.request = None # No request object mock_response.url = "https://api.example.com/server-error" mock_exc = MockHTTPError("500 Internal Server Error") mock_exc.response = mock_response with patch("requests.exceptions.HTTPError", MockHTTPError): result = self.adapter.from_exception(mock_exc) assert isinstance(result, UpstreamError) assert result.status_code == 500 assert result.message == "Upstream HTTP request failed (Internal Server Error, server error)." assert result.developer_message == "500 Internal Server Error" assert result.extra["service"] == "_http" assert result.extra["endpoint"] == "https://api.example.com/server-error" assert "http_method" not in result.extra # No method available def test_requests_http_error_no_response(self): """Test handling requests HTTPError with no response.""" # Create a mock HTTPError class class MockHTTPError(Exception): pass mock_exc = MockHTTPError("No response") mock_exc.response = None with patch("requests.exceptions.HTTPError", MockHTTPError): result = self.adapter.from_exception(mock_exc) assert result is None def test_requests_timeout_exception_handling(self): """Timeout exceptions should map to retryable NetworkTransportError (TIMEOUT).""" request = requests.Request("GET", "https://api.example.com/slow?token=secret").prepare() exc = requests.exceptions.ReadTimeout("Request timed out", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT assert result.extra["service"] == "_http" assert result.extra["error_type"] == "ReadTimeout" assert result.extra["endpoint"] == "https://api.example.com/slow" assert result.extra["http_method"] == "GET" def test_requests_transport_exception_handling(self): """Connection errors should map to NetworkTransportError (UNREACHABLE).""" request = requests.Request("POST", "https://api.example.com/ping").prepare() exc = requests.exceptions.ConnectionError("Connection failed", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNREACHABLE assert result.extra["service"] == "_http" assert result.extra["error_type"] == "ConnectionError" assert result.extra["endpoint"] == "https://api.example.com/ping" assert result.extra["http_method"] == "POST" def test_requests_invalid_url_routes_to_fatal_tool_error(self): """Invalid URL is a client construction bug — FatalToolError, not retryable.""" request = requests.Request("GET", "https://api.example.com/bad").prepare() exc = requests.exceptions.InvalidURL("Invalid URL", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert result.kind == ErrorKind.TOOL_RUNTIME_FATAL assert result.message == "HTTP request URL is invalid or malformed." assert result.extra["service"] == "_http" assert result.extra["error_type"] == "InvalidURL" assert result.extra["endpoint"] == "https://api.example.com/bad" assert result.extra["http_method"] == "GET" def test_requests_missing_schema_routes_to_fatal_tool_error(self): """MissingSchema is a construction bug — FatalToolError with specific message.""" exc = requests.exceptions.MissingSchema("No scheme") result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert ( result.message == "HTTP request URL is missing a scheme (expected http:// or https://)." ) assert result.extra["error_type"] == "MissingSchema" def test_requests_invalid_schema_routes_to_fatal_tool_error(self): """InvalidSchema (unsupported scheme like ftp://) → FatalToolError.""" exc = requests.exceptions.InvalidSchema("Bad scheme") result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert ( result.message == "HTTP request URL uses an unsupported scheme (expected http or https)." ) assert result.extra["error_type"] == "InvalidSchema" def test_requests_invalid_proxy_url_routes_to_fatal_tool_error(self): """InvalidProxyURL is a subclass of InvalidURL — proxy-specific message.""" exc = requests.exceptions.InvalidProxyURL("bad proxy") result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert result.message == "HTTP proxy URL is invalid or malformed." assert result.extra["error_type"] == "InvalidProxyURL" def test_requests_invalid_header_routes_to_fatal_tool_error(self): """InvalidHeader is a construction bug — FatalToolError.""" exc = requests.exceptions.InvalidHeader("Bad header") result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert result.message == "HTTP request contains an invalid header name or value." assert result.extra["error_type"] == "InvalidHeader" def test_requests_url_required_routes_to_fatal_tool_error(self): """URLRequired (no URL provided) → FatalToolError.""" exc = requests.exceptions.URLRequired("No URL") result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert result.message == "HTTP request requires a URL but none was provided." assert result.extra["error_type"] == "URLRequired" def test_requests_ssl_error_routes_to_fatal_tool_error(self): """SSLError is typically a local cert/trust config issue — FatalToolError.""" exc = requests.exceptions.SSLError("bad cert") result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert ( result.message == "TLS handshake failed — likely a local certificate or trust " "configuration issue." ) assert result.extra["error_type"] == "SSLError" def test_requests_content_decoding_error_handling(self): """Decode failures should map to NetworkTransportError (UNMAPPED, retryable).""" request = requests.Request("GET", "https://api.example.com/json").prepare() exc = requests.exceptions.ContentDecodingError("Bad payload", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED assert result.extra["service"] == "_http" assert result.extra["error_type"] == "ContentDecodingError" assert result.extra["endpoint"] == "https://api.example.com/json" assert result.extra["http_method"] == "GET" def test_requests_too_many_redirects_is_non_retryable(self): """Redirect loops → NetworkTransportError (UNMAPPED, not retryable).""" request = requests.Request("GET", "https://api.example.com/redirect-loop").prepare() exc = requests.exceptions.TooManyRedirects("Exceeded redirect limit", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is False assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED assert result.extra["service"] == "_http" assert result.extra["error_type"] == "TooManyRedirects" assert result.extra["endpoint"] == "https://api.example.com/redirect-loop" assert result.extra["http_method"] == "GET" def test_requests_request_exception_fallback(self): """Unhandled requests base exceptions → NetworkTransportError (UNMAPPED).""" request = requests.Request("DELETE", "https://api.example.com/resource/123").prepare() exc = requests.exceptions.RequestException("Request failed", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED assert result.extra["service"] == "_http" assert result.extra["error_type"] == "RequestException" assert result.extra["endpoint"] == "https://api.example.com/resource/123" assert result.extra["http_method"] == "DELETE" def test_requests_handler_degrades_gracefully_without_invalid_proxy_url( self, monkeypatch ): """Older ``requests`` (<2.21.0) predates ``InvalidProxyURL``. In those versions, a bad proxy URL raises plain ``InvalidURL`` instead, so the adapter should fall through to the ``InvalidURL`` handler and still produce a ``FatalToolError`` (regression for the bulk-import bug that used to silently disable the whole requests chain). """ import requests.exceptions as rex monkeypatch.delattr(rex, "InvalidProxyURL", raising=False) exc = requests.exceptions.InvalidURL("bad proxy url") result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert result.message == "HTTP request URL is invalid or malformed." assert result.extra["error_type"] == "InvalidURL" def test_requests_handler_degrades_gracefully_without_invalid_header( self, monkeypatch ): """Older ``requests`` (<2.12.0) predates ``InvalidHeader`` — same guard. Here we only need to prove the handler still returns a classified error rather than ``None`` for *any* requests exception when ``InvalidHeader`` is missing. A ``Timeout`` is the cleanest witness because it's unambiguously a ``NetworkTransportError`` regardless of the header routing block. """ import requests.exceptions as rex monkeypatch.delattr(rex, "InvalidHeader", raising=False) request = requests.Request("GET", "https://api.example.com/x").prepare() exc = requests.exceptions.Timeout("timed out", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT def test_unhandled_exception_logs_warning(self, caplog): """Test that unhandled exceptions log a warning.""" with caplog.at_level(logging.INFO): unknown_exc = ValueError("Some unrelated error") result = self.adapter.from_exception(unknown_exc) assert result is None assert len(caplog.records) == 1 assert "ValueError" in caplog.records[0].message assert "_http" in caplog.records[0].message assert "not handled" in caplog.records[0].message def test_httpx_without_request_info(self): """Test handling httpx exception without request information.""" # Create a mock HTTPStatusError class class MockHTTPStatusError(Exception): pass mock_response = Mock() mock_response.status_code = 400 mock_response.headers = {} # Fully detached: neither the exception nor the response carries a Request. mock_response.request = None mock_response.url = None mock_exc = MockHTTPStatusError("400 Bad Request") mock_exc.response = mock_response mock_exc.request = None with patch("httpx.HTTPStatusError", MockHTTPStatusError): result = self.adapter.from_exception(mock_exc) assert isinstance(result, UpstreamError) assert result.status_code == 400 assert result.message == "Upstream HTTP request failed (Bad Request, client error)." assert result.developer_message == "400 Bad Request" assert result.extra["service"] == "_http" assert "endpoint" not in result.extra assert "http_method" not in result.extra def test_httpx_timeout_exception_handling(self): """Timeout exceptions → NetworkTransportError (TIMEOUT, retryable).""" request = httpx.Request("GET", "https://api.example.com/slow?token=secret") exc = httpx.ReadTimeout("Read timed out", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT assert result.extra["service"] == "_http" assert result.extra["error_type"] == "ReadTimeout" assert result.extra["endpoint"] == "https://api.example.com/slow" assert result.extra["http_method"] == "GET" def test_httpx_pool_timeout_routes_to_timeout(self): """PoolTimeout (local pool exhaustion) → NetworkTransportError (TIMEOUT).""" exc = httpx.PoolTimeout("pool exhausted") result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT assert result.can_retry is True assert result.status_code is None assert result.extra["error_type"] == "PoolTimeout" def test_httpx_transport_exception_handling(self): """Transport exceptions → NetworkTransportError (UNREACHABLE, retryable).""" request = httpx.Request("POST", "https://api.example.com/ping") exc = httpx.ConnectError("Connection failed", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNREACHABLE assert result.extra["service"] == "_http" assert result.extra["error_type"] == "ConnectError" assert result.extra["endpoint"] == "https://api.example.com/ping" assert result.extra["http_method"] == "POST" def test_httpx_unsupported_protocol_routes_to_fatal_tool_error(self): """Unsupported scheme is a construction bug — FatalToolError with specific msg.""" request = httpx.Request("GET", "ftp://api.example.com/resource") exc = httpx.UnsupportedProtocol("Unsupported protocol", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert result.kind == ErrorKind.TOOL_RUNTIME_FATAL assert ( result.message == "HTTP request URL uses an unsupported scheme (expected http or https)." ) assert result.extra["service"] == "_http" assert result.extra["error_type"] == "UnsupportedProtocol" assert result.extra["endpoint"] == "ftp://api.example.com/resource" assert result.extra["http_method"] == "GET" def test_httpx_invalid_url_routes_to_fatal_tool_error(self): """httpx.InvalidURL is a bare Exception (not RequestError); still → FatalToolError.""" exc = httpx.InvalidURL("bad url") result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert result.message == "HTTP request URL is invalid or malformed." assert result.extra["error_type"] == "InvalidURL" def test_httpx_request_error_fallback(self): """Unhandled httpx RequestError subclasses → NetworkTransportError (UNMAPPED).""" request = httpx.Request("DELETE", "https://api.example.com/resource/123") exc = httpx.RequestError("Request failed", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED assert result.extra["service"] == "_http" assert result.extra["error_type"] == "RequestError" assert result.extra["endpoint"] == "https://api.example.com/resource/123" assert result.extra["http_method"] == "DELETE" def test_httpx_decoding_error_handling(self): """Decoding errors → NetworkTransportError (UNMAPPED, retryable).""" request = httpx.Request("GET", "https://api.example.com/json") exc = httpx.DecodingError("Unable to decode response body", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED assert result.extra["service"] == "_http" assert result.extra["error_type"] == "DecodingError" assert result.extra["endpoint"] == "https://api.example.com/json" assert result.extra["http_method"] == "GET" def test_httpx_local_protocol_error_routes_to_fatal_tool_error(self): """LocalProtocolError = our HTTP framing was invalid (construction bug).""" request = httpx.Request("GET", "https://api.example.com/broken") exc = httpx.LocalProtocolError("Malformed local protocol state", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, FatalToolError) assert result.can_retry is False assert result.kind == ErrorKind.TOOL_RUNTIME_FATAL assert ( result.message == "HTTP request violated the HTTP protocol before it was sent " "(malformed headers or body)." ) assert result.extra["service"] == "_http" assert result.extra["error_type"] == "LocalProtocolError" assert result.extra["endpoint"] == "https://api.example.com/broken" assert result.extra["http_method"] == "GET" def test_httpx_remote_protocol_error_is_retryable_transport_error(self): """RemoteProtocolError (upstream sent malformed HTTP) → UNREACHABLE, retryable.""" request = httpx.Request("GET", "https://api.example.com/protocol") exc = httpx.RemoteProtocolError("Malformed upstream protocol response", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is True assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNREACHABLE assert result.extra["service"] == "_http" assert result.extra["error_type"] == "RemoteProtocolError" assert result.extra["endpoint"] == "https://api.example.com/protocol" assert result.extra["http_method"] == "GET" def test_httpx_too_many_redirects_is_non_retryable(self): """Redirect loops → NetworkTransportError (UNMAPPED, not retryable).""" request = httpx.Request("GET", "https://api.example.com/redirect-loop") exc = httpx.TooManyRedirects("Exceeded redirect limit", request=request) result = self.adapter.from_exception(exc) assert isinstance(result, NetworkTransportError) assert result.status_code is None assert result.can_retry is False assert result.kind == ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED assert result.extra["service"] == "_http" assert result.extra["error_type"] == "TooManyRedirects" assert result.extra["endpoint"] == "https://api.example.com/redirect-loop" assert result.extra["http_method"] == "GET" def test_adapter_slug(self): """Test that the adapter has the correct slug.""" assert HTTPErrorAdapter.slug == "_http" def test_map_status_to_error_403_with_exhausted_quota(self): """Test mapping 403 with exhausted quota to rate limit error.""" headers = { "retry-after": "30", "x-ratelimit-remaining": "0", } result = self.adapter._map_status_to_error( status=403, headers=headers, msg="Forbidden", request_url="https://api.example.com/user/repos", request_method="GET", ) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 30_000 assert result.message == "Forbidden" def test_map_status_to_error_403_with_remaining_quota(self): """Test mapping 403 with remaining quota to regular upstream error.""" headers = { "x-ratelimit-remaining": "4941", "x-ratelimit-reset": "1762795446", } result = self.adapter._map_status_to_error( status=403, headers=headers, msg="Forbidden", request_url="https://api.example.com/user/repos", request_method="GET", ) assert isinstance(result, UpstreamError) assert not isinstance(result, UpstreamRateLimitError) assert result.status_code == 403 assert result.message == "Forbidden" def test_is_rate_limit_403_with_exhausted_quota(self): """Test detecting rate limit 403 when quota is exhausted.""" headers = {"x-ratelimit-remaining": "0"} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is True def test_is_rate_limit_403_with_remaining_quota(self): """Test that 403 with remaining quota is NOT detected as rate limiting.""" headers = {"x-ratelimit-remaining": "4941"} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is False def test_is_rate_limit_403_without_header(self): """Test that 403 without rate limit headers is not detected as rate limiting.""" headers = {} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is False def test_is_rate_limit_403_with_x_rate_limit_remaining_variant(self): """Test detecting rate limit 403 with x-rate-limit-remaining header variant.""" headers = {"x-rate-limit-remaining": "0"} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is True def test_is_rate_limit_403_with_ratelimit_remaining_variant(self): """Test detecting rate limit 403 with ratelimit-remaining header variant.""" headers = {"ratelimit-remaining": "0"} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is True def test_is_rate_limit_403_with_app_rate_limit_remaining_variant(self): """Test detecting rate limit 403 with x-app-rate-limit-remaining header variant.""" headers = {"x-app-rate-limit-remaining": "0"} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is True def test_is_rate_limit_403_with_non_digit_value(self): """Test that non-digit remaining value is handled gracefully.""" headers = {"x-ratelimit-remaining": "invalid"} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is False def test_is_rate_limit_403_with_float_value(self): """Test that float remaining value is handled (converted to int).""" headers = {"x-ratelimit-remaining": "0.0"} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is True def test_is_rate_limit_403_with_retry_after_and_rate_limit_headers(self): """Test detecting rate limit when retry-after is present with rate limit headers.""" headers = { "retry-after": "60", "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "100", # Still has quota but retry-after suggests rate limit } msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is True def test_is_rate_limit_403_with_retry_after_only(self): """Test that retry-after alone without rate limit headers is not treated as rate limit.""" headers = {"retry-after": "60"} msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is False def test_is_rate_limit_403_with_rate_limit_headers_no_retry_after(self): """Test that rate limit headers without retry-after and with remaining quota is not rate limit.""" headers = { "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "100", } msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is False def test_is_rate_limit_403_with_retry_after_zero(self): """Test that retry-after with value 0 is not treated as rate limiting.""" headers = { "retry-after": "0", "x-ratelimit-limit": "5000", } msg = "Forbidden" result = self.adapter._is_rate_limit_403(headers, msg) assert result is False def test_httpx_403_rate_limit_handling(self): """Test handling httpx 403 rate limit with exhausted quota.""" # Create a mock HTTPStatusError class class MockHTTPStatusError(Exception): pass mock_response = Mock() mock_response.status_code = 403 mock_response.headers = { "x-ratelimit-reset": "1640995200", "retry-after": "120", "x-ratelimit-remaining": "0", # Quota is exhausted } mock_request = Mock() mock_request.url = "https://api.example.com/search" mock_request.method = "GET" mock_exc = MockHTTPStatusError("403 Forbidden") mock_exc.response = mock_response mock_exc.request = mock_request with patch("httpx.HTTPStatusError", MockHTTPStatusError): result = self.adapter.from_exception(mock_exc) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 120_000 assert result.message == ( "Upstream HTTP request failed (Forbidden, client error). " "Retry after 120 second(s)." ) assert result.developer_message == "403 Forbidden" def test_requests_403_rate_limit_handling(self): """Test handling requests 403 rate limit with exhausted quota.""" # Create a mock HTTPError class class MockHTTPError(Exception): pass mock_response = Mock() mock_response.status_code = 403 mock_response.headers = { "x-ratelimit-reset-ms": "30000", "x-ratelimit-remaining": "0", # Quota is exhausted } mock_request = Mock() mock_request.url = "https://api.example.com/user/repos" mock_request.method = "POST" mock_response.request = mock_request mock_exc = MockHTTPError("403 Forbidden") mock_exc.response = mock_response with patch("requests.exceptions.HTTPError", MockHTTPError): result = self.adapter.from_exception(mock_exc) assert isinstance(result, UpstreamRateLimitError) assert result.retry_after_ms == 30_000 assert result.message == ( "Upstream HTTP request failed (Forbidden, client error). " "Retry after 30 second(s)." ) assert result.developer_message == "403 Forbidden" def test_http_status_message_keeps_sensitive_data_in_developer_message_only(self): """Status messages should remain descriptive while avoiding sensitive payload leaks.""" request = httpx.Request("GET", "https://api.example.com/users?token=secret-token") response = httpx.Response( 401, request=request, headers={"authorization": "Bearer super-secret"}, json={"error": "token secret-token is invalid"}, ) exc = httpx.HTTPStatusError( "401 Client Error: Unauthorized for url: " "https://api.example.com/users?token=secret-token payload=secret-token", request=request, response=response, ) result = self.adapter.from_exception(exc) assert isinstance(result, UpstreamError) assert result.message == "Upstream HTTP request failed (Unauthorized, client error)." assert "secret-token" not in result.message assert "Bearer" not in result.message assert "payload" not in result.message assert "secret-token" in (result.developer_message or "")