Skip to content

Commit 37d8c7e

Browse files
committed
chores: Implement unified response parser
1 parent 3ad7931 commit 37d8c7e

3 files changed

Lines changed: 378 additions & 1 deletion

File tree

src/shade/errors.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,22 @@ class AuthenticationError(ShadeError):
7474

7575

7676
class InvalidRequestError(ShadeError):
77-
"""Raised when a request is malformed or rejected by validation."""
77+
"""Raised when a request is malformed or rejected by validation.
78+
79+
Attributes:
80+
field_errors: Field-level validation errors extracted from the response
81+
body, if the API provided them. ``None`` when absent.
82+
"""
83+
84+
def __init__(
85+
self,
86+
message: str,
87+
status_code: Optional[int] = None,
88+
response_body: Optional[str] = None,
89+
field_errors: Optional[object] = None,
90+
) -> None:
91+
super().__init__(message, status_code, response_body)
92+
self.field_errors = field_errors
7893

7994

8095
class NotFoundError(ShadeError):

src/shade/http.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
NetworkError,
2727
NotFoundError,
2828
RateLimitError,
29+
ShadeError,
2930
)
3031

3132
logger = logging.getLogger(__name__)
@@ -223,6 +224,170 @@ def _raise_for_status(
223224
raise HTTPError(f"HTTP {status}: {detail}".strip(), status_code=status)
224225

225226

227+
# ---------------------------------------------------------------------------
228+
# Single response parser
229+
# ---------------------------------------------------------------------------
230+
231+
def _error_message(data: Any, default: str) -> str:
232+
"""Extract a human-readable message from a parsed error body.
233+
234+
Handles the common shapes ``{"error": {"message": ...}}``,
235+
``{"error": "..."}`` and ``{"message": ...}``. Falls back to *default*
236+
when nothing usable is present (including when the body failed to decode).
237+
"""
238+
if isinstance(data, dict):
239+
err = data.get("error")
240+
if isinstance(err, dict):
241+
message = err.get("message")
242+
if message:
243+
return str(message)
244+
elif isinstance(err, str) and err:
245+
return err
246+
message = data.get("message")
247+
if message:
248+
return str(message)
249+
return default
250+
251+
252+
def _field_errors(data: Any) -> Optional[Any]:
253+
"""Extract field-level validation errors from a parsed error body, if any.
254+
255+
Looks for ``fields``/``field_errors``/``errors`` either nested under
256+
``error`` or at the top level. Returns ``None`` when absent.
257+
"""
258+
candidates = []
259+
if isinstance(data, dict):
260+
err = data.get("error")
261+
if isinstance(err, dict):
262+
candidates.append(err)
263+
candidates.append(data)
264+
for source in candidates:
265+
for key in ("fields", "field_errors", "errors"):
266+
fields = source.get(key)
267+
if fields:
268+
return fields
269+
return None
270+
271+
272+
def _parse_response(response: "httpx.Response") -> Dict[str, Any]:
273+
"""Parse an ``httpx.Response`` into a dict, mapping errors to typed exceptions.
274+
275+
This is the single funnel every resource method should route responses
276+
through. Centralizing JSON decoding, success detection, and the mapping of
277+
HTTP status codes to the SDK's typed exception hierarchy here keeps error
278+
handling from drifting between resources.
279+
280+
Parameters
281+
----------
282+
response : httpx.Response
283+
The response returned by an httpx request.
284+
285+
Returns
286+
-------
287+
dict
288+
The decoded JSON body of a successful (2xx) response.
289+
290+
Raises
291+
------
292+
AuthenticationError
293+
For HTTP 401/403.
294+
InvalidRequestError
295+
For HTTP 400/422, carrying field-level errors when the body provides
296+
them.
297+
NotFoundError
298+
For HTTP 404.
299+
RateLimitError
300+
For HTTP 429.
301+
NetworkError
302+
For HTTP 5xx (subject to retry by callers).
303+
HTTPError
304+
For any other non-2xx status not covered above.
305+
ShadeError
306+
When a 2xx body cannot be decoded as JSON, or a 2xx body itself
307+
carries an ``error`` key. The raw body and HTTP status are attached to
308+
every raised exception.
309+
"""
310+
status = response.status_code
311+
body = response.text
312+
313+
# Decode up-front so the raw body can drive both error mapping and the
314+
# success path. A decode failure is captured rather than raised here so
315+
# error statuses still produce their typed exception with the raw body.
316+
try:
317+
data: Any = json.loads(body) if body else {}
318+
decoded = True
319+
except (json.JSONDecodeError, ValueError):
320+
data = None
321+
decoded = False
322+
323+
if 200 <= status < 300:
324+
if not decoded:
325+
raise ShadeError(
326+
"Invalid response from API",
327+
status_code=status,
328+
response_body=body,
329+
)
330+
if not isinstance(data, dict):
331+
raise ShadeError(
332+
"Invalid response from API",
333+
status_code=status,
334+
response_body=body,
335+
)
336+
# A 2xx body that still carries an error is treated as a failure.
337+
if data.get("error"):
338+
raise ShadeError(
339+
_error_message(data, "API returned an error"),
340+
status_code=status,
341+
response_body=body,
342+
)
343+
return data
344+
345+
if status in (401, 403):
346+
raise AuthenticationError(
347+
_error_message(data, "Authentication failed"),
348+
status_code=status,
349+
response_body=body,
350+
)
351+
352+
if status in (400, 422):
353+
raise InvalidRequestError(
354+
_error_message(data, "Invalid request"),
355+
status_code=status,
356+
response_body=body,
357+
field_errors=_field_errors(data),
358+
)
359+
360+
if status == 404:
361+
raise NotFoundError(
362+
_error_message(data, "Resource not found"),
363+
status_code=status,
364+
response_body=body,
365+
)
366+
367+
if status == 429:
368+
raise RateLimitError(
369+
_error_message(data, "Rate limit exceeded"),
370+
retry_after=_parse_retry_after(response.headers),
371+
status_code=status,
372+
response_body=body,
373+
)
374+
375+
if 500 <= status < 600:
376+
raise NetworkError(
377+
_error_message(data, f"Server error: {status}"),
378+
status_code=status,
379+
response_body=body,
380+
)
381+
382+
# Any other non-2xx status (e.g. 3xx, uncommon 4xx) still maps to a typed
383+
# exception so nothing escapes the funnel unhandled.
384+
raise HTTPError(
385+
_error_message(data, f"HTTP {status}"),
386+
status_code=status,
387+
response_body=body,
388+
)
389+
390+
226391
# ---------------------------------------------------------------------------
227392
# Synchronous client
228393
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)