Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 73 additions & 17 deletions src/norbix_python/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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,
)


Expand All @@ -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,
)


Expand All @@ -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,
)


Expand All @@ -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,
)


Expand All @@ -143,13 +175,15 @@ 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,
"code": code,
"details": details,
"errors": errors,
"body": body,
"context": context,
}
if status == 404:
return NotFoundError(message, **kwargs)
Expand Down Expand Up @@ -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 <status>)`` 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 <status>)`` and ``HTTP_<status>`` 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})"
Expand All @@ -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,
)
135 changes: 134 additions & 1 deletion tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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="<html>502 Bad Gateway</html>")

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 == "<html>502 Bad Gateway</html>"


@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"}
Loading