arcade-mcp/libs/tests/sdk/test_httpx_adapter.py

460 lines
18 KiB
Python

import logging
from datetime import datetime, timezone
from unittest.mock import Mock, patch
from arcade_core.errors import 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 == "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 == "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 == "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 == "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_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 = {}
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 == "400 Bad Request"
assert result.extra["service"] == "_http"
assert "endpoint" not in result.extra
assert "http_method" not in result.extra
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_rate_limit_headers(self):
"""Test mapping 403 with rate limit headers to rate limit error."""
headers = {
"retry-after": "30",
}
result = self.adapter._map_status_to_error(
status=403,
headers=headers,
msg="Forbidden",
request_url="https://api.github.com/user/repos",
request_method="GET",
)
assert isinstance(result, UpstreamRateLimitError)
assert result.retry_after_ms == 30_000
assert result.message == "Forbidden"
assert result.extra["service"] == "_http"
assert result.extra["endpoint"] == "https://api.github.com/user/repos"
assert result.extra["http_method"] == "GET"
def test_map_status_to_error_403_without_rate_limit_headers(self):
"""Test mapping 403 without rate limiting headers to regular upstream error."""
headers = {}
result = self.adapter._map_status_to_error(
status=403,
headers=headers,
msg="Access denied due to insufficient permissions",
request_url="https://api.github.com/user/repos",
request_method="GET",
)
assert isinstance(result, UpstreamError)
assert not isinstance(result, UpstreamRateLimitError)
assert result.status_code == 403
assert result.message == "Access denied due to insufficient permissions"
def test_is_rate_limit_403_with_retry_after_header(self):
"""Test detecting rate limit 403 based on retry-after header."""
headers = {"Retry-After": "60"}
msg = "Forbidden"
result = self.adapter._is_rate_limit_403(headers, msg)
assert result is True
def test_is_rate_limit_403_with_x_ratelimit_reset_header(self):
"""Test detecting rate limit 403 based on x-ratelimit-reset header."""
headers = {"x-ratelimit-reset": "1640995200"}
msg = "Forbidden"
result = self.adapter._is_rate_limit_403(headers, msg)
assert result is True
def test_is_rate_limit_403_with_x_ratelimit_reset_ms_header(self):
"""Test detecting rate limit 403 based on x-ratelimit-reset-ms header."""
headers = {"X-RateLimit-Reset-Ms": "5000"}
msg = "Forbidden"
result = self.adapter._is_rate_limit_403(headers, msg)
assert result is True
def test_is_rate_limit_403_without_headers(self):
"""Test that 403 without rate limiting headers is not detected as rate limiting."""
headers = {}
msg = "Access denied due to insufficient permissions"
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 rate limiting headers."""
# 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"}
mock_request = Mock()
mock_request.url = "https://api.github.com/search/repositories"
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 == "403 Forbidden"
assert result.extra["service"] == "_http"
assert result.extra["endpoint"] == "https://api.github.com/search/repositories"
assert result.extra["http_method"] == "GET"
def test_requests_403_rate_limit_handling(self):
"""Test handling requests 403 rate limit with rate limiting headers."""
# 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"}
mock_request = Mock()
mock_request.url = "https://api.github.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 == "403 Forbidden"
assert result.extra["service"] == "_http"
assert result.extra["endpoint"] == "https://api.github.com/user/repos"
assert result.extra["http_method"] == "POST"