Skip to content

Commit 0cf3f50

Browse files
authored
Merge pull request #32 from KodeSage/feat/unified_parser
chores: Implement unified response parser
2 parents 9b300f7 + 365f5a3 commit 0cf3f50

5 files changed

Lines changed: 386 additions & 20 deletions

File tree

poetry.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ flake8 = "^6.1.0"
1717
black = "^23.7.0"
1818
isort = "^5.12.0"
1919
aiohttp = "^3.14.1"
20+
httpx = "^0.28.1"
2021

2122

2223
[build-system]

src/shade/errors.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
INVALID_REQUEST_STATUS_CODES = (400, 422)
1010

11+
# Sentinel distinguishing "field_errors not supplied" (parse it from the body)
12+
# from an explicit ``field_errors=None`` passed by the response funnel.
13+
_UNSET = object()
14+
1115

1216
class ShadeError(Exception):
1317
"""Base exception for all Shade SDK errors."""
@@ -76,21 +80,30 @@ class AuthenticationError(ShadeError):
7680

7781

7882
class InvalidRequestError(ShadeError):
79-
"""Raised on HTTP 400/422 responses for malformed or invalid parameters."""
83+
"""Raised when a request is malformed or rejected by validation (HTTP 400/422).
84+
85+
Attributes:
86+
param: The offending parameter named by the API, if any.
87+
field_errors: Field-level validation errors. When supplied explicitly by
88+
the SDK's response funnel it reflects exactly what the body provided
89+
(a dict, a list, or ``None`` when absent). When the error is built
90+
directly from a response body, it is parsed into a dict (``{}`` when
91+
absent).
92+
"""
8093

8194
def __init__(
8295
self,
8396
message: str,
8497
status_code: Optional[int] = None,
8598
response_body: Optional[str] = None,
8699
param: Optional[str] = None,
87-
field_errors: Optional[dict[str, Any]] = None,
100+
field_errors: Any = _UNSET,
88101
) -> None:
89102
super().__init__(message, status_code, response_body)
90103
parsed = _parse_error_response(response_body)
91104
self.param: Optional[str] = param if param is not None else parsed.get("param")
92-
self.field_errors: dict[str, Any] = (
93-
field_errors if field_errors is not None else parsed.get("field_errors", {})
105+
self.field_errors: Any = (
106+
parsed.get("field_errors", {}) if field_errors is _UNSET else field_errors
94107
)
95108

96109
def __str__(self) -> str:
@@ -150,16 +163,6 @@ def from_response(
150163
return cls(message, status_code=404, response_body=response_body)
151164

152165

153-
def _parse_body(response_body: Optional[str]) -> dict:
154-
if not response_body:
155-
return {}
156-
try:
157-
data = json.loads(response_body)
158-
return data if isinstance(data, dict) else {}
159-
except (json.JSONDecodeError, ValueError):
160-
return {}
161-
162-
163166
class NetworkError(ShadeError):
164167
"""Raised when the SDK cannot complete a network request."""
165168

src/shade/http.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
NetworkError,
2929
NotFoundError,
3030
RateLimitError,
31+
ShadeError,
3132
)
3233

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

226227

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

0 commit comments

Comments
 (0)