From 3d40c2f09c65157fcf5a36af7a2352b93546771e Mon Sep 17 00:00:00 2001 From: CodeMash Date: Tue, 22 Sep 2026 11:51:06 +0300 Subject: [PATCH] feat(errors)!: [10b-ERRORS] report the gateway's real error and fail on a failed answer The gateway puts the message and the error code inside `responseStatus.errors[]`. The SDK read the top of the body, so callers saw the whole JSON as the message and the code `HTTP_404` (#66). It also treated every 2xx as a success, while the gateway answers a business refusal with HTTP 200 and `responseStatus.isSuccess = False` (#67). The new `error_from_body` reads `responseStatus.errors[]` first (first entry for the message and the code, every entry kept in the new `NorbixError.errors` as `ErrorItem`), falls back to the top of `responseStatus`, then to the top of the body when there is no `responseStatus`, and only then to `Request failed (HTTP )` with the code `HTTP_`. `NorbixError` also keeps the answer in `body` and answers `http_status` / `error_code`. Both the sync and the async transport now raise on a 200 whose body says `isSuccess: False`. Binary answers (file download, the public file link) return before the check and are unchanged. BREAKING CHANGE: a call the gateway refused with HTTP 200 and `responseStatus.isSuccess = False` used to come back as a value and now raises a NorbixError with http_status 200. Code that checked `result["responseStatus"]["isSuccess"]` itself moves into a try / except. See the "Errors" section of the README. Co-Authored-By: Claude Fable 5.1 --- README.md | 37 ++++++ src/norbix_python/__init__.py | 2 + src/norbix_python/errors.py | 172 +++++++++++++++++++++++++-- src/norbix_python/transport.py | 50 +++++--- tests/test_errors.py | 211 +++++++++++++++++++++++++++++++++ 5 files changed, 444 insertions(+), 28 deletions(-) create mode 100644 tests/test_errors.py diff --git a/README.md b/README.md index 8d4c963..f8bba41 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,43 @@ account = norbix.account.get_account_profile() print(account) ``` +## Errors + +```python +from norbix_python import NorbixError + +try: + norbix.files.get_file_info(integration_id, path="a/b.txt") +except NorbixError as exc: + # http_status / error_code are the names every Norbix SDK uses. + # status / code are the same values, kept for older code. + print(exc.http_status, exc.error_code, exc.message) + for item in exc.errors: + print(item.error_code, item.field_name, item.message) + print(exc.body) # the answer exactly as it arrived +``` + +`message` and `error_code` are the gateway's own. The gateway puts them inside +`responseStatus.errors[]`, so the SDK reads that list first, takes the first +entry for the message and the code, and keeps every entry in `errors`. Only +when the body has no `responseStatus` are the top-level `message` and +`errorCode` read. `Request failed (HTTP )` with the code +`HTTP_` is the last fallback, used when the body says nothing — a 500 +page that is not JSON, say. + +### Breaking change — a refused call now raises + +The gateway answers a business refusal (an unknown id, a rule that says no) +with **HTTP 200** and `responseStatus.isSuccess = False`. The SDK used to hand +that answer back as a normal value, so code carried on as if the call had +worked. It now raises a `NorbixError` with `http_status` 200 and the gateway's +message and error code. + +If your code checked `result["responseStatus"]["isSuccess"]` itself, move that +check into a `try / except`. Endpoints that answer with raw bytes rather than a +document (file download, the public file link) are not JSON and are unchanged. +Both the sync and the async client follow the same rule. + ## Breaking changes (recent major-style refresh) - Methods use **snake_case** (`find_one`, `get_database_schemas`) instead of camelCase. diff --git a/src/norbix_python/__init__.py b/src/norbix_python/__init__.py index 049bf1a..ddaa334 100644 --- a/src/norbix_python/__init__.py +++ b/src/norbix_python/__init__.py @@ -3,6 +3,7 @@ from .client import AsyncNorbix, LoginCredentials, Norbix, NorbixApi, NorbixHub from .errors import ( AuthenticationError, + ErrorItem, NorbixError, NotFoundError, RateLimitError, @@ -20,6 +21,7 @@ "AuthLoginResult", "AuthenticationError", "DatabaseFindResult", + "ErrorItem", "LoginCredentials", "Norbix", "NorbixApi", diff --git a/src/norbix_python/errors.py b/src/norbix_python/errors.py index 263cb6f..73d0325 100644 --- a/src/norbix_python/errors.py +++ b/src/norbix_python/errors.py @@ -3,6 +3,29 @@ from typing import Any +class ErrorItem: + """One error inside the gateway's ``responseStatus.errors`` list.""" + + def __init__( + self, + *, + error_code: str = "", + message: str = "", + field_name: str = "", + context: dict[str, Any] | None = None, + ) -> None: + self.error_code = error_code + self.message = message + self.field_name = field_name + self.context = context or {} + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return ( + f"ErrorItem(error_code={self.error_code!r}, message={self.message!r}, " + f"field_name={self.field_name!r})" + ) + + class NorbixError(Exception): """Base error for Norbix SDK failures.""" @@ -13,12 +36,28 @@ def __init__( status: int = 0, code: str = "NORBIX_ERROR", details: dict[str, Any] | None = None, + errors: list[ErrorItem] | None = None, + body: Any = None, ) -> None: super().__init__(message) self.message = message self.status = status self.code = code self.details = details or {} + #: Every error the gateway sent, in the order it sent them. + self.errors = errors or [] + #: The answer exactly as it arrived, for whoever needs the rest of it. + self.body = body + + @property + def http_status(self) -> int: + """Same value as :attr:`status`. The name every Norbix SDK uses.""" + return self.status + + @property + def error_code(self) -> str: + """Same value as :attr:`code`. The name every Norbix SDK uses.""" + return self.code def __str__(self) -> str: return f"{self.code} ({self.status}): {self.message}" @@ -34,8 +73,12 @@ def __init__( status: int = 0, code: str = "NORBIX_AUTHENTICATION_ERROR", details: dict[str, Any] | None = None, + errors: list[ErrorItem] | None = None, + body: Any = None, ) -> None: - super().__init__(message, status=status, code=code, details=details) + super().__init__( + message, status=status, code=code, details=details, errors=errors, body=body + ) class NotFoundError(NorbixError): @@ -48,8 +91,12 @@ def __init__( status: int = 404, code: str = "NORBIX_NOT_FOUND", details: dict[str, Any] | None = None, + errors: list[ErrorItem] | None = None, + body: Any = None, ) -> None: - super().__init__(message, status=status, code=code, details=details) + super().__init__( + message, status=status, code=code, details=details, errors=errors, body=body + ) class RateLimitError(NorbixError): @@ -62,8 +109,12 @@ def __init__( status: int = 429, code: str = "NORBIX_RATE_LIMIT", details: dict[str, Any] | None = None, + errors: list[ErrorItem] | None = None, + body: Any = None, ) -> None: - super().__init__(message, status=status, code=code, details=details) + super().__init__( + message, status=status, code=code, details=details, errors=errors, body=body + ) class ValidationError(NorbixError): @@ -76,8 +127,12 @@ def __init__( status: int = 0, code: str = "NORBIX_VALIDATION_ERROR", details: dict[str, Any] | None = None, + errors: list[ErrorItem] | None = None, + body: Any = None, ) -> None: - super().__init__(message, status=status, code=code, details=details) + super().__init__( + message, status=status, code=code, details=details, errors=errors, body=body + ) def error_from_http( @@ -86,13 +141,112 @@ def error_from_http( status: int, code: str, details: dict[str, Any], + errors: list[ErrorItem] | None = None, + body: Any = None, ) -> NorbixError: + kwargs: dict[str, Any] = { + "status": status, + "code": code, + "details": details, + "errors": errors, + "body": body, + } if status == 404: - return NotFoundError(message, status=status, code=code, details=details) + return NotFoundError(message, **kwargs) if status == 429: - return RateLimitError(message, status=status, code=code, details=details) + return RateLimitError(message, **kwargs) if status == 401 or status == 403: - return AuthenticationError(message, status=status, code=code, details=details) + return AuthenticationError(message, **kwargs) if status == 400 or status == 422: - return ValidationError(message, status=status, code=code, details=details) - return NorbixError(message, status=status, code=code, details=details) + return ValidationError(message, **kwargs) + return NorbixError(message, **kwargs) + + +def _response_status_of(body: Any) -> dict[str, Any] | None: + """The ``responseStatus`` block of a body, whatever the casing of the key.""" + if not isinstance(body, dict): + return None + for key in ("responseStatus", "ResponseStatus"): + value = body.get(key) + if isinstance(value, dict): + return value + return None + + +def says_it_failed(body: Any) -> bool: + """``True`` when the body carries ``responseStatus.isSuccess = False``. + + The gateway answers a business refusal — an unknown id, a rule that says + no — with HTTP 200 and that flag. Without this check the SDK would hand + such an answer back as a value and the caller would carry on as if the call + had worked (10b-files, issue #67). + """ + status = _response_status_of(body) + if status is None: + return False + for key in ("isSuccess", "IsSuccess"): + if key in status: + return status[key] is False + return False + + +def _items_of(value: Any) -> list[ErrorItem]: + if not isinstance(value, list): + return [] + items: list[ErrorItem] = [] + for entry in value: + if not isinstance(entry, dict): + continue + context = entry.get("context") + items.append( + ErrorItem( + error_code=_text(entry.get("errorCode")), + message=_text(entry.get("message")), + field_name=_text(entry.get("fieldName")), + context=context if isinstance(context, dict) else None, + ) + ) + return items + + +def _text(value: Any) -> str: + return value if isinstance(value, str) and value else "" + + +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. + """ + 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 + + items = _items_of(source.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")) + + if not message: + message = f"Request failed (HTTP {status})" + if not code: + # Callers switch on ``code``, so it is never left empty; the gateway's + # own code wins whenever the gateway sent one. + code = f"HTTP_{status}" + + return error_from_http( + message=message, + status=status, + code=code, + details=details, + errors=items, + body=body if body is not None else (text or None), + ) diff --git a/src/norbix_python/transport.py b/src/norbix_python/transport.py index d095cbd..f1c5e5d 100644 --- a/src/norbix_python/transport.py +++ b/src/norbix_python/transport.py @@ -9,7 +9,7 @@ import httpx -from .errors import NorbixError, error_from_http +from .errors import NorbixError, error_from_body, says_it_failed HttpVerb = Literal["GET", "POST", "PUT", "PATCH", "DELETE"] Scope = Literal["project", "account", "unauthenticated"] @@ -125,28 +125,34 @@ def send( raise NorbixError(message=str(exc), code="NORBIX_NETWORK_ERROR") from exc if response.status_code >= 400: - data: dict[str, Any] try: - parsed = response.json() - data = parsed if isinstance(parsed, dict) else {} + parsed: Any = response.json() except ValueError: - data = {} - raise error_from_http( - message=data.get("message", response.text or "Request failed"), + parsed = None + raise error_from_body( + body=parsed, status=response.status_code, - code=data.get("errorCode", f"HTTP_{response.status_code}"), - details=data, + text=response.text, ) + # An endpoint that answers with a file, not a document. Those answers + # are not JSON, so the check below does not apply to them. if response_type == "binary": return response.content if response.status_code == 204 or not response.content: return None try: - return response.json() + parsed_ok: Any = response.json() except ValueError: return response.text + # A 2xx does not mean the call worked: the gateway answers a business + # refusal with HTTP 200 and responseStatus.isSuccess = False, and that + # is a failure the caller must see (10b-files, issue #67). + if says_it_failed(parsed_ok): + raise error_from_body(body=parsed_ok, status=response.status_code) + return parsed_ok + def _request_with_retries( self, *, @@ -267,28 +273,34 @@ async def send( raise NorbixError(message=str(exc), code="NORBIX_NETWORK_ERROR") from exc if response.status_code >= 400: - data: dict[str, Any] try: - parsed = response.json() - data = parsed if isinstance(parsed, dict) else {} + parsed: Any = response.json() except ValueError: - data = {} - raise error_from_http( - message=data.get("message", response.text or "Request failed"), + parsed = None + raise error_from_body( + body=parsed, status=response.status_code, - code=data.get("errorCode", f"HTTP_{response.status_code}"), - details=data, + text=response.text, ) + # An endpoint that answers with a file, not a document. Those answers + # are not JSON, so the check below does not apply to them. if response_type == "binary": return response.content if response.status_code == 204 or not response.content: return None try: - return response.json() + parsed_ok: Any = response.json() except ValueError: return response.text + # A 2xx does not mean the call worked: the gateway answers a business + # refusal with HTTP 200 and responseStatus.isSuccess = False, and that + # is a failure the caller must see (10b-files, issue #67). + if says_it_failed(parsed_ok): + raise error_from_body(body=parsed_ok, status=response.status_code) + return parsed_ok + async def _request_with_retries( self, *, diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..9eaaa6f --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,211 @@ +"""What the caller sees when a call fails (10b-files slice ERRORS, #66 and #67). + +Two rules are pinned here. First, the message and the error code are the +gateway's own: the gateway puts them inside ``responseStatus.errors[]``, so +reading the top of that block gave every caller the whole body as the message +and the code ``HTTP_404`` — that was #66. Second, a call fails when the gateway +says it failed, even with HTTP 200 and ``responseStatus.isSuccess = False`` — +that was #67. + +Every test builds its own client and its own fake answer, so the order the +tests run in does not matter and no real server is contacted. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import httpx +import pytest + +from norbix_python import AsyncNorbix, Norbix, NorbixError, ValidationError + +INTEGRATION_ID = "int_7" + + +class _Answers(httpx.BaseTransport): + """Answers every request with one status and one body.""" + + def __init__(self, status: int, *, json: Any = None, text: str | None = None) -> None: + self.status = status + self.json = json + self.text = text + + def handle_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 client_answering(status: int, *, json: Any = None, text: str | None = None) -> Norbix: + return Norbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.Client(transport=_Answers(status, json=json, text=text)), + ) + + +def a_call(client: Norbix) -> Any: + """Any call does; the transport is the same for all of them.""" + return client.api.files.test_files_integration(INTEGRATION_ID) + + +# (a) HTTP 400 with two errors inside responseStatus.errors +def test_a_400_takes_message_and_code_from_the_first_error_and_keeps_them_all() -> None: + client = client_answering( + 400, + json={ + "responseStatus": { + "isSuccess": False, + "errors": [ + { + "message": "File name is required", + "errorCode": "CM-ERRORS-FILES-002", + "fieldName": "fileName", + }, + { + "message": "Folder does not exist", + "errorCode": "CM-ERRORS-FILES-016", + "context": {"Provider": "Local"}, + }, + ], + } + }, + ) + + with pytest.raises(ValidationError) as caught: + a_call(client) + + err = caught.value + assert err.message == "File name is required" + assert err.error_code == "CM-ERRORS-FILES-002" + assert err.http_status == 400 + assert len(err.errors) == 2 + assert err.errors[0].field_name == "fileName" + assert err.errors[1].error_code == "CM-ERRORS-FILES-016" + assert err.errors[1].context == {"Provider": "Local"} + # The body as it arrived is kept for anyone who needs the rest of it. + assert err.body["responseStatus"]["isSuccess"] is False + + +# (b) HTTP 200 whose body says the call failed +def test_a_200_that_says_it_failed_raises_with_the_gateway_message() -> None: + client = client_answering( + 200, + json={ + "responseStatus": { + "isSuccess": False, + "errors": [ + { + "message": 'File not found: "a/b.txt" does not exist in Local (int_7).', + "errorCode": "CM-ERRORS-FILES-016", + } + ], + } + }, + ) + + with pytest.raises(NorbixError) as caught: + a_call(client) + + err = caught.value + assert err.http_status == 200 + assert err.message == 'File not found: "a/b.txt" does not exist in Local (int_7).' + assert err.error_code == "CM-ERRORS-FILES-016" + + +# (c) HTTP 200 that says the call worked — unchanged +def test_a_200_that_says_it_worked_still_comes_back_as_a_value() -> None: + client = client_answering( + 200, + json={ + "items": [{"operation": "UploadFile", "result": "OK"}], + "responseStatus": {"isSuccess": True}, + }, + ) + + result = a_call(client) + + assert result["items"][0]["result"] == "OK" + + +def test_a_200_with_no_response_status_still_comes_back_as_a_value() -> None: + client = client_answering(200, json={"items": []}) + + assert a_call(client) == {"items": []} + + +# (d) a 500 whose body is not JSON at all +def test_a_500_with_a_body_that_is_not_json_uses_the_fallback_text() -> None: + client = client_answering(500, text="Bad Gateway") + + with pytest.raises(NorbixError) as caught: + a_call(client) + + err = caught.value + assert err.message == "Request failed (HTTP 500)" + assert err.error_code == "HTTP_500" + assert err.body == "Bad Gateway" + + +def test_an_empty_error_body_uses_the_fallback_text_too() -> None: + client = client_answering(404, json={}) + + with pytest.raises(NorbixError) as caught: + a_call(client) + + assert caught.value.message == "Request failed (HTTP 404)" + + +def test_reads_the_top_of_the_body_when_there_is_no_response_status() -> None: + client = client_answering( + 409, json={"message": "Already exists", "errorCode": "CM-ERRORS-FILES-009"} + ) + + with pytest.raises(NorbixError) as caught: + a_call(client) + + assert caught.value.message == "Already exists" + assert caught.value.error_code == "CM-ERRORS-FILES-009" + + +class _AsyncAnswers(httpx.AsyncBaseTransport): + def __init__(self, status: int, json: Any) -> None: + self.status = status + self.json = json + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return httpx.Response(self.status, json=self.json) + + +def test_the_async_client_fails_on_a_200_that_says_it_failed() -> None: + """The async transport has its own copy of the check; it must agree.""" + transport = _AsyncAnswers( + 200, + { + "responseStatus": { + "isSuccess": False, + "errors": [ + {"message": "Integration not found", "errorCode": "CM-ERRORS-INTEGRATIONS-001"} + ], + } + }, + ) + + async def run() -> None: + client = AsyncNorbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.AsyncClient(transport=transport), + ) + await client.api.files.test_files_integration(INTEGRATION_ID) + + with pytest.raises(NorbixError) as caught: + asyncio.run(run()) + + assert caught.value.http_status == 200 + assert caught.value.message == "Integration not found" + assert caught.value.error_code == "CM-ERRORS-INTEGRATIONS-001"