From e90176d1cf7a32a8a2e92b72a8b1c581da7aa6bd Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Fri, 12 Jun 2026 17:15:44 -0500 Subject: [PATCH 1/2] Add async and sync HTTP transport with error dispatch Implements _transport.py with AsyncVisorTransport and SyncVisorTransport backed by httpx. Shared _handle_response helper maps 4xx/5xx status codes to typed exceptions; _parse_retry_after handles integer and HTTP-date Retry-After values. 28 tests cover both transports via respx mocks. Co-Authored-By: Claude Sonnet 4.6 --- src/visor/_transport.py | 108 ++++++++++++++++ tests/test_transport.py | 272 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 src/visor/_transport.py create mode 100644 tests/test_transport.py diff --git a/src/visor/_transport.py b/src/visor/_transport.py new file mode 100644 index 0000000..a5f3854 --- /dev/null +++ b/src/visor/_transport.py @@ -0,0 +1,108 @@ +from datetime import datetime +from email.utils import parsedate_to_datetime + +import httpx + +from visor.exceptions import ( + AuthError, + ForbiddenError, + NotFoundError, + PaymentRequiredError, + RateLimitError, + ValidationError, + VisorAPIError, + VisorTransportError, +) + +DEFAULT_BASE_URL = "https://api.visor.vin/v1" + + +def _parse_retry_after(value: str | None) -> int | None: + if not value: + return None + try: + return int(value) + except ValueError: + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + return max(0, int((retry_at - datetime.now(retry_at.tzinfo)).total_seconds())) + + +def _handle_response(response: httpx.Response) -> dict: # type: ignore[type-arg] + if response.is_success: + return response.json() # type: ignore[no-any-return] + + try: + body = response.json() + error_code = body.get("error", {}).get("code", "unknown_error") + message = body.get("error", {}).get("message", response.text) + except Exception: + error_code = "unknown_error" + message = response.text + + match response.status_code: + case 400: + raise ValidationError(400, error_code, message) + case 401: + raise AuthError(401, error_code, message) + case 402: + raise PaymentRequiredError(402, error_code, message) + case 403: + raise ForbiddenError(403, error_code, message) + case 404: + raise NotFoundError(404, error_code, message) + case 429: + retry_after = _parse_retry_after(response.headers.get("Retry-After")) + raise RateLimitError(429, error_code, message, retry_after=retry_after) + case _: + raise VisorAPIError(response.status_code, error_code, message) + + +class AsyncVisorTransport: + def __init__( + self, + api_key: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + self._client = httpx.AsyncClient( + base_url=base_url, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=timeout, + ) + + async def get(self, path: str, params: dict[str, str] | None = None) -> dict: # type: ignore[type-arg] + try: + response = await self._client.get(path, params=params or {}) + except httpx.RequestError as e: + raise VisorTransportError(f"Request failed: {e}") from e + return _handle_response(response) + + async def aclose(self) -> None: + await self._client.aclose() + + +class SyncVisorTransport: + def __init__( + self, + api_key: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + self._client = httpx.Client( + base_url=base_url, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=timeout, + ) + + def get(self, path: str, params: dict[str, str] | None = None) -> dict: # type: ignore[type-arg] + try: + response = self._client.get(path, params=params or {}) + except httpx.RequestError as e: + raise VisorTransportError(f"Request failed: {e}") from e + return _handle_response(response) + + def close(self) -> None: + self._client.close() diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..c511e7d --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,272 @@ +import httpx +import pytest +import respx + +from visor._transport import DEFAULT_BASE_URL, AsyncVisorTransport, SyncVisorTransport +from visor.exceptions import ( + AuthError, + ForbiddenError, + NotFoundError, + PaymentRequiredError, + RateLimitError, + ValidationError, + VisorAPIError, + VisorTransportError, +) + +API_KEY = "test-key" +SUCCESS_BODY = {"data": {"id": "abc"}} +ERROR_BODY = {"error": {"code": "some_error", "message": "something went wrong"}} + + +# --------------------------------------------------------------------------- +# Async transport +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_auth_header_sent(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + await transport.get("/listings") + await transport.aclose() + + assert route.called + auth = route.calls[0].request.headers["Authorization"] + assert auth == f"Bearer {API_KEY}" + + +@pytest.mark.asyncio +async def test_async_base_url_path_composition(): + custom_base = "https://api.visor.vin/v1" + with respx.mock(base_url=custom_base) as mock: + route = mock.get("/vins/ABC123").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = AsyncVisorTransport(api_key=API_KEY, base_url=custom_base) + await transport.get("/vins/ABC123") + await transport.aclose() + + assert route.called + + +@pytest.mark.asyncio +async def test_async_query_params_passed(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + await transport.get("/listings", params={"make": "Toyota", "state": "TX"}) + await transport.aclose() + + url = str(route.calls[0].request.url) + assert "make=Toyota" in url + assert "state=TX" in url + + +@pytest.mark.asyncio +async def test_async_success_returns_json(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(return_value=httpx.Response(200, json=SUCCESS_BODY)) + transport = AsyncVisorTransport(api_key=API_KEY) + result = await transport.get("/listings") + await transport.aclose() + + assert result == SUCCESS_BODY + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_code, exc_class", + [ + (400, ValidationError), + (401, AuthError), + (402, PaymentRequiredError), + (403, ForbiddenError), + (404, NotFoundError), + (429, RateLimitError), + (500, VisorAPIError), + ], +) +async def test_async_error_status_raises(status_code: int, exc_class: type) -> None: + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(status_code, json=ERROR_BODY) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(exc_class): + await transport.get("/listings") + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_rate_limit_carries_retry_after(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "60"}, + json=ERROR_BODY, + ) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.retry_after == 60 + + +@pytest.mark.asyncio +async def test_async_rate_limit_retry_after_none_when_missing(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(return_value=httpx.Response(429, json=ERROR_BODY)) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.retry_after is None + + +@pytest.mark.asyncio +async def test_async_non_json_error_body_unknown_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 503, + content=b"Service Unavailable", + headers={"Content-Type": "text/html"}, + ) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorAPIError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.error_code == "unknown_error" + assert "Service Unavailable" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_async_request_error_raises_transport_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(side_effect=httpx.ConnectError("connection refused")) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorTransportError): + await transport.get("/listings") + await transport.aclose() + + +# --------------------------------------------------------------------------- +# Sync transport +# --------------------------------------------------------------------------- + + +def test_sync_auth_header_sent(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = SyncVisorTransport(api_key=API_KEY) + transport.get("/listings") + transport.close() + + assert route.called + auth = route.calls[0].request.headers["Authorization"] + assert auth == f"Bearer {API_KEY}" + + +def test_sync_query_params_passed(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = SyncVisorTransport(api_key=API_KEY) + transport.get("/listings", params={"make": "Ford", "state": "CA"}) + transport.close() + + url = str(route.calls[0].request.url) + assert "make=Ford" in url + assert "state=CA" in url + + +def test_sync_success_returns_json(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(return_value=httpx.Response(200, json=SUCCESS_BODY)) + transport = SyncVisorTransport(api_key=API_KEY) + result = transport.get("/listings") + transport.close() + + assert result == SUCCESS_BODY + + +@pytest.mark.parametrize( + "status_code, exc_class", + [ + (400, ValidationError), + (401, AuthError), + (402, PaymentRequiredError), + (403, ForbiddenError), + (404, NotFoundError), + (429, RateLimitError), + (500, VisorAPIError), + ], +) +def test_sync_error_status_raises(status_code: int, exc_class: type) -> None: + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(status_code, json=ERROR_BODY) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(exc_class): + transport.get("/listings") + transport.close() + + +def test_sync_rate_limit_carries_retry_after(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "45"}, + json=ERROR_BODY, + ) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + transport.get("/listings") + transport.close() + + assert exc_info.value.retry_after == 45 + + +def test_sync_non_json_error_body_unknown_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 503, + content=b"Service Unavailable", + headers={"Content-Type": "text/html"}, + ) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorAPIError) as exc_info: + transport.get("/listings") + transport.close() + + assert exc_info.value.error_code == "unknown_error" + assert "Service Unavailable" in exc_info.value.message + + +def test_sync_request_error_raises_transport_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(side_effect=httpx.ConnectError("connection refused")) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorTransportError): + transport.get("/listings") + transport.close() From e2e3eebf08af5558934127596bdd7dfa2331634a Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Fri, 12 Jun 2026 17:19:44 -0500 Subject: [PATCH 2/2] Narrow error body parsing in _handle_response Replaces broad `except Exception` with `except ValueError` for JSON decode failures and adds isinstance guards so a malformed envelope like {"error": "oops"} surfaces as unknown_error instead of masking the AttributeError. Adds a regression test for the malformed envelope case. Co-Authored-By: Claude Sonnet 4.6 --- src/visor/_transport.py | 11 ++++++++--- tests/test_transport.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/visor/_transport.py b/src/visor/_transport.py index a5f3854..b785205 100644 --- a/src/visor/_transport.py +++ b/src/visor/_transport.py @@ -36,9 +36,14 @@ def _handle_response(response: httpx.Response) -> dict: # type: ignore[type-arg try: body = response.json() - error_code = body.get("error", {}).get("code", "unknown_error") - message = body.get("error", {}).get("message", response.text) - except Exception: + except ValueError: + body = None + + error: object = body.get("error") if isinstance(body, dict) else None + if isinstance(error, dict): + error_code = error.get("code", "unknown_error") or "unknown_error" + message = error.get("message", response.text) or response.text + else: error_code = "unknown_error" message = response.text diff --git a/tests/test_transport.py b/tests/test_transport.py index c511e7d..69dac7a 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -162,6 +162,21 @@ async def test_async_request_error_raises_transport_error(): await transport.aclose() +@pytest.mark.asyncio +async def test_async_malformed_error_envelope_unknown_error(): + """{"error": "oops"} — error is a string, not a dict; must not crash.""" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(500, json={"error": "oops"}) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorAPIError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.error_code == "unknown_error" + + # --------------------------------------------------------------------------- # Sync transport # ---------------------------------------------------------------------------