Skip to content

Framework wraps pydantic.ValidationError as INTERNAL_ERROR; should be INVALID_REQUEST #652

@bokelley

Description

@bokelley

Summary

adcp.decisioning.dispatch._invoke_platform_method wraps any uncaught exception from a platform delegate as INTERNAL_ERROR with recovery="terminal". For genuine internals (database failures, NoneType bugs, etc.) that's correct.

But it also catches pydantic.ValidationError, which is request-validation failure — semantically INVALID_REQUEST with recovery="correctable". The current shape is technically wrong (terminal vs correctable, internal vs request) and the wire envelope on A2A specifically degrades to a generic "Task failed" message that strips the field information a buyer needs to fix the request.

Repro

A buyer sends an update_media_buy patch with a field shape that fails the platform's stricter request schema (e.g., the platform extends the library type with extra="forbid" and the buyer includes an extra field, or a type mismatch in a nested object). The delegate calls RequestModel.model_validate(patch), Pydantic raises ValidationError, no caller catches it.

Wire result on A2A: "Task failed" (no adcp_error.code, no field, no recovery hint).
Wire result on MCP: INTERNAL_ERROR: "Platform method 'update_media_buy' raised ValidationError" (terminal recovery — wrong; buyer can correct the payload).

Proposed framework behavior

In _invoke_platform_method (or wherever the catch-all wrapping lives), distinguish pydantic.ValidationError from generic Exception:

except pydantic.ValidationError as exc:
    raise AdcpError(
        code="INVALID_REQUEST",
        message=_first_error_message(exc),
        recovery="correctable",
        field=_first_error_loc_as_dotted_path(exc),
        details={"validation_errors": exc.errors(include_url=False)},
    )
except Exception as exc:
    raise AdcpError(code="INTERNAL_ERROR", ..., recovery="terminal")

This matches the framework's own behavior on request-body validation failures (when the JSON body fails the library schema, the framework already emits the proper INVALID_REQUEST envelope). The bug is just that ValidationErrors raised inside the delegate — typically from a seller's extended request schema — aren't routed the same way.

Workaround (already shipped seller-side)

We extended our @translate_adcp_errors decorator at core/platforms/_delegate.py to also catch pydantic.ValidationError and translate to INVALID_REQUEST before it escapes to the framework: bokelley/salesagent#330.

Every seller that extends RequestModel (which is the recommended pattern in the SDK docs — LibraryX as ParentX; class X(ParentX): ...) will hit this same gap. Fixing at the framework level prevents the eight-line-of-boilerplate-per-seller workaround.

Tests

Cross-transport contract test pattern that exercises real ASGI envelopes:

import httpx
from core.main import build_app

async def test_validation_error_translates_to_invalid_request(transport):
    app = build_app(...)
    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
        response = await client.post(transport_url, json=bad_request_body)
        adcp_error = extract_adcp_error_from_envelope(response, transport)
        assert adcp_error["code"] == "INVALID_REQUEST"
        assert adcp_error["recovery"] == "correctable"
        assert adcp_error["field"]  # populated from validation errors

(See bokelley/salesagent#330tests/integration/test_delegate_wire_envelope_cross_transport.py — for a working version.)

Environment

  • adcp Python SDK: pinned per salesagent's uv.lock (2026-05-11)
  • AdCP spec: 3.0.9

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions