arcade-mcp/libs/tests/sdk/test_httpx_adapter.py
Francisco Or Something 8f5d0ff54e
Improve typed httpx error mapping and adapter guidance (#820)
## Summary

Routes HTTP adapter exceptions to the right error class instead of
shoe-horning everything into `UpstreamError`. Addresses Eric's earlier
feedback that several exceptions this PR was wrapping as `UpstreamError`
didn't satisfy the "something happened with the upstream" claim (local
pool exhaustion, client-side request construction, local TLS failures).

### Scope

- `UpstreamError` (unchanged) — upstream responded with an HTTP status
code.
- **`NetworkTransportError`** (new sibling in `arcade-core`) — no
complete response was received. `status_code=None`. Three kinds:
`NETWORK_TRANSPORT_RUNTIME_TIMEOUT`, `_UNREACHABLE`, `_UNMAPPED`.
- **`FatalToolError`** (existing) — client construction bugs
(`InvalidURL`, `UnsupportedProtocol`, `MissingSchema`, `InvalidHeader`,
`LocalProtocolError`, …) and local TLS/cert config failures. Never
retried.

---

## Before / After (per Eric's request)

Shows the error payload a tool produces for each exception, before this
PR vs. after. "Before" = current `main` (exceptions without real HTTP
responses fall through to the generic `@tool` `FatalToolError` catch-all
with `message=str(exc)`).

### No-response transport failures

| Exception | Before — class / message / kind | After — class / message
/ kind |
|---|---|---|
| `httpx.PoolTimeout` | `FatalToolError` — `str(exc)` leaks raw detail —
`TOOL_RUNTIME_FATAL`, not retryable | `NetworkTransportError` — `"HTTP
request timed out before a complete response was received."` —
`NETWORK_TRANSPORT_RUNTIME_TIMEOUT`, **retryable** |
| `httpx.ConnectTimeout` | same as above | same as PoolTimeout —
`TIMEOUT`, retryable |
| `httpx.ConnectError` (refused / DNS) | `FatalToolError` — `str(exc)` |
`NetworkTransportError` — `"HTTP request failed before reaching the
upstream service."` — `UNREACHABLE`, retryable |
| `httpx.RemoteProtocolError` (upstream sent bad HTTP) |
`FatalToolError` — `str(exc)` | `NetworkTransportError` — same message
as ConnectError — `UNREACHABLE`, retryable |
| `httpx.DecodingError` | `FatalToolError` — `str(exc)` |
`NetworkTransportError` — `"HTTP response from upstream could not be
decoded."` — `UNMAPPED`, retryable |
| `httpx.TooManyRedirects` | `FatalToolError` — `str(exc)` |
`NetworkTransportError` — `"HTTP redirect limit exceeded before a final
response was received."` — `UNMAPPED`, **not** retryable |

### Client construction / local env bugs

| Exception | Before | After |
|---|---|---|
| `httpx.UnsupportedProtocol`, `httpx.InvalidURL`,
`httpx.LocalProtocolError` | `FatalToolError` with `message=str(exc)`
(may leak scheme / URL content) | `FatalToolError` — `"Tool constructed
an invalid HTTP request — likely a tool-authoring bug."` —
`TOOL_RUNTIME_FATAL`, not retryable |
| `requests.MissingSchema`, `InvalidURL`, `InvalidHeader`,
`InvalidSchema`, `InvalidProxyURL`, `URLRequired` | same as above | same
as above |
| `requests.SSLError` | `FatalToolError` — `str(exc)` often contains raw
cert chain detail | `FatalToolError` — `"TLS handshake failed — likely a
local certificate or trust configuration issue."` —
`TOOL_RUNTIME_FATAL`, not retryable |

### Real HTTP response errors (UNCHANGED — same behavior)

| Exception | Class | Message | Kind | Retryable |
|---|---|---|---|---|
| `httpx.HTTPStatusError` 404 | `UpstreamError` | `"Upstream HTTP
request failed (Not Found, client error)."` |
`UPSTREAM_RUNTIME_NOT_FOUND` | No |
| `httpx.HTTPStatusError` 429 (w/ Retry-After: 60) |
`UpstreamRateLimitError` | `"Upstream HTTP request failed (Too Many
Requests, client error). Retry after 60 second(s)."` |
`UPSTREAM_RUNTIME_RATE_LIMIT` | Yes |
| `httpx.HTTPStatusError` 500 | `UpstreamError` | `"Upstream HTTP
request failed (Internal Server Error, server error)."` |
`UPSTREAM_RUNTIME_SERVER_ERROR` | Yes |

### What's no longer in the message

- Raw exception `str(exc)` output (which frequently includes the full
URL with query-string tokens, connection pool details, or cert chains)
is **no longer the agent-facing `message`**. It's preserved in
`developer_message` for server-side diagnostics.
- The misleading "Upstream HTTP…" prefix is gone from network-transport
and construction-bug messages. Those messages now honestly describe what
happened on the tool side.
- For 429s without a `Retry-After` header, we still show "Retry after N
seconds." (pre-existing behavior; see follow-up notes).

---

## Companion PRs

-
[ArcadeAI/arcade-mcp#823](https://github.com/ArcadeAI/arcade-mcp/pull/823)
— introduces `NetworkTransportError` in `arcade-core`
- [ArcadeAI/monorepo#911](https://github.com/ArcadeAI/monorepo/pull/911)
— adds the 3 `ErrorKind` constants to the Go engine and Datadog
dashboards
- [ArcadeAI/docs#920](https://github.com/ArcadeAI/docs/pull/920) —
documents the new hierarchy and adapter routing

## Follow-ups (out of scope for this PR)

A short investigation surfaced several pre-existing issues that are
worth fixing separately. A full list is in
`NETWORK_TRANSPORT_ERROR_FOLLOWUPS.md` (shared offline). Summary:

1. `requests.HTTPError` with `response is None` returns `None` from the
adapter; should fall through to the `NetworkTransportError(UNMAPPED)`
fallback instead of becoming a generic `FatalToolError`.
2. `developer_message` can leak URL query strings (and therefore tokens)
since it stores raw `str(exc)`.
3. `_sanitize_uri` does not strip userinfo (credentials in URL path).
4. `_parse_retry_ms` misinterprets epoch-style `x-ratelimit-reset`
headers.
5. 429 responses without `Retry-After` synthesize a fabricated "Retry
after 1 second(s)." suffix.
6. `UPSTREAM_RUNTIME_VALIDATION_ERROR` is defined but never emitted.
7. `UpstreamError` silently accepts out-of-range status codes.
8. `requests.HTTPError` branch re-extracts `request_url` /
`request_method` inconsistently (dead work).

## Test plan

- [x] Existing `libs/tests/sdk/test_httpx_adapter.py` +
`test_graphql_adapter.py` updated; every no-response / construction-bug
test asserts the new class + kind + `can_retry`.
- [x] Full test suite passes locally.
- [x] mypy clean on `arcade-core`, `arcade-tdk`, `arcade-mcp-server`.
- [x] Smoke-tested 21 exception routing cases end-to-end against real
httpx / requests exceptions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes core error classification and retryability for
`httpx`/`requests`/GraphQL transport failures, which can affect tool
retry behavior and telemetry. Risk is mitigated by extensive new/updated
tests covering the new mappings and privacy expectations.
> 
> **Overview**
> **Improves error adapter behavior to be more semantically correct and
privacy-safe.** The HTTP adapter now distinguishes real HTTP responses
(`UpstreamError`/`UpstreamRateLimitError`) from no-response failures
(`NetworkTransportError` with `ErrorKind` + retryability) and from
client construction/local TLS issues (`FatalToolError`).
> 
> **Reduces sensitive data exposure in agent-facing messages.**
Status-based errors now emit standardized messages derived from status
phrase/class, while preserving raw exception detail in
`developer_message`; Google/Microsoft/Slack fallback paths similarly
switch to `unhandled <ExceptionType>` messages and move `str(exc)` into
`developer_message`. GraphQL transport connection/protocol errors are
reclassified from `UpstreamError` (502) to `NetworkTransportError`, and
transport/server messages are standardized.
> 
> Bumps `arcade-tdk` version to `3.8.0` and expands/updates the SDK test
suite to assert new classes, `kind`, `can_retry`, request metadata
extraction, and privacy behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1041cb1bec4fa3b0bae3e7c6b860b84cf376cf9a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 20:32:17 -03:00

964 lines
40 KiB
Python

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 "")