diff --git a/src/norbix_python/errors.py b/src/norbix_python/errors.py index 73d0325..2ae96cc 100644 --- a/src/norbix_python/errors.py +++ b/src/norbix_python/errors.py @@ -38,6 +38,7 @@ def __init__( details: dict[str, Any] | None = None, errors: list[ErrorItem] | None = None, body: Any = None, + context: dict[str, Any] | None = None, ) -> None: super().__init__(message) self.message = message @@ -48,6 +49,9 @@ def __init__( self.errors = errors or [] #: The answer exactly as it arrived, for whoever needs the rest of it. self.body = body + #: Extra facts about the error the message came from — the missing + #: permission, the reference id of an internal error. Empty when none. + self.context = context or {} @property def http_status(self) -> int: @@ -75,9 +79,16 @@ def __init__( details: dict[str, Any] | None = None, errors: list[ErrorItem] | None = None, body: Any = None, + context: dict[str, Any] | None = None, ) -> None: super().__init__( - message, status=status, code=code, details=details, errors=errors, body=body + message, + status=status, + code=code, + details=details, + errors=errors, + body=body, + context=context, ) @@ -93,9 +104,16 @@ def __init__( details: dict[str, Any] | None = None, errors: list[ErrorItem] | None = None, body: Any = None, + context: dict[str, Any] | None = None, ) -> None: super().__init__( - message, status=status, code=code, details=details, errors=errors, body=body + message, + status=status, + code=code, + details=details, + errors=errors, + body=body, + context=context, ) @@ -111,9 +129,16 @@ def __init__( details: dict[str, Any] | None = None, errors: list[ErrorItem] | None = None, body: Any = None, + context: dict[str, Any] | None = None, ) -> None: super().__init__( - message, status=status, code=code, details=details, errors=errors, body=body + message, + status=status, + code=code, + details=details, + errors=errors, + body=body, + context=context, ) @@ -129,9 +154,16 @@ def __init__( details: dict[str, Any] | None = None, errors: list[ErrorItem] | None = None, body: Any = None, + context: dict[str, Any] | None = None, ) -> None: super().__init__( - message, status=status, code=code, details=details, errors=errors, body=body + message, + status=status, + code=code, + details=details, + errors=errors, + body=body, + context=context, ) @@ -143,6 +175,7 @@ def error_from_http( details: dict[str, Any], errors: list[ErrorItem] | None = None, body: Any = None, + context: dict[str, Any] | None = None, ) -> NorbixError: kwargs: dict[str, Any] = { "status": status, @@ -150,6 +183,7 @@ def error_from_http( "details": details, "errors": errors, "body": body, + "context": context, } if status == 404: return NotFoundError(message, **kwargs) @@ -213,27 +247,48 @@ def _text(value: Any) -> str: return value if isinstance(value, str) and value else "" +def _context_of(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) and value else None + + def error_from_body(*, body: Any, status: int, text: str = "") -> NorbixError: """Build the error a gateway answer describes. - The gateway puts its message and its error code inside - ``responseStatus.errors[]``, not at the top of the block, so that list is - read first: the first entry gives the message and the code, and every entry - is kept in :attr:`NorbixError.errors`. Only when the body has no - ``responseStatus`` are the top-level ``message`` and ``errorCode`` read. - ``Request failed (HTTP )`` is the last fallback, used when the body - says nothing at all — a 500 page that is not JSON, say. + Each of message, code and context is taken from the first place that has + it, in this order: + + 1. the first entry of ``responseStatus.errors[]`` that has a message or a + code — that is where the gateway puts them; + 2. ``responseStatus.message`` / ``errorCode`` / ``context``; + 3. the top-level ``message`` / ``errorCode`` / ``context`` (an answer from + outside the service layer, such as an internal error with a reference id). + + ``Request failed (HTTP )`` and ``HTTP_`` are the last + fallback, used when the body says nothing at all — a 502 page that is not + JSON, say. Every entry of ``errors[]`` is kept in :attr:`NorbixError.errors` + and the body as it arrived in :attr:`NorbixError.body`. """ details: dict[str, Any] = body if isinstance(body, dict) else {} - # source is responseStatus when the body has one, the body itself when it - # has none — so the top-level fields are read only in the second case. - source = _response_status_of(body) or details + response_status = _response_status_of(body) or {} - items = _items_of(source.get("errors")) + items = _items_of(response_status.get("errors")) first = next((i for i in items if i.message or i.error_code), None) - message = (first.message if first else "") or _text(source.get("message")) - code = (first.error_code if first else "") or _text(source.get("errorCode")) + message = ( + (first.message if first else "") + or _text(response_status.get("message")) + or _text(details.get("message")) + ) + code = ( + (first.error_code if first else "") + or _text(response_status.get("errorCode")) + or _text(details.get("errorCode")) + ) + context = ( + (_context_of(first.context) if first else None) + or _context_of(response_status.get("context")) + or _context_of(details.get("context")) + ) if not message: message = f"Request failed (HTTP {status})" @@ -249,4 +304,5 @@ def error_from_body(*, body: Any, status: int, text: str = "") -> NorbixError: details=details, errors=items, body=body if body is not None else (text or None), + context=context, ) diff --git a/tests/test_errors.py b/tests/test_errors.py index 9eaaa6f..65b80e1 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -19,7 +19,13 @@ import httpx import pytest -from norbix_python import AsyncNorbix, Norbix, NorbixError, ValidationError +from norbix_python import ( + AsyncNorbix, + AuthenticationError, + Norbix, + NorbixError, + ValidationError, +) INTEGRATION_ID = "int_7" @@ -209,3 +215,130 @@ async def run() -> None: assert caught.value.http_status == 200 assert caught.value.message == "Integration not found" assert caught.value.error_code == "CM-ERRORS-INTEGRATIONS-001" + + +# --- The gateway's answer shapes since every failure carries a real status --- +# Each case runs through the sync and the async client: both transports must +# read an answer the same way. + +MEMBERSHIP_403 = { + "responseStatus": { + "isSuccess": False, + "errors": [ + { + "message": "Caller is missing required permission 'database:create on db_1'.", + "errorCode": "CM-ERRORS-MEMBERSHIP-039", + "context": {"MissingPermissions": "database:create on db_1"}, + } + ], + } +} + +INTERNAL_500 = { + "message": "A temporary internal error occurred. Reference: ref_1", + "errorCode": "CM-ERRORS-INFRA-NORBIX-001", + "context": {"ReferenceId": "ref_1"}, +} + + +class _AsyncAnswersAny(httpx.AsyncBaseTransport): + def __init__(self, status: int, *, json: Any = None, text: str | None = None) -> None: + self.status = status + self.json = json + self.text = text + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if self.text is not None: + return httpx.Response( + self.status, text=self.text, headers={"Content-Type": "text/html"} + ) + return httpx.Response(self.status, json=self.json) + + +def _call_sync(status: int, *, json: Any = None, text: str | None = None) -> Any: + return a_call(client_answering(status, json=json, text=text)) + + +def _call_async(status: int, *, json: Any = None, text: str | None = None) -> Any: + async def run() -> Any: + client = AsyncNorbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.AsyncClient(transport=_AsyncAnswersAny(status, json=json, text=text)), + ) + return await client.api.files.test_files_integration(INTEGRATION_ID) + + return asyncio.run(run()) + + +both_clients = pytest.mark.parametrize("call", [_call_sync, _call_async], ids=["sync", "async"]) + + +@both_clients +def test_a_403_gives_the_membership_code_message_and_context(call: Any) -> None: + with pytest.raises(AuthenticationError) as caught: + call(403, json=MEMBERSHIP_403) + + err = caught.value + assert err.http_status == 403 + assert err.error_code == "CM-ERRORS-MEMBERSHIP-039" + assert err.message == "Caller is missing required permission 'database:create on db_1'." + assert err.context == {"MissingPermissions": "database:create on db_1"} + assert len(err.errors) == 1 + assert err.body == MEMBERSHIP_403 + + +@both_clients +def test_a_500_with_a_reference_id_reads_the_top_of_the_body(call: Any) -> None: + with pytest.raises(NorbixError) as caught: + call(500, json=INTERNAL_500) + + err = caught.value + assert type(err) is NorbixError + assert err.http_status == 500 + assert err.error_code == "CM-ERRORS-INFRA-NORBIX-001" + assert err.message == "A temporary internal error occurred. Reference: ref_1" + assert err.context == {"ReferenceId": "ref_1"} + assert err.errors == [] + + +@both_clients +def test_a_200_that_says_it_failed_keeps_code_and_context(call: Any) -> None: + with pytest.raises(NorbixError) as caught: + call(200, json=MEMBERSHIP_403) + + err = caught.value + assert err.http_status == 200 + assert err.error_code == "CM-ERRORS-MEMBERSHIP-039" + assert err.context == {"MissingPermissions": "database:create on db_1"} + + +@both_clients +def test_a_502_that_is_not_json_uses_the_generic_message(call: Any) -> None: + with pytest.raises(NorbixError) as caught: + call(502, text="502 Bad Gateway") + + err = caught.value + assert err.http_status == 502 + assert err.message == "Request failed (HTTP 502)" + assert err.error_code == "HTTP_502" + assert err.context == {} + assert err.body == "502 Bad Gateway" + + +@both_clients +def test_a_success_still_comes_back_as_a_value(call: Any) -> None: + body = {"items": [{"result": "OK"}], "responseStatus": {"isSuccess": True}} + + assert call(200, json=body) == body + + +def test_an_empty_errors_list_falls_back_to_the_top_of_the_body() -> None: + """A responseStatus with nothing in it must not hide the top-level fields.""" + body = {"responseStatus": {"isSuccess": False, "errors": []}, **INTERNAL_500} + + with pytest.raises(NorbixError) as caught: + _call_sync(500, json=body) + + assert caught.value.error_code == "CM-ERRORS-INFRA-NORBIX-001" + assert caught.value.context == {"ReferenceId": "ref_1"}