Skip to content

Commit bf7c2e0

Browse files
makegov-mark[bot]vdavezclaude
authored
feat(client): surface structured shape errors on TangoValidationError (#46)
400 messages now name the rejected field(s) and reason when the API returns structured issues, and TangoValidationError gains .issues and .available_fields accessors over response_data. Closes #45 (client half; server half tracked in makegov/tango#2586). Co-authored-by: V. David Zvenyach <dave@zvenyach.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a14ba50 commit bf7c2e0

4 files changed

Lines changed: 91 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `TangoValidationError` now exposes the API's structured validation details
12+
directly: `.issues` (the list of `{"path": ..., "reason": ...}` entries the
13+
server returns for shape errors) and `.available_fields` (the endpoint's
14+
valid field set, when included). Both were previously reachable only by
15+
digging through `.response_data`. ([#45](https://github.com/makegov/tango-python/issues/45))
16+
17+
### Changed
18+
- 400 error messages now name the rejected field(s) and reason when the API
19+
returns structured `issues` — e.g.
20+
`Invalid request parameters: Invalid shape: tradeoff_process (unknown_field)`
21+
instead of just `Invalid request parameters: Invalid shape`. ([#45](https://github.com/makegov/tango-python/issues/45))
22+
1023
## [1.2.0] - 2026-06-05
1124

1225
### Added

tango/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ def _request(
183183
)
184184
if detail:
185185
error_msg = f"Invalid request parameters: {detail}"
186+
issues = error_data.get("issues")
187+
if isinstance(issues, list):
188+
rejected = [
189+
f"{issue['path']} ({issue['reason']})"
190+
if issue.get("reason")
191+
else str(issue["path"])
192+
for issue in issues
193+
if isinstance(issue, dict) and issue.get("path")
194+
]
195+
if rejected:
196+
error_msg = f"{error_msg}: {', '.join(rejected)}"
186197
raise TangoValidationError(
187198
error_msg,
188199
response.status_code,

tango/exceptions.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,24 @@ class TangoNotFoundError(TangoAPIError):
3333
class TangoValidationError(TangoAPIError):
3434
"""Request validation error"""
3535

36-
pass
36+
@property
37+
def issues(self) -> list[dict[str, Any]]:
38+
"""Structured validation issues from the API response.
39+
40+
For shape errors the API returns entries like
41+
``{"path": "tradeoff_process", "reason": "unknown_field"}``.
42+
Empty list when the response carried no structured issues.
43+
"""
44+
val = self.response_data.get("issues")
45+
if not isinstance(val, list):
46+
return []
47+
return [item for item in val if isinstance(item, dict)]
48+
49+
@property
50+
def available_fields(self) -> dict[str, Any] | None:
51+
"""The endpoint's valid field set, when the API includes one."""
52+
val = self.response_data.get("available_fields")
53+
return val if isinstance(val, dict) else None
3754

3855

3956
class TangoRateLimitError(TangoAPIError):

tests/test_client.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,55 @@ def test_400_validation_error(self, mock_request):
11521152
assert exc_info.value.status_code == 400
11531153
assert exc_info.value.response_data == {"error": "invalid params"}
11541154

1155+
@patch("tango.client.httpx.Client.request")
1156+
def test_400_structured_shape_error(self, mock_request):
1157+
"""Test 400 with structured shape-error body surfaces issues and available_fields"""
1158+
body = {
1159+
"error": "Invalid shape",
1160+
"issues": [{"path": "fair_opportunity_limited_sources", "reason": "unknown_field"}],
1161+
"available_fields": {"fields": ["piid", "competition"]},
1162+
}
1163+
mock_response = Mock()
1164+
mock_response.is_success = False
1165+
mock_response.status_code = 400
1166+
mock_response.content = b"x"
1167+
mock_response.json.return_value = body
1168+
mock_request.return_value = mock_response
1169+
1170+
client = TangoClient(api_key="test-key")
1171+
1172+
with pytest.raises(TangoValidationError) as exc_info:
1173+
client.list_agencies()
1174+
1175+
err = exc_info.value
1176+
assert str(err) == (
1177+
"Invalid request parameters: Invalid shape: "
1178+
"fair_opportunity_limited_sources (unknown_field)"
1179+
)
1180+
assert err.issues == [
1181+
{"path": "fair_opportunity_limited_sources", "reason": "unknown_field"}
1182+
]
1183+
assert err.available_fields == {"fields": ["piid", "competition"]}
1184+
assert err.response_data == body
1185+
1186+
@patch("tango.client.httpx.Client.request")
1187+
def test_400_validation_error_issues_accessors_empty(self, mock_request):
1188+
"""Test issues/available_fields accessors on a plain 400 body"""
1189+
mock_response = Mock()
1190+
mock_response.is_success = False
1191+
mock_response.status_code = 400
1192+
mock_response.content = b'{"error": "invalid params"}'
1193+
mock_response.json.return_value = {"error": "invalid params"}
1194+
mock_request.return_value = mock_response
1195+
1196+
client = TangoClient(api_key="test-key")
1197+
1198+
with pytest.raises(TangoValidationError) as exc_info:
1199+
client.list_agencies()
1200+
1201+
assert exc_info.value.issues == []
1202+
assert exc_info.value.available_fields is None
1203+
11551204
@patch("tango.client.httpx.Client.request")
11561205
def test_400_validation_error_no_content(self, mock_request):
11571206
"""Test 400 with no content"""

0 commit comments

Comments
 (0)