diff --git a/src/shade/errors.py b/src/shade/errors.py index 710bf34..dbbcfbc 100644 --- a/src/shade/errors.py +++ b/src/shade/errors.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Optional @@ -32,7 +33,44 @@ class InvalidRequestError(ShadeError): class NotFoundError(ShadeError): - """Raised when an API resource cannot be found.""" + """Raised on HTTP 404 responses. + + Attributes: + resource_type: Kind of resource that was not found (e.g. "payment", "invoice"). + resource_id: ID of the missing resource. + """ + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + response_body: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + ) -> None: + super().__init__(message, status_code, response_body) + parsed = _parse_body(response_body) + self.resource_type: Optional[str] = resource_type or parsed.get("resource_type") + self.resource_id: Optional[str] = resource_id or parsed.get("resource_id") + + @classmethod + def from_response( + cls, + message: str, + response_body: Optional[str] = None, + ) -> "NotFoundError": + """Construct from a raw 404 response body.""" + 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): diff --git a/tests/test_errors.py b/tests/test_errors.py index 4339d2b..c8fbe64 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -49,3 +49,61 @@ def test_package_root_exports_error_classes(): assert shade.InvalidRequestError is InvalidRequestError assert shade.NetworkError is NetworkError assert shade.NotFoundError is NotFoundError + + +def test_not_found_error_is_shade_error(): + error = NotFoundError("not found", status_code=404) + assert isinstance(error, ShadeError) + + +def test_not_found_error_parses_resource_from_body(): + body = '{"resource_type": "payment", "resource_id": "pay_abc123"}' + error = NotFoundError("not found", status_code=404, response_body=body) + + assert error.resource_type == "payment" + assert error.resource_id == "pay_abc123" + + +def test_not_found_error_explicit_attrs_override_body(): + body = '{"resource_type": "invoice", "resource_id": "inv_999"}' + error = NotFoundError( + "not found", + status_code=404, + response_body=body, + resource_type="payment", + resource_id="pay_001", + ) + + assert error.resource_type == "payment" + assert error.resource_id == "pay_001" + + +def test_not_found_error_none_when_body_missing(): + error = NotFoundError("not found", status_code=404) + + assert error.resource_type is None + assert error.resource_id is None + + +def test_not_found_error_none_when_body_lacks_fields(): + error = NotFoundError("not found", status_code=404, response_body='{"error":"gone"}') + + assert error.resource_type is None + assert error.resource_id is None + + +def test_not_found_error_from_response_factory(): + body = '{"resource_type": "invoice", "resource_id": "inv_456"}' + error = NotFoundError.from_response("invoice not found", response_body=body) + + assert error.status_code == 404 + assert error.resource_type == "invoice" + assert error.resource_id == "inv_456" + assert isinstance(error, ShadeError) + + +def test_not_found_error_invalid_json_body(): + error = NotFoundError("not found", status_code=404, response_body="not-json") + + assert error.resource_type is None + assert error.resource_id is None