Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `TangoValidationError` now exposes the API's structured validation details
directly: `.issues` (the list of `{"path": ..., "reason": ...}` entries the
server returns for shape errors) and `.available_fields` (the endpoint's
valid field set, when included). Both were previously reachable only by
digging through `.response_data`. ([#45](https://github.com/makegov/tango-python/issues/45))

### Changed
- 400 error messages now name the rejected field(s) and reason when the API
returns structured `issues` — e.g.
`Invalid request parameters: Invalid shape: tradeoff_process (unknown_field)`
instead of just `Invalid request parameters: Invalid shape`. ([#45](https://github.com/makegov/tango-python/issues/45))

## [1.2.0] - 2026-06-05

### Added
Expand Down
11 changes: 11 additions & 0 deletions tango/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ def _request(
)
if detail:
error_msg = f"Invalid request parameters: {detail}"
issues = error_data.get("issues")
if isinstance(issues, list):
rejected = [
f"{issue['path']} ({issue['reason']})"
if issue.get("reason")
else str(issue["path"])
for issue in issues
if isinstance(issue, dict) and issue.get("path")
]
if rejected:
error_msg = f"{error_msg}: {', '.join(rejected)}"
raise TangoValidationError(
error_msg,
response.status_code,
Expand Down
19 changes: 18 additions & 1 deletion tango/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,24 @@ class TangoNotFoundError(TangoAPIError):
class TangoValidationError(TangoAPIError):
"""Request validation error"""

pass
@property
def issues(self) -> list[dict[str, Any]]:
"""Structured validation issues from the API response.

For shape errors the API returns entries like
``{"path": "tradeoff_process", "reason": "unknown_field"}``.
Empty list when the response carried no structured issues.
"""
val = self.response_data.get("issues")
if not isinstance(val, list):
return []
return [item for item in val if isinstance(item, dict)]

@property
def available_fields(self) -> dict[str, Any] | None:
"""The endpoint's valid field set, when the API includes one."""
val = self.response_data.get("available_fields")
return val if isinstance(val, dict) else None


class TangoRateLimitError(TangoAPIError):
Expand Down
49 changes: 49 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,55 @@ def test_400_validation_error(self, mock_request):
assert exc_info.value.status_code == 400
assert exc_info.value.response_data == {"error": "invalid params"}

@patch("tango.client.httpx.Client.request")
def test_400_structured_shape_error(self, mock_request):
"""Test 400 with structured shape-error body surfaces issues and available_fields"""
body = {
"error": "Invalid shape",
"issues": [{"path": "fair_opportunity_limited_sources", "reason": "unknown_field"}],
"available_fields": {"fields": ["piid", "competition"]},
}
mock_response = Mock()
mock_response.is_success = False
mock_response.status_code = 400
mock_response.content = b"x"
mock_response.json.return_value = body
mock_request.return_value = mock_response

client = TangoClient(api_key="test-key")

with pytest.raises(TangoValidationError) as exc_info:
client.list_agencies()

err = exc_info.value
assert str(err) == (
"Invalid request parameters: Invalid shape: "
"fair_opportunity_limited_sources (unknown_field)"
)
assert err.issues == [
{"path": "fair_opportunity_limited_sources", "reason": "unknown_field"}
]
assert err.available_fields == {"fields": ["piid", "competition"]}
assert err.response_data == body

@patch("tango.client.httpx.Client.request")
def test_400_validation_error_issues_accessors_empty(self, mock_request):
"""Test issues/available_fields accessors on a plain 400 body"""
mock_response = Mock()
mock_response.is_success = False
mock_response.status_code = 400
mock_response.content = b'{"error": "invalid params"}'
mock_response.json.return_value = {"error": "invalid params"}
mock_request.return_value = mock_response

client = TangoClient(api_key="test-key")

with pytest.raises(TangoValidationError) as exc_info:
client.list_agencies()

assert exc_info.value.issues == []
assert exc_info.value.available_fields is None

@patch("tango.client.httpx.Client.request")
def test_400_validation_error_no_content(self, mock_request):
"""Test 400 with no content"""
Expand Down
Loading