Skip to content

Commit 29ff61d

Browse files
Domantasclaude
andcommitted
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 <status>)` with the code `HTTP_<status>`. `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 <noreply@anthropic.com>
1 parent a89b059 commit 29ff61d

5 files changed

Lines changed: 444 additions & 28 deletions

File tree

‎README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,43 @@ account = norbix.account.get_account_profile()
9898
print(account)
9999
```
100100

101+
## Errors
102+
103+
```python
104+
from norbix_python import NorbixError
105+
106+
try:
107+
norbix.files.get_file_info(integration_id, path="a/b.txt")
108+
except NorbixError as exc:
109+
# http_status / error_code are the names every Norbix SDK uses.
110+
# status / code are the same values, kept for older code.
111+
print(exc.http_status, exc.error_code, exc.message)
112+
for item in exc.errors:
113+
print(item.error_code, item.field_name, item.message)
114+
print(exc.body) # the answer exactly as it arrived
115+
```
116+
117+
`message` and `error_code` are the gateway's own. The gateway puts them inside
118+
`responseStatus.errors[]`, so the SDK reads that list first, takes the first
119+
entry for the message and the code, and keeps every entry in `errors`. Only
120+
when the body has no `responseStatus` are the top-level `message` and
121+
`errorCode` read. `Request failed (HTTP <status>)` with the code
122+
`HTTP_<status>` is the last fallback, used when the body says nothing — a 500
123+
page that is not JSON, say.
124+
125+
### Breaking change — a refused call now raises
126+
127+
The gateway answers a business refusal (an unknown id, a rule that says no)
128+
with **HTTP 200** and `responseStatus.isSuccess = False`. The SDK used to hand
129+
that answer back as a normal value, so code carried on as if the call had
130+
worked. It now raises a `NorbixError` with `http_status` 200 and the gateway's
131+
message and error code.
132+
133+
If your code checked `result["responseStatus"]["isSuccess"]` itself, move that
134+
check into a `try / except`. Endpoints that answer with raw bytes rather than a
135+
document (file download, the public file link) are not JSON and are unchanged.
136+
Both the sync and the async client follow the same rule.
137+
101138
## Breaking changes (recent major-style refresh)
102139

103140
- Methods use **snake_case** (`find_one`, `get_database_schemas`) instead of camelCase.

‎src/norbix_python/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .client import AsyncNorbix, LoginCredentials, Norbix, NorbixApi, NorbixHub
44
from .errors import (
55
AuthenticationError,
6+
ErrorItem,
67
NorbixError,
78
NotFoundError,
89
RateLimitError,
@@ -20,6 +21,7 @@
2021
"AuthLoginResult",
2122
"AuthenticationError",
2223
"DatabaseFindResult",
24+
"ErrorItem",
2325
"LoginCredentials",
2426
"Norbix",
2527
"NorbixApi",

‎src/norbix_python/errors.py‎

Lines changed: 163 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,29 @@
33
from typing import Any
44

55

6+
class ErrorItem:
7+
"""One error inside the gateway's ``responseStatus.errors`` list."""
8+
9+
def __init__(
10+
self,
11+
*,
12+
error_code: str = "",
13+
message: str = "",
14+
field_name: str = "",
15+
context: dict[str, Any] | None = None,
16+
) -> None:
17+
self.error_code = error_code
18+
self.message = message
19+
self.field_name = field_name
20+
self.context = context or {}
21+
22+
def __repr__(self) -> str: # pragma: no cover - debugging aid
23+
return (
24+
f"ErrorItem(error_code={self.error_code!r}, message={self.message!r}, "
25+
f"field_name={self.field_name!r})"
26+
)
27+
28+
629
class NorbixError(Exception):
730
"""Base error for Norbix SDK failures."""
831

@@ -13,12 +36,28 @@ def __init__(
1336
status: int = 0,
1437
code: str = "NORBIX_ERROR",
1538
details: dict[str, Any] | None = None,
39+
errors: list[ErrorItem] | None = None,
40+
body: Any = None,
1641
) -> None:
1742
super().__init__(message)
1843
self.message = message
1944
self.status = status
2045
self.code = code
2146
self.details = details or {}
47+
#: Every error the gateway sent, in the order it sent them.
48+
self.errors = errors or []
49+
#: The answer exactly as it arrived, for whoever needs the rest of it.
50+
self.body = body
51+
52+
@property
53+
def http_status(self) -> int:
54+
"""Same value as :attr:`status`. The name every Norbix SDK uses."""
55+
return self.status
56+
57+
@property
58+
def error_code(self) -> str:
59+
"""Same value as :attr:`code`. The name every Norbix SDK uses."""
60+
return self.code
2261

2362
def __str__(self) -> str:
2463
return f"{self.code} ({self.status}): {self.message}"
@@ -34,8 +73,12 @@ def __init__(
3473
status: int = 0,
3574
code: str = "NORBIX_AUTHENTICATION_ERROR",
3675
details: dict[str, Any] | None = None,
76+
errors: list[ErrorItem] | None = None,
77+
body: Any = None,
3778
) -> None:
38-
super().__init__(message, status=status, code=code, details=details)
79+
super().__init__(
80+
message, status=status, code=code, details=details, errors=errors, body=body
81+
)
3982

4083

4184
class NotFoundError(NorbixError):
@@ -48,8 +91,12 @@ def __init__(
4891
status: int = 404,
4992
code: str = "NORBIX_NOT_FOUND",
5093
details: dict[str, Any] | None = None,
94+
errors: list[ErrorItem] | None = None,
95+
body: Any = None,
5196
) -> None:
52-
super().__init__(message, status=status, code=code, details=details)
97+
super().__init__(
98+
message, status=status, code=code, details=details, errors=errors, body=body
99+
)
53100

54101

55102
class RateLimitError(NorbixError):
@@ -62,8 +109,12 @@ def __init__(
62109
status: int = 429,
63110
code: str = "NORBIX_RATE_LIMIT",
64111
details: dict[str, Any] | None = None,
112+
errors: list[ErrorItem] | None = None,
113+
body: Any = None,
65114
) -> None:
66-
super().__init__(message, status=status, code=code, details=details)
115+
super().__init__(
116+
message, status=status, code=code, details=details, errors=errors, body=body
117+
)
67118

68119

69120
class ValidationError(NorbixError):
@@ -76,8 +127,12 @@ def __init__(
76127
status: int = 0,
77128
code: str = "NORBIX_VALIDATION_ERROR",
78129
details: dict[str, Any] | None = None,
130+
errors: list[ErrorItem] | None = None,
131+
body: Any = None,
79132
) -> None:
80-
super().__init__(message, status=status, code=code, details=details)
133+
super().__init__(
134+
message, status=status, code=code, details=details, errors=errors, body=body
135+
)
81136

82137

83138
def error_from_http(
@@ -86,13 +141,112 @@ def error_from_http(
86141
status: int,
87142
code: str,
88143
details: dict[str, Any],
144+
errors: list[ErrorItem] | None = None,
145+
body: Any = None,
89146
) -> NorbixError:
147+
kwargs: dict[str, Any] = {
148+
"status": status,
149+
"code": code,
150+
"details": details,
151+
"errors": errors,
152+
"body": body,
153+
}
90154
if status == 404:
91-
return NotFoundError(message, status=status, code=code, details=details)
155+
return NotFoundError(message, **kwargs)
92156
if status == 429:
93-
return RateLimitError(message, status=status, code=code, details=details)
157+
return RateLimitError(message, **kwargs)
94158
if status == 401 or status == 403:
95-
return AuthenticationError(message, status=status, code=code, details=details)
159+
return AuthenticationError(message, **kwargs)
96160
if status == 400 or status == 422:
97-
return ValidationError(message, status=status, code=code, details=details)
98-
return NorbixError(message, status=status, code=code, details=details)
161+
return ValidationError(message, **kwargs)
162+
return NorbixError(message, **kwargs)
163+
164+
165+
def _response_status_of(body: Any) -> dict[str, Any] | None:
166+
"""The ``responseStatus`` block of a body, whatever the casing of the key."""
167+
if not isinstance(body, dict):
168+
return None
169+
for key in ("responseStatus", "ResponseStatus"):
170+
value = body.get(key)
171+
if isinstance(value, dict):
172+
return value
173+
return None
174+
175+
176+
def says_it_failed(body: Any) -> bool:
177+
"""``True`` when the body carries ``responseStatus.isSuccess = False``.
178+
179+
The gateway answers a business refusal — an unknown id, a rule that says
180+
no — with HTTP 200 and that flag. Without this check the SDK would hand
181+
such an answer back as a value and the caller would carry on as if the call
182+
had worked (10b-files, issue #67).
183+
"""
184+
status = _response_status_of(body)
185+
if status is None:
186+
return False
187+
for key in ("isSuccess", "IsSuccess"):
188+
if key in status:
189+
return status[key] is False
190+
return False
191+
192+
193+
def _items_of(value: Any) -> list[ErrorItem]:
194+
if not isinstance(value, list):
195+
return []
196+
items: list[ErrorItem] = []
197+
for entry in value:
198+
if not isinstance(entry, dict):
199+
continue
200+
context = entry.get("context")
201+
items.append(
202+
ErrorItem(
203+
error_code=_text(entry.get("errorCode")),
204+
message=_text(entry.get("message")),
205+
field_name=_text(entry.get("fieldName")),
206+
context=context if isinstance(context, dict) else None,
207+
)
208+
)
209+
return items
210+
211+
212+
def _text(value: Any) -> str:
213+
return value if isinstance(value, str) and value else ""
214+
215+
216+
def error_from_body(*, body: Any, status: int, text: str = "") -> NorbixError:
217+
"""Build the error a gateway answer describes.
218+
219+
The gateway puts its message and its error code inside
220+
``responseStatus.errors[]``, not at the top of the block, so that list is
221+
read first: the first entry gives the message and the code, and every entry
222+
is kept in :attr:`NorbixError.errors`. Only when the body has no
223+
``responseStatus`` are the top-level ``message`` and ``errorCode`` read.
224+
``Request failed (HTTP <status>)`` is the last fallback, used when the body
225+
says nothing at all — a 500 page that is not JSON, say.
226+
"""
227+
details: dict[str, Any] = body if isinstance(body, dict) else {}
228+
# source is responseStatus when the body has one, the body itself when it
229+
# has none — so the top-level fields are read only in the second case.
230+
source = _response_status_of(body) or details
231+
232+
items = _items_of(source.get("errors"))
233+
first = next((i for i in items if i.message or i.error_code), None)
234+
235+
message = (first.message if first else "") or _text(source.get("message"))
236+
code = (first.error_code if first else "") or _text(source.get("errorCode"))
237+
238+
if not message:
239+
message = f"Request failed (HTTP {status})"
240+
if not code:
241+
# Callers switch on ``code``, so it is never left empty; the gateway's
242+
# own code wins whenever the gateway sent one.
243+
code = f"HTTP_{status}"
244+
245+
return error_from_http(
246+
message=message,
247+
status=status,
248+
code=code,
249+
details=details,
250+
errors=items,
251+
body=body if body is not None else (text or None),
252+
)

‎src/norbix_python/transport.py‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import httpx
1111

12-
from .errors import NorbixError, error_from_http
12+
from .errors import NorbixError, error_from_body, says_it_failed
1313

1414
HttpVerb = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
1515
Scope = Literal["project", "account", "unauthenticated"]
@@ -125,28 +125,34 @@ def send(
125125
raise NorbixError(message=str(exc), code="NORBIX_NETWORK_ERROR") from exc
126126

127127
if response.status_code >= 400:
128-
data: dict[str, Any]
129128
try:
130-
parsed = response.json()
131-
data = parsed if isinstance(parsed, dict) else {}
129+
parsed: Any = response.json()
132130
except ValueError:
133-
data = {}
134-
raise error_from_http(
135-
message=data.get("message", response.text or "Request failed"),
131+
parsed = None
132+
raise error_from_body(
133+
body=parsed,
136134
status=response.status_code,
137-
code=data.get("errorCode", f"HTTP_{response.status_code}"),
138-
details=data,
135+
text=response.text,
139136
)
140137

138+
# An endpoint that answers with a file, not a document. Those answers
139+
# are not JSON, so the check below does not apply to them.
141140
if response_type == "binary":
142141
return response.content
143142
if response.status_code == 204 or not response.content:
144143
return None
145144
try:
146-
return response.json()
145+
parsed_ok: Any = response.json()
147146
except ValueError:
148147
return response.text
149148

149+
# A 2xx does not mean the call worked: the gateway answers a business
150+
# refusal with HTTP 200 and responseStatus.isSuccess = False, and that
151+
# is a failure the caller must see (10b-files, issue #67).
152+
if says_it_failed(parsed_ok):
153+
raise error_from_body(body=parsed_ok, status=response.status_code)
154+
return parsed_ok
155+
150156
def _request_with_retries(
151157
self,
152158
*,
@@ -267,28 +273,34 @@ async def send(
267273
raise NorbixError(message=str(exc), code="NORBIX_NETWORK_ERROR") from exc
268274

269275
if response.status_code >= 400:
270-
data: dict[str, Any]
271276
try:
272-
parsed = response.json()
273-
data = parsed if isinstance(parsed, dict) else {}
277+
parsed: Any = response.json()
274278
except ValueError:
275-
data = {}
276-
raise error_from_http(
277-
message=data.get("message", response.text or "Request failed"),
279+
parsed = None
280+
raise error_from_body(
281+
body=parsed,
278282
status=response.status_code,
279-
code=data.get("errorCode", f"HTTP_{response.status_code}"),
280-
details=data,
283+
text=response.text,
281284
)
282285

286+
# An endpoint that answers with a file, not a document. Those answers
287+
# are not JSON, so the check below does not apply to them.
283288
if response_type == "binary":
284289
return response.content
285290
if response.status_code == 204 or not response.content:
286291
return None
287292
try:
288-
return response.json()
293+
parsed_ok: Any = response.json()
289294
except ValueError:
290295
return response.text
291296

297+
# A 2xx does not mean the call worked: the gateway answers a business
298+
# refusal with HTTP 200 and responseStatus.isSuccess = False, and that
299+
# is a failure the caller must see (10b-files, issue #67).
300+
if says_it_failed(parsed_ok):
301+
raise error_from_body(body=parsed_ok, status=response.status_code)
302+
return parsed_ok
303+
292304
async def _request_with_retries(
293305
self,
294306
*,

0 commit comments

Comments
 (0)