4747 else (httpx .HTTPError , httpx2 .HTTPError )
4848)
4949
50+ from ... import errors as _native_errors
5051from ..errors .genaierror import GenAiError
5152from ..errors .no_response_error import NoResponseError
5253from ..errors .responsevalidationerror import ResponseValidationError
@@ -79,14 +80,20 @@ def __init__(
7980 * ,
8081 body : object ,
8182 ) -> None :
82- super ().__init__ (message )
83+ # Call Exception.__init__ directly so subclasses with multiple bases
84+ # only initialize Exception with `message`.
85+ Exception .__init__ (self , message )
8386 self .request = request
8487 self .message = message
8588 self .body = body
8689
8790 def __str__ (self ) -> str :
8891 return self .message
8992
93+ def __reduce__ (self ) -> tuple [Callable [..., "APIError" ], tuple [Any , ...]]:
94+ # Preserve the concrete error subclass across copy and pickle.
95+ return (_rebuild_error , (type (self ), self .args , self .__dict__ .copy ()))
96+
9097 @classmethod
9198 def generate (
9299 cls ,
@@ -105,17 +112,25 @@ def generate(
105112 )
106113 status_cls = cast (type [APIStatusError ], target_cls )
107114 return status_cls (msg , response = response , body = body )
108- # Plain APIError fallback (status_code None or unexpected success
109- # status like 201). Attach status_code to the instance for callers
110- # that need to inspect it; not part of the Stainless contract for
111- # APIError but covers the unexpected-status path.
115+ # Plain APIError fallback (status_code None or unexpected non-4xx/5xx
116+ # status).
112117 request = response .request if response is not None else None
113118 err = target_cls (msg , request , body = body ) # type: ignore[arg-type]
114119 if status_code is not None :
115120 err .status_code = status_code
116121 return err
117122
118123
124+ def _rebuild_error (
125+ cls : type [APIError ], args : tuple [Any , ...], state : dict [str , Any ]
126+ ) -> APIError :
127+ """Copy/unpickle helper for `APIError.__reduce__`."""
128+ error = cls .__new__ (cls )
129+ Exception .__init__ (error , * args )
130+ error .__dict__ .update (state )
131+ return error
132+
133+
119134class APIResponseValidationError (APIError ):
120135 response : httpx .Response
121136
@@ -135,7 +150,7 @@ def __init__(
135150 self .status_code = response .status_code
136151
137152
138- class APIStatusError (APIError ):
153+ class APIStatusError (APIError , _native_errors . APIError ): # type: ignore[misc]
139154 """Raised when an API response has a 4xx or 5xx status code."""
140155
141156 response : httpx .Response
@@ -150,6 +165,21 @@ def __init__(
150165 super ().__init__ (message , response .request , body = body )
151166 self .response = response
152167 self .status_code = response .status_code
168+ self ._set_native_fields (response )
169+
170+ def _set_native_fields (self , response : httpx .Response ) -> None :
171+ """Populate `code`, `status`, and `details` from the HTTP response."""
172+ details = self .body
173+ if isinstance (details , list ) and len (details ) == 1 :
174+ details = details [0 ]
175+ if not isinstance (details , (dict , list )):
176+ details = {
177+ "message" : details if isinstance (details , str ) else "" ,
178+ "status" : response .reason_phrase ,
179+ }
180+ self .code = response .status_code
181+ self .status = _error_status (details )
182+ self .details = details
153183
154184
155185class APIConnectionError (APIError ):
@@ -174,38 +204,42 @@ def __init__(self, request: httpx.Request) -> None:
174204 )
175205
176206
177- class BadRequestError (APIStatusError ):
207+ class BadRequestError (APIStatusError , _native_errors . ClientError ): # type: ignore[misc]
178208 status_code : int = 400 # type: ignore[assignment]
179209
180210
181- class AuthenticationError (APIStatusError ):
211+ class AuthenticationError (APIStatusError , _native_errors . ClientError ): # type: ignore[misc]
182212 status_code : int = 401 # type: ignore[assignment]
183213
184214
185- class PermissionDeniedError (APIStatusError ):
215+ class PermissionDeniedError (APIStatusError , _native_errors . ClientError ): # type: ignore[misc]
186216 status_code : int = 403 # type: ignore[assignment]
187217
188218
189- class NotFoundError (APIStatusError ):
219+ class NotFoundError (APIStatusError , _native_errors . ClientError ): # type: ignore[misc]
190220 status_code : int = 404 # type: ignore[assignment]
191221
192222
193- class ConflictError (APIStatusError ):
223+ class ConflictError (APIStatusError , _native_errors . ClientError ): # type: ignore[misc]
194224 status_code : int = 409 # type: ignore[assignment]
195225
196226
197- class UnprocessableEntityError (APIStatusError ):
227+ class UnprocessableEntityError (APIStatusError , _native_errors . ClientError ): # type: ignore[misc]
198228 status_code : int = 422 # type: ignore[assignment]
199229
200230
201- class RateLimitError (APIStatusError ):
231+ class RateLimitError (APIStatusError , _native_errors . ClientError ): # type: ignore[misc]
202232 status_code : int = 429 # type: ignore[assignment]
203233
204234
205- class InternalServerError (APIStatusError ):
235+ class InternalServerError (APIStatusError , _native_errors . ServerError ): # type: ignore[misc]
206236 pass
207237
208238
239+ class _ClientStatusError (APIStatusError , _native_errors .ClientError ): # type: ignore[misc]
240+ """Fallback status error for 4xx responses without a dedicated subclass."""
241+
242+
209243_STATUS_MAP = {
210244 400 : BadRequestError ,
211245 401 : AuthenticationError ,
@@ -226,7 +260,7 @@ def _status_class(status_code: Optional[int]) -> type[APIError]:
226260 if 500 <= status_code < 600 :
227261 return InternalServerError
228262 if 400 <= status_code < 500 :
229- return APIStatusError
263+ return _ClientStatusError
230264 return APIError
231265
232266
@@ -245,6 +279,18 @@ def _parse_body(body: Any) -> Any:
245279 return text
246280
247281
282+ def _error_status (body : Any ) -> Optional [str ]:
283+ """Extract the API status string from a parsed error body."""
284+ if not isinstance (body , dict ):
285+ return None
286+ status = body .get ("status" )
287+ if isinstance (status , str ):
288+ return status
289+ error = body .get ("error" )
290+ nested = error .get ("status" ) if isinstance (error , dict ) else None
291+ return nested if isinstance (nested , str ) else None
292+
293+
248294def _compose_message (status_code : Optional [int ], body : Any ) -> str :
249295 """Build an error message in `Error code: {status} - {body}` form.
250296
@@ -279,13 +325,10 @@ def _wrap_no_response_error(error: NoResponseError) -> APIConnectionError:
279325
280326
281327def _wrap_httpx_error (error : BaseException ) -> APIConnectionError :
282- """Translate an `httpx.HTTPError` into the compat connection-error class .
328+ """Translate an `httpx.HTTPError` into `APIConnectionError` or `APITimeoutError` .
283329
284- `httpx.HTTPError.request` is a descriptor that raises `RuntimeError` when
285- the request was never attached. Reads the underlying `_request` attribute
286- directly. May pass `None` if the error was raised before request
287- construction — type hint stays strict (mirrors Stainless) but runtime
288- tolerates the edge case rather than fabricating a misleading stand-in.
330+ Reads `_request` directly since `httpx.HTTPError.request` raises
331+ `RuntimeError` when no request is attached.
289332 """
290333 request = getattr (error , "_request" , None )
291334 if isinstance (error , _HTTPX_TIMEOUT_ERRORS ):
0 commit comments