diff --git a/src/shade/errors.py b/src/shade/errors.py index 3a15923..d03256b 100644 --- a/src/shade/errors.py +++ b/src/shade/errors.py @@ -74,7 +74,22 @@ class AuthenticationError(ShadeError): class InvalidRequestError(ShadeError): - """Raised when a request is malformed or rejected by validation.""" + """Raised when a request is malformed or rejected by validation. + + Attributes: + field_errors: Field-level validation errors extracted from the response + body, if the API provided them. ``None`` when absent. + """ + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + response_body: Optional[str] = None, + field_errors: Optional[object] = None, + ) -> None: + super().__init__(message, status_code, response_body) + self.field_errors = field_errors class NotFoundError(ShadeError): diff --git a/src/shade/http.py b/src/shade/http.py index b05ab26..48499ae 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -26,6 +26,7 @@ NetworkError, NotFoundError, RateLimitError, + ShadeError, ) logger = logging.getLogger(__name__) @@ -223,6 +224,170 @@ def _raise_for_status( raise HTTPError(f"HTTP {status}: {detail}".strip(), status_code=status) +# --------------------------------------------------------------------------- +# Single response parser +# --------------------------------------------------------------------------- + +def _error_message(data: Any, default: str) -> str: + """Extract a human-readable message from a parsed error body. + + Handles the common shapes ``{"error": {"message": ...}}``, + ``{"error": "..."}`` and ``{"message": ...}``. Falls back to *default* + when nothing usable is present (including when the body failed to decode). + """ + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + message = err.get("message") + if message: + return str(message) + elif isinstance(err, str) and err: + return err + message = data.get("message") + if message: + return str(message) + return default + + +def _field_errors(data: Any) -> Optional[Any]: + """Extract field-level validation errors from a parsed error body, if any. + + Looks for ``fields``/``field_errors``/``errors`` either nested under + ``error`` or at the top level. Returns ``None`` when absent. + """ + candidates = [] + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + candidates.append(err) + candidates.append(data) + for source in candidates: + for key in ("fields", "field_errors", "errors"): + fields = source.get(key) + if fields: + return fields + return None + + +def _parse_response(response: "httpx.Response") -> Dict[str, Any]: + """Parse an ``httpx.Response`` into a dict, mapping errors to typed exceptions. + + This is the single funnel every resource method should route responses + through. Centralizing JSON decoding, success detection, and the mapping of + HTTP status codes to the SDK's typed exception hierarchy here keeps error + handling from drifting between resources. + + Parameters + ---------- + response : httpx.Response + The response returned by an httpx request. + + Returns + ------- + dict + The decoded JSON body of a successful (2xx) response. + + Raises + ------ + AuthenticationError + For HTTP 401/403. + InvalidRequestError + For HTTP 400/422, carrying field-level errors when the body provides + them. + NotFoundError + For HTTP 404. + RateLimitError + For HTTP 429. + NetworkError + For HTTP 5xx (subject to retry by callers). + HTTPError + For any other non-2xx status not covered above. + ShadeError + When a 2xx body cannot be decoded as JSON, or a 2xx body itself + carries an ``error`` key. The raw body and HTTP status are attached to + every raised exception. + """ + status = response.status_code + body = response.text + + # Decode up-front so the raw body can drive both error mapping and the + # success path. A decode failure is captured rather than raised here so + # error statuses still produce their typed exception with the raw body. + try: + data: Any = json.loads(body) if body else {} + decoded = True + except (json.JSONDecodeError, ValueError): + data = None + decoded = False + + if 200 <= status < 300: + if not decoded: + raise ShadeError( + "Invalid response from API", + status_code=status, + response_body=body, + ) + if not isinstance(data, dict): + raise ShadeError( + "Invalid response from API", + status_code=status, + response_body=body, + ) + # A 2xx body that still carries an error is treated as a failure. + if data.get("error"): + raise ShadeError( + _error_message(data, "API returned an error"), + status_code=status, + response_body=body, + ) + return data + + if status in (401, 403): + raise AuthenticationError( + _error_message(data, "Authentication failed"), + status_code=status, + response_body=body, + ) + + if status in (400, 422): + raise InvalidRequestError( + _error_message(data, "Invalid request"), + status_code=status, + response_body=body, + field_errors=_field_errors(data), + ) + + if status == 404: + raise NotFoundError( + _error_message(data, "Resource not found"), + status_code=status, + response_body=body, + ) + + if status == 429: + raise RateLimitError( + _error_message(data, "Rate limit exceeded"), + retry_after=_parse_retry_after(response.headers), + status_code=status, + response_body=body, + ) + + if 500 <= status < 600: + raise NetworkError( + _error_message(data, f"Server error: {status}"), + status_code=status, + response_body=body, + ) + + # Any other non-2xx status (e.g. 3xx, uncommon 4xx) still maps to a typed + # exception so nothing escapes the funnel unhandled. + raise HTTPError( + _error_message(data, f"HTTP {status}"), + status_code=status, + response_body=body, + ) + + # --------------------------------------------------------------------------- # Synchronous client # --------------------------------------------------------------------------- diff --git a/tests/test_parse_response.py b/tests/test_parse_response.py new file mode 100644 index 0000000..4c0274c --- /dev/null +++ b/tests/test_parse_response.py @@ -0,0 +1,197 @@ +""" +Tests for the single ``_parse_response`` response funnel (http.py). + +Acceptance criteria covered: +* Every 4xx/5xx response maps to the correct typed exception. +* The raw response body and HTTP status are accessible on every exception. +* A non-JSON body raises ShadeError instead of a raw JSONDecodeError. +* 2xx responses carrying an ``error`` key are still treated as errors. +""" +from __future__ import annotations + +import httpx +import pytest + +from shade.errors import ( + AuthenticationError, + HTTPError, + InvalidRequestError, + NetworkError, + NotFoundError, + RateLimitError, + ShadeError, +) +from shade.http import _parse_response + + +def _resp(status: int, *, json_body=None, text=None, headers=None) -> httpx.Response: + """Build an httpx.Response with either a JSON or raw-text body.""" + kwargs = {"status_code": status, "headers": headers or {}} + if json_body is not None: + kwargs["json"] = json_body + elif text is not None: + kwargs["text"] = text + return httpx.Response(**kwargs) + + +# --------------------------------------------------------------------------- +# Success path +# --------------------------------------------------------------------------- + +class TestSuccess: + def test_2xx_returns_decoded_dict(self): + resp = _resp(200, json_body={"id": "pay_1", "status": "ok"}) + assert _parse_response(resp) == {"id": "pay_1", "status": "ok"} + + def test_201_returns_decoded_dict(self): + resp = _resp(201, json_body={"id": "inv_1"}) + assert _parse_response(resp) == {"id": "inv_1"} + + def test_empty_body_returns_empty_dict(self): + resp = _resp(204, text="") + assert _parse_response(resp) == {} + + def test_2xx_with_error_key_is_treated_as_error(self): + resp = _resp(200, json_body={"error": {"message": "soft failure"}}) + with pytest.raises(ShadeError) as exc_info: + _parse_response(resp) + assert exc_info.value.status_code == 200 + assert "soft failure" in str(exc_info.value) + assert exc_info.value.response_body == resp.text + + def test_2xx_with_falsy_error_key_is_success(self): + resp = _resp(200, json_body={"id": "pay_1", "error": None}) + assert _parse_response(resp) == {"id": "pay_1", "error": None} + + def test_2xx_non_dict_json_raises_shade_error(self): + resp = _resp(200, json_body=[1, 2, 3]) + with pytest.raises(ShadeError) as exc_info: + _parse_response(resp) + assert "Invalid response from API" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# JSON decode failure +# --------------------------------------------------------------------------- + +class TestDecodeFailure: + def test_2xx_non_json_body_raises_shade_error_not_jsondecodeerror(self): + resp = _resp(200, text="not json") + with pytest.raises(ShadeError) as exc_info: + _parse_response(resp) + assert str(exc_info.value).startswith("Invalid response from API") + assert exc_info.value.status_code == 200 + assert exc_info.value.response_body == "not json" + + def test_error_status_non_json_body_still_maps_to_typed_error(self): + resp = _resp(500, text="upstream exploded") + with pytest.raises(NetworkError) as exc_info: + _parse_response(resp) + assert exc_info.value.status_code == 500 + assert exc_info.value.response_body == "upstream exploded" + + +# --------------------------------------------------------------------------- +# Error status mapping +# --------------------------------------------------------------------------- + +class TestErrorMapping: + @pytest.mark.parametrize("status", [401, 403]) + def test_auth_error(self, status): + resp = _resp(status, json_body={"error": {"message": "bad token"}}) + with pytest.raises(AuthenticationError) as exc_info: + _parse_response(resp) + assert exc_info.value.status_code == status + assert "bad token" in str(exc_info.value) + + @pytest.mark.parametrize("status", [400, 422]) + def test_invalid_request_error(self, status): + resp = _resp(status, json_body={"error": {"message": "bad input"}}) + with pytest.raises(InvalidRequestError) as exc_info: + _parse_response(resp) + assert exc_info.value.status_code == status + + def test_invalid_request_carries_nested_field_errors(self): + body = {"error": {"message": "validation failed", "fields": {"amount": "required"}}} + resp = _resp(422, json_body=body) + with pytest.raises(InvalidRequestError) as exc_info: + _parse_response(resp) + assert exc_info.value.field_errors == {"amount": "required"} + + def test_invalid_request_carries_top_level_field_errors(self): + body = {"message": "bad", "errors": [{"field": "currency", "msg": "unknown"}]} + resp = _resp(400, json_body=body) + with pytest.raises(InvalidRequestError) as exc_info: + _parse_response(resp) + assert exc_info.value.field_errors == [{"field": "currency", "msg": "unknown"}] + + def test_invalid_request_field_errors_none_when_absent(self): + resp = _resp(400, json_body={"error": {"message": "bad"}}) + with pytest.raises(InvalidRequestError) as exc_info: + _parse_response(resp) + assert exc_info.value.field_errors is None + + def test_not_found_error(self): + resp = _resp(404, json_body={"error": {"message": "missing"}}) + with pytest.raises(NotFoundError) as exc_info: + _parse_response(resp) + assert exc_info.value.status_code == 404 + + def test_rate_limit_error_parses_retry_after(self): + resp = _resp(429, json_body={"error": {"message": "slow down"}}, + headers={"Retry-After": "12"}) + with pytest.raises(RateLimitError) as exc_info: + _parse_response(resp) + assert exc_info.value.status_code == 429 + assert exc_info.value.retry_after == 12 + + def test_rate_limit_error_retry_after_none_when_absent(self): + resp = _resp(429, json_body={"error": {"message": "slow down"}}) + with pytest.raises(RateLimitError) as exc_info: + _parse_response(resp) + assert exc_info.value.retry_after is None + + @pytest.mark.parametrize("status", [500, 502, 503, 504]) + def test_server_error_maps_to_network_error(self, status): + resp = _resp(status, json_body={"error": {"message": "boom"}}) + with pytest.raises(NetworkError) as exc_info: + _parse_response(resp) + assert exc_info.value.status_code == status + + def test_other_4xx_maps_to_http_error(self): + resp = _resp(418, json_body={"error": {"message": "teapot"}}) + with pytest.raises(HTTPError) as exc_info: + _parse_response(resp) + assert exc_info.value.status_code == 418 + + +# --------------------------------------------------------------------------- +# Raw body + status accessible on every exception +# --------------------------------------------------------------------------- + +class TestExceptionContext: + @pytest.mark.parametrize( + "status, exc_type", + [ + (401, AuthenticationError), + (400, InvalidRequestError), + (404, NotFoundError), + (429, RateLimitError), + (503, NetworkError), + (418, HTTPError), + ], + ) + def test_raw_body_and_status_present(self, status, exc_type): + resp = _resp(status, json_body={"error": {"message": "x"}}) + with pytest.raises(exc_type) as exc_info: + _parse_response(resp) + err = exc_info.value + assert err.status_code == status + assert err.response_body == resp.text + assert isinstance(err, ShadeError) + + def test_message_falls_back_when_body_has_no_message(self): + resp = _resp(404, json_body={"foo": "bar"}) + with pytest.raises(NotFoundError) as exc_info: + _parse_response(resp) + assert "Resource not found" in str(exc_info.value)