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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <status>)` with the code
`HTTP_<status>` 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.
Expand Down
2 changes: 2 additions & 0 deletions src/norbix_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .client import AsyncNorbix, LoginCredentials, Norbix, NorbixApi, NorbixHub
from .errors import (
AuthenticationError,
ErrorItem,
NorbixError,
NotFoundError,
RateLimitError,
Expand All @@ -20,6 +21,7 @@
"AuthLoginResult",
"AuthenticationError",
"DatabaseFindResult",
"ErrorItem",
"LoginCredentials",
"Norbix",
"NorbixApi",
Expand Down
172 changes: 163 additions & 9 deletions src/norbix_python/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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}"
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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(
Expand All @@ -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 <status>)`` 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),
)
50 changes: 31 additions & 19 deletions src/norbix_python/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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,
*,
Expand Down
Loading
Loading