diff --git a/poetry.lock b/poetry.lock index 1c3dba2..cbb437f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -189,7 +189,7 @@ version = "4.14.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"}, {file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"}, @@ -281,7 +281,7 @@ version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, @@ -718,7 +718,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -730,7 +730,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -752,7 +752,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1769,4 +1769,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "734f74e33a7dc083bdc6ceaabe25bf80ce2f1a277ae56fd09574b91864c9ce15" +content-hash = "2aafbf3a0b56e50586010064755ac3151bafde9f644a4534af18964a97622287" diff --git a/pyproject.toml b/pyproject.toml index 48edca0..6dc4155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ flake8 = "^6.1.0" black = "^23.7.0" isort = "^5.12.0" aiohttp = "^3.14.1" +httpx = "^0.28.1" [build-system] diff --git a/src/shade/errors.py b/src/shade/errors.py index 8a57889..4805f9c 100644 --- a/src/shade/errors.py +++ b/src/shade/errors.py @@ -8,6 +8,10 @@ INVALID_REQUEST_STATUS_CODES = (400, 422) +# Sentinel distinguishing "field_errors not supplied" (parse it from the body) +# from an explicit ``field_errors=None`` passed by the response funnel. +_UNSET = object() + class ShadeError(Exception): """Base exception for all Shade SDK errors.""" @@ -76,7 +80,16 @@ class AuthenticationError(ShadeError): class InvalidRequestError(ShadeError): - """Raised on HTTP 400/422 responses for malformed or invalid parameters.""" + """Raised when a request is malformed or rejected by validation (HTTP 400/422). + + Attributes: + param: The offending parameter named by the API, if any. + field_errors: Field-level validation errors. When supplied explicitly by + the SDK's response funnel it reflects exactly what the body provided + (a dict, a list, or ``None`` when absent). When the error is built + directly from a response body, it is parsed into a dict (``{}`` when + absent). + """ def __init__( self, @@ -84,13 +97,13 @@ def __init__( status_code: Optional[int] = None, response_body: Optional[str] = None, param: Optional[str] = None, - field_errors: Optional[dict[str, Any]] = None, + field_errors: Any = _UNSET, ) -> None: super().__init__(message, status_code, response_body) parsed = _parse_error_response(response_body) self.param: Optional[str] = param if param is not None else parsed.get("param") - self.field_errors: dict[str, Any] = ( - field_errors if field_errors is not None else parsed.get("field_errors", {}) + self.field_errors: Any = ( + parsed.get("field_errors", {}) if field_errors is _UNSET else field_errors ) def __str__(self) -> str: @@ -150,16 +163,6 @@ def from_response( return cls(message, status_code=404, response_body=response_body) -def _parse_body(response_body: Optional[str]) -> dict: - if not response_body: - return {} - try: - data = json.loads(response_body) - return data if isinstance(data, dict) else {} - except (json.JSONDecodeError, ValueError): - return {} - - class NetworkError(ShadeError): """Raised when the SDK cannot complete a network request.""" diff --git a/src/shade/http.py b/src/shade/http.py index 5118de9..fbd87a2 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -28,6 +28,7 @@ NetworkError, NotFoundError, RateLimitError, + ShadeError, ) logger = logging.getLogger(__name__) @@ -224,6 +225,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)