33from 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+
629class 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
4184class 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
55102class 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
69120class 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
83138def 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+ )
0 commit comments