diff --git a/CHANGELOG.md b/CHANGELOG.md index 264c92e..e2cf62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/foxnose_sdk/errors.py b/src/foxnose_sdk/errors.py index 6935089..601a4d0 100644 --- a/src/foxnose_sdk/errors.py +++ b/src/foxnose_sdk/errors.py @@ -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 @@ -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). @@ -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" diff --git a/tests/test_http_transport.py b/tests/test_http_transport.py index e465265..6e76c1f 100644 --- a/tests/test_http_transport.py +++ b/tests/test_http_transport.py @@ -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)