460 lines
18 KiB
Python
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"
|