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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `data_validation_error` (HTTP 422) now raises `ContentValidationFailed` instead of a bare `FoxnoseAPIError`. This is the code a Flux resource write returns for a schema violation, so the most common way to hit one was not caught by `except ContentValidationFailed`, and callers had to match on `status_code == 422` instead. The `errors` list and `errors_truncated` flag are populated from the same detail payload as `content_validation_failed`, and `error_code` still reports what the server sent.

## [0.6.0] - 2026-07-16

### Added
Expand Down
20 changes: 17 additions & 3 deletions src/foxnose_sdk/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,13 @@ class ExternalIdConflict(FoxnoseAPIError):


class ContentValidationFailed(FoxnoseAPIError):
"""Raised on HTTP 422 ``content_validation_failed`` — the submitted ``data``
failed the collection's schema.
"""Raised on HTTP 422 when submitted ``data`` fails the collection's schema.

Two server error codes mean this, and both map here: ``content_validation_
failed`` and ``data_validation_error``. The latter is what a Flux resource
write returns, so leaving it unmapped meant the most common way to hit a
schema violation raised a bare ``FoxnoseAPIError``, and
``except ContentValidationFailed`` never fired for it.

``errors`` is the list of individual validation problems, each a mapping that
includes a ``json_path`` locating the offending field. ``errors_truncated`` is
Expand Down Expand Up @@ -137,6 +142,15 @@ class FoxnoseTransportError(FoxnoseError):
"""Raised when the HTTP layer fails before receiving a response."""


# Both codes describe the same failure -- submitted data rejected by the
# collection schema -- and carry the same detail shape. The resource-write path
# raises RevisionValidationError ("data_validation_error"); other paths raise
# the content variant.
_CONTENT_VALIDATION_CODES = frozenset(
{"content_validation_failed", "data_validation_error"}
)


def _validation_errors_from_detail(detail: Any) -> tuple[list, bool]:
"""Normalize a validation ``detail`` payload into (errors, truncated).

Expand Down Expand Up @@ -237,7 +251,7 @@ def build_api_error(
base_kwargs["message"] = "Resource key already exists"
return ExternalIdConflict(**base_kwargs)

if status_code == 422 and error_code == "content_validation_failed":
if status_code == 422 and error_code in _CONTENT_VALIDATION_CODES:
errors, truncated = _validation_errors_from_detail(detail)
if not message:
base_kwargs["message"] = "Content validation failed"
Expand Down
39 changes: 39 additions & 0 deletions tests/test_http_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,45 @@ def test_build_api_error_content_validation_multiple_truncated():
assert err.errors_truncated is True


def test_build_api_error_data_validation_error_is_the_same_failure():
"""A Flux resource write reports a schema violation with this code.

It used to fall through to a bare FoxnoseAPIError, so the documented
`except ContentValidationFailed` never fired on the most common way to
reach a schema violation, and callers had to match on the status code.
"""
detail = {"json_path": "$.title", "message": "required", "validator": "required"}
err = _api_error(422, "data_validation_error", detail=detail)
assert isinstance(err, ContentValidationFailed)
assert err.errors == [detail]
assert err.errors_truncated is False


def test_build_api_error_data_validation_error_multiple_truncated():
detail = {
"json_path": "multiple",
"errors": [{"json_path": "$.a"}, {"json_path": "$.b"}],
"errors_truncated": True,
"errors_total": 250,
}
err = _api_error(422, "data_validation_error", detail=detail)
assert isinstance(err, ContentValidationFailed)
assert [e["json_path"] for e in err.errors] == ["$.a", "$.b"]
assert err.errors_truncated is True


def test_build_api_error_data_validation_error_keeps_its_own_code():
"""Mapped onto a shared class, but the wire code is not rewritten."""
err = _api_error(422, "data_validation_error", detail={"json_path": "$.x"})
assert err.error_code == "data_validation_error"


def test_build_api_error_other_422_still_falls_through():
"""Only the two validation codes map; 422 alone must not."""
err = _api_error(422, "draft_not_supported")
assert type(err) is FoxnoseAPIError


def test_build_api_error_upstream():
err = _api_error(502, "upstream_error", body={"error_code": "upstream_error"})
assert isinstance(err, UpstreamError)
Expand Down
Loading