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
27 changes: 27 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,33 @@ except ADCPTaskError as exc:
Custom error codes (outside `STANDARD_ERROR_CODES`) default to
`recovery="terminal"`. Override with `adcp_error("MY_CODE", recovery="correctable")`.

### Rejecting a request with every problem

A `DecisioningPlatform` method that validates a whole request raises one
`AdcpError` carrying every problem, so the buyer fixes the request in one
pass. `AdcpError.from_errors` puts the first entry in the envelope
`adcp_error` and all of them in the payload `errors[]`, on MCP and A2A:

```python
from adcp.decisioning import AdcpError

problems = []
if package_budget < floor:
problems.append(AdcpError("BUDGET_TOO_LOW", message="budget below minimum",
recovery="correctable", field="packages[0].budget"))
if end_time <= start_time:
problems.append(AdcpError("INVALID_REQUEST", message="end_time before start_time",
recovery="correctable", field="end_time"))
if problems:
raise AdcpError.from_errors(problems)
```

Entries may also be generated `Error` models or wire mappings; one without
`recovery` takes the raised error's. To keep a summary envelope instead, pass
the list as `AdcpError("INVALID_REQUEST", message="2 problems", errors=problems)`.
A list in `details["errors"]` whose entries carry a string `code` and
`message` is lifted into `errors[]` too.

## Response builders

Manual `model_dump()` on response Pydantic objects is error-prone —
Expand Down
107 changes: 104 additions & 3 deletions src/adcp/decisioning/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from __future__ import annotations

from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Generic, Literal

Expand Down Expand Up @@ -50,6 +50,7 @@
AccountScope,
BusinessEntity,
CreditLimit,
Error,
GovernanceAgent,
PaymentTerms,
ReportingBucket,
Expand Down Expand Up @@ -105,8 +106,28 @@ class AdcpError(Exception):
error.
:param retry_after: Seconds to wait before retrying. Only
meaningful with ``recovery='transient'``.
:param details: Free-form extras for codes that need them
(e.g. ``{'errors': [...]}`` for multi-error preflight).
:param details: Free-form extras for codes that need them.
:param errors: Every problem found, for a request rejected with
several at once (a whole-request preflight). Each entry is an
:class:`AdcpError`, a generated ``Error`` model or a wire mapping
with string ``code`` and ``message``; an entry without
``recovery`` takes this error's. The framework writes the list to
the payload ``errors[]`` and this error to the envelope
``adcp_error``. :meth:`from_errors` builds the error from the
list, so ``adcp_error`` is the first entry. Without ``errors``,
``errors[]`` carries this error alone, unless ``details['errors']``
holds entries of that shape (the slot this class documented for
multi-error preflight before ``errors`` existed), which are
lifted into ``errors[]``.

Example — reject a whole request with every problem::

raise AdcpError.from_errors([
AdcpError("BUDGET_TOO_LOW", message="budget below minimum",
recovery="correctable", field="packages[0].budget"),
AdcpError("INVALID_REQUEST", message="end_time before start_time",
recovery="correctable", field="end_time"),
])
"""

def __init__(
Expand All @@ -121,6 +142,7 @@ def __init__(
suggestion: str | None = None,
retry_after: int | None = None,
details: dict[str, Any] | None = None,
errors: Sequence[AdcpError | Error | Mapping[str, Any]] | None = None,
) -> None:
super().__init__(message or code)
self.code = code
Expand All @@ -129,6 +151,38 @@ def __init__(
self.suggestion = suggestion
self.retry_after = retry_after
self.details = details or {}
if errors is not None:
self.errors: list[dict[str, Any]] = [
_error_entry_to_wire(entry, recovery) for entry in errors
]
else:
self.errors = _lift_detail_errors(self.details, recovery)

@classmethod
def from_errors(cls, errors: Sequence[AdcpError | Error | Mapping[str, Any]], /) -> AdcpError:
"""Build one raisable error that rejects a request for every entry.

The first entry fills the envelope ``adcp_error``; all of them,
in order, fill the payload ``errors[]``. Entries are as for the
``errors`` parameter; one without ``recovery`` takes the first
entry's (``terminal`` when the first has none).

:raises ValueError: if ``errors`` is empty.
"""
if not errors:
raise ValueError("AdcpError.from_errors needs at least one error")
first = _error_entry_to_wire(errors[0], "terminal")
details = first.get("details")
return AdcpError(
first["code"],
message=first["message"],
recovery=first["recovery"],
field=first.get("field"),
suggestion=first.get("suggestion"),
retry_after=first.get("retry_after"),
details=dict(details) if isinstance(details, Mapping) else None,
errors=errors,
)

def __str__(self) -> str:
return f"AdcpError[{self.code} / {self.recovery}]: {self.args[0]}"
Expand Down Expand Up @@ -158,6 +212,53 @@ def to_wire(self) -> dict[str, Any]:
out["details"] = details
return out

def errors_to_wire(self) -> list[dict[str, Any]]:
"""Project to the payload ``errors[]``: every entry, else this error."""
if self.errors:
return [dict(entry) for entry in self.errors]
return [self.to_wire()]


def _error_entry_to_wire(entry: Any, recovery: str) -> dict[str, Any]:
"""Normalize one ``errors`` entry to its wire dict."""
if isinstance(entry, AdcpError):
return entry.to_wire()
if hasattr(entry, "model_dump"):
entry = entry.model_dump(mode="json", exclude_none=True)
if not isinstance(entry, Mapping):
raise TypeError(
"AdcpError errors entries must be AdcpError, Error or a mapping, "
f"got {type(entry).__name__}"
)
code, message = entry.get("code"), entry.get("message")
if not isinstance(code, str) or not code or not isinstance(message, str):
raise ValueError("AdcpError errors entries need a non-empty string code and a message")
out = {key: value for key, value in entry.items() if value is not None}
out.setdefault("recovery", recovery)
if out.get("details"):
details = sanitize_error_details(code, out["details"])
if details:
out["details"] = details
else:
del out["details"]
return out


def _lift_detail_errors(details: Mapping[str, Any], recovery: str) -> list[dict[str, Any]]:
"""Read the documented ``details['errors']`` multi-error slot, if well formed."""
entries = details.get("errors")
if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes)) or not entries:
return []
if not all(
isinstance(entry, Mapping)
and isinstance(entry.get("code"), str)
and entry["code"]
and isinstance(entry.get("message"), str)
for entry in entries
):
return []
return [_error_entry_to_wire(entry, recovery) for entry in entries]


#: Per-platform metadata generic. Defaults to ``dict[str, Any]`` for
#: adopters who don't define a typed metadata shape; multi-tenant adopters
Expand Down
7 changes: 5 additions & 2 deletions src/adcp/server/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,7 +839,7 @@ async def _send_adcp_error(
# Lazy import — ``translate.py`` pulls in heavier server deps
# (mcp.types) which the A2A module doesn't otherwise need.
from adcp.server.helpers import inject_context
from adcp.server.translate import _extract_structured_fields
from adcp.server.translate import _extract_structured_fields, _payload_errors

code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields(
exc
Expand All @@ -863,7 +863,10 @@ async def _send_adcp_error(

# A fatal failure populates both layers (L3 error handling): the payload
# carries the protocol-neutral ``errors[]`` and the envelope ``adcp_error``.
data: dict[str, Any] = {"adcp_error": adcp_error, "errors": [dict(adcp_error)]}
data: dict[str, Any] = {
"adcp_error": adcp_error,
"errors": _payload_errors(exc, adcp_error),
}
if params is not None:
inject_context(params, data)

Expand Down
21 changes: 18 additions & 3 deletions src/adcp/server/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def _extract_structured_fields(
suggestion = decisioning_exc.suggestion
recovery = decisioning_exc.recovery
details = decisioning_exc.details or None
errors = None
errors = decisioning_exc.errors_to_wire() if decisioning_exc.errors else None
field = decisioning_exc.field
elif isinstance(exc, ADCPError):
code = _error_code_for_exception(exc)
Expand All @@ -192,6 +192,17 @@ def _extract_structured_fields(
return code, message, recovery, field, suggestion, details, errors


def _payload_errors(exc: Any, adcp_error: dict[str, Any]) -> list[dict[str, Any]]:
"""The payload ``errors[]`` for a fatal failure.

Every error a raised decisioning ``AdcpError`` carries (see its
``errors`` parameter), else a copy of the envelope ``adcp_error``.
"""
if isinstance(exc, _get_decisioning_adcp_error_types()) and cast(Any, exc).errors:
return cast("list[dict[str, Any]]", cast(Any, exc).errors_to_wire())
return [dict(adcp_error)]


def build_mcp_error_result(
exc: ADCPError | Error | Any,
*,
Expand All @@ -208,7 +219,8 @@ def build_mcp_error_result(
same envelope — the transport-errors.mdx §MCP Binding ``adcp_error``
plus the payload ``errors[]`` that L3 error handling says a fatal
failure SHOULD also carry (storyboards read both ``/adcp_error/code``
and ``errors[0].field``).
and ``errors[0].field``). A decisioning ``AdcpError`` raised with
several ``errors`` puts all of them in ``errors[]``.

The text fallback in ``content[]`` preserves human-readable display
for clients that do not consume ``structuredContent`` (LLM tool-use
Expand Down Expand Up @@ -263,7 +275,10 @@ def build_mcp_error_result(

# A fatal failure populates both layers (L3 error handling): the payload
# carries the protocol-neutral ``errors[]`` and the envelope ``adcp_error``.
structured: dict[str, Any] = {"adcp_error": adcp_error, "errors": [dict(adcp_error)]}
structured: dict[str, Any] = {
"adcp_error": adcp_error,
"errors": _payload_errors(exc, adcp_error),
}
if params is not None:
inject_context(params, structured)

Expand Down
Loading
Loading