Skip to content

Commit 5634c68

Browse files
authored
Merge pull request #26 from samueloyibodevv/fix/17-implement-invalid-request-error
Implement InvalidRequestError with field-level validation details
2 parents 9271dd3 + ed59f0b commit 5634c68

2 files changed

Lines changed: 173 additions & 3 deletions

File tree

src/shade/errors.py

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from __future__ import annotations
55

66
import json
7-
from typing import Optional
7+
from typing import Any, Optional
8+
9+
INVALID_REQUEST_STATUS_CODES = (400, 422)
810

911

1012
class ShadeError(Exception):
@@ -74,7 +76,47 @@ class AuthenticationError(ShadeError):
7476

7577

7678
class InvalidRequestError(ShadeError):
77-
"""Raised when a request is malformed or rejected by validation."""
79+
"""Raised on HTTP 400/422 responses for malformed or invalid parameters."""
80+
81+
def __init__(
82+
self,
83+
message: str,
84+
status_code: Optional[int] = None,
85+
response_body: Optional[str] = None,
86+
param: Optional[str] = None,
87+
field_errors: Optional[dict[str, Any]] = None,
88+
) -> None:
89+
super().__init__(message, status_code, response_body)
90+
parsed = _parse_error_response(response_body)
91+
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", {})
94+
)
95+
96+
def __str__(self) -> str:
97+
message = self.message
98+
if self.param:
99+
message = f"{message} (param: {self.param})"
100+
if self.status_code is None:
101+
return message
102+
return f"{message} (status code: {self.status_code})"
103+
104+
@classmethod
105+
def from_response(
106+
cls,
107+
status_code: int,
108+
response_body: Optional[str] = None,
109+
) -> "InvalidRequestError":
110+
"""Construct from a raw 400/422 API response body."""
111+
parsed = _parse_error_response(response_body)
112+
message = parsed.get("message") or "Invalid request"
113+
return cls(
114+
message,
115+
status_code=status_code,
116+
response_body=response_body,
117+
param=parsed.get("param"),
118+
field_errors=parsed.get("field_errors", {}),
119+
)
78120

79121

80122
class NotFoundError(ShadeError):
@@ -119,4 +161,46 @@ def _parse_body(response_body: Optional[str]) -> dict:
119161

120162

121163
class NetworkError(ShadeError):
122-
"""Raised when the SDK cannot complete a network request."""
164+
"""Raised when the SDK cannot complete a network request."""
165+
166+
167+
def raise_for_invalid_request(
168+
status_code: int,
169+
response_body: Optional[str] = None,
170+
) -> None:
171+
"""Raise InvalidRequestError when the API returns 400 or 422."""
172+
if status_code in INVALID_REQUEST_STATUS_CODES:
173+
raise InvalidRequestError.from_response(status_code, response_body)
174+
175+
176+
def _parse_body(response_body: Optional[str]) -> dict[str, Any]:
177+
if not response_body:
178+
return {}
179+
try:
180+
data = json.loads(response_body)
181+
return data if isinstance(data, dict) else {}
182+
except (json.JSONDecodeError, ValueError):
183+
return {}
184+
185+
186+
def _parse_error_response(response_body: Optional[str]) -> dict[str, Any]:
187+
data = _parse_body(response_body)
188+
error = data.get("error", {})
189+
if not isinstance(error, dict):
190+
error = {}
191+
192+
field_errors = error.get("field_errors")
193+
if not isinstance(field_errors, dict):
194+
field_errors = data.get("field_errors")
195+
if not isinstance(field_errors, dict):
196+
field_errors = {}
197+
198+
message = error.get("message")
199+
if not message:
200+
message = data.get("message")
201+
202+
return {
203+
"message": message,
204+
"param": error.get("param"),
205+
"field_errors": field_errors,
206+
}

tests/test_errors.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
import shade
23
from shade import (
34
AuthenticationError,
@@ -7,6 +8,7 @@
78
RateLimitError,
89
ShadeError,
910
)
11+
from shade.errors import raise_for_invalid_request
1012

1113

1214
def test_shade_error_can_be_raised_standalone():
@@ -69,6 +71,90 @@ def test_package_root_exports_error_classes():
6971
assert shade.RateLimitError is RateLimitError
7072

7173

74+
def test_invalid_request_error_parses_param_from_body():
75+
body = (
76+
'{"error": {"code": "invalid_param", "param": "amount", '
77+
'"message": "Amount must be greater than zero"}}'
78+
)
79+
error = InvalidRequestError("invalid request", status_code=400, response_body=body)
80+
81+
assert error.param == "amount"
82+
assert error.message == "invalid request"
83+
84+
85+
def test_invalid_request_error_parses_field_errors_from_body():
86+
body = (
87+
'{"error": {"code": "invalid_param", "param": "amount", "message": "Validation failed", '
88+
'"field_errors": {"amount": "must be positive", "currency": "is required"}}}'
89+
)
90+
error = InvalidRequestError.from_response(400, body)
91+
92+
assert error.param == "amount"
93+
assert error.field_errors == {
94+
"amount": "must be positive",
95+
"currency": "is required",
96+
}
97+
98+
99+
def test_invalid_request_error_str_includes_param():
100+
body = (
101+
'{"error": {"code": "invalid_param", "param": "amount", '
102+
'"message": "Amount must be greater than zero"}}'
103+
)
104+
error = InvalidRequestError.from_response(400, body)
105+
106+
assert "amount" in str(error)
107+
assert "Amount must be greater than zero" in str(error)
108+
assert str(error) == "Amount must be greater than zero (param: amount) (status code: 400)"
109+
110+
111+
def test_invalid_request_error_explicit_attrs_override_body():
112+
body = (
113+
'{"error": {"param": "currency", "field_errors": {"currency": "invalid"}}, '
114+
'"field_errors": {"amount": "too small"}}'
115+
)
116+
error = InvalidRequestError(
117+
"invalid request",
118+
status_code=422,
119+
response_body=body,
120+
param="amount",
121+
field_errors={"amount": "required"},
122+
)
123+
124+
assert error.param == "amount"
125+
assert error.field_errors == {"amount": "required"}
126+
127+
128+
def test_400_response_raises_invalid_request_error():
129+
body = (
130+
'{"error": {"code": "invalid_param", "param": "amount", '
131+
'"message": "Amount must be greater than zero"}}'
132+
)
133+
134+
with pytest.raises(InvalidRequestError) as exc_info:
135+
raise_for_invalid_request(400, body)
136+
137+
error = exc_info.value
138+
assert error.status_code == 400
139+
assert error.param == "amount"
140+
assert isinstance(error, ShadeError)
141+
142+
143+
def test_422_response_raises_invalid_request_error():
144+
body = '{"error": {"param": "email", "message": "Invalid email format"}}'
145+
146+
with pytest.raises(InvalidRequestError) as exc_info:
147+
raise_for_invalid_request(422, body)
148+
149+
assert exc_info.value.param == "email"
150+
assert exc_info.value.status_code == 422
151+
152+
153+
def test_raise_for_invalid_request_ignores_other_status_codes():
154+
raise_for_invalid_request(404, '{"error": {"message": "not found"}}')
155+
raise_for_invalid_request(500, '{"error": {"message": "server error"}}')
156+
157+
72158
def test_not_found_error_is_shade_error():
73159
error = NotFoundError("not found", status_code=404)
74160
assert isinstance(error, ShadeError)

0 commit comments

Comments
 (0)