Skip to content

Commit 272fced

Browse files
bokelleyclaude
andauthored
feat(decisioning): let a raised AdcpError reject a request with every error (#1780) (#1810)
A seller that validates a whole request could only raise one AdcpError, so the payload errors[] carried one entry even when the caller listed every problem in details["errors"]. AdcpError takes errors=[...] (AdcpError, generated Error or wire mapping entries) and AdcpError.from_errors([...]) builds the raise from the list, the first entry filling adcp_error. build_mcp_error_result, the A2A failed-task builder and translate_error write the whole list to errors[]; adcp_error is unchanged. A details["errors"] list whose entries carry a string code and message is lifted into errors[] as well. An entry without recovery takes the raised error's; entry details are sanitized like to_wire(). A single-error raise still mirrors adcp_error into errors[] (#1558). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7447d00 commit 272fced

5 files changed

Lines changed: 457 additions & 8 deletions

File tree

‎docs/handler-authoring.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,33 @@ except ADCPTaskError as exc:
801801
Custom error codes (outside `STANDARD_ERROR_CODES`) default to
802802
`recovery="terminal"`. Override with `adcp_error("MY_CODE", recovery="correctable")`.
803803

804+
### Rejecting a request with every problem
805+
806+
A `DecisioningPlatform` method that validates a whole request raises one
807+
`AdcpError` carrying every problem, so the buyer fixes the request in one
808+
pass. `AdcpError.from_errors` puts the first entry in the envelope
809+
`adcp_error` and all of them in the payload `errors[]`, on MCP and A2A:
810+
811+
```python
812+
from adcp.decisioning import AdcpError
813+
814+
problems = []
815+
if package_budget < floor:
816+
problems.append(AdcpError("BUDGET_TOO_LOW", message="budget below minimum",
817+
recovery="correctable", field="packages[0].budget"))
818+
if end_time <= start_time:
819+
problems.append(AdcpError("INVALID_REQUEST", message="end_time before start_time",
820+
recovery="correctable", field="end_time"))
821+
if problems:
822+
raise AdcpError.from_errors(problems)
823+
```
824+
825+
Entries may also be generated `Error` models or wire mappings; one without
826+
`recovery` takes the raised error's. To keep a summary envelope instead, pass
827+
the list as `AdcpError("INVALID_REQUEST", message="2 problems", errors=problems)`.
828+
A list in `details["errors"]` whose entries carry a string `code` and
829+
`message` is lifted into `errors[]` too.
830+
804831
## Response builders
805832

806833
Manual `model_dump()` on response Pydantic objects is error-prone —

‎src/adcp/decisioning/types.py‎

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from __future__ import annotations
2323

24-
from collections.abc import Awaitable, Callable
24+
from collections.abc import Awaitable, Callable, Mapping, Sequence
2525
from dataclasses import dataclass, field
2626
from typing import TYPE_CHECKING, Any, Generic, Literal
2727

@@ -50,6 +50,7 @@
5050
AccountScope,
5151
BusinessEntity,
5252
CreditLimit,
53+
Error,
5354
GovernanceAgent,
5455
PaymentTerms,
5556
ReportingBucket,
@@ -105,8 +106,28 @@ class AdcpError(Exception):
105106
error.
106107
:param retry_after: Seconds to wait before retrying. Only
107108
meaningful with ``recovery='transient'``.
108-
:param details: Free-form extras for codes that need them
109-
(e.g. ``{'errors': [...]}`` for multi-error preflight).
109+
:param details: Free-form extras for codes that need them.
110+
:param errors: Every problem found, for a request rejected with
111+
several at once (a whole-request preflight). Each entry is an
112+
:class:`AdcpError`, a generated ``Error`` model or a wire mapping
113+
with string ``code`` and ``message``; an entry without
114+
``recovery`` takes this error's. The framework writes the list to
115+
the payload ``errors[]`` and this error to the envelope
116+
``adcp_error``. :meth:`from_errors` builds the error from the
117+
list, so ``adcp_error`` is the first entry. Without ``errors``,
118+
``errors[]`` carries this error alone, unless ``details['errors']``
119+
holds entries of that shape (the slot this class documented for
120+
multi-error preflight before ``errors`` existed), which are
121+
lifted into ``errors[]``.
122+
123+
Example — reject a whole request with every problem::
124+
125+
raise AdcpError.from_errors([
126+
AdcpError("BUDGET_TOO_LOW", message="budget below minimum",
127+
recovery="correctable", field="packages[0].budget"),
128+
AdcpError("INVALID_REQUEST", message="end_time before start_time",
129+
recovery="correctable", field="end_time"),
130+
])
110131
"""
111132

112133
def __init__(
@@ -121,6 +142,7 @@ def __init__(
121142
suggestion: str | None = None,
122143
retry_after: int | None = None,
123144
details: dict[str, Any] | None = None,
145+
errors: Sequence[AdcpError | Error | Mapping[str, Any]] | None = None,
124146
) -> None:
125147
super().__init__(message or code)
126148
self.code = code
@@ -129,6 +151,38 @@ def __init__(
129151
self.suggestion = suggestion
130152
self.retry_after = retry_after
131153
self.details = details or {}
154+
if errors is not None:
155+
self.errors: list[dict[str, Any]] = [
156+
_error_entry_to_wire(entry, recovery) for entry in errors
157+
]
158+
else:
159+
self.errors = _lift_detail_errors(self.details, recovery)
160+
161+
@classmethod
162+
def from_errors(cls, errors: Sequence[AdcpError | Error | Mapping[str, Any]], /) -> AdcpError:
163+
"""Build one raisable error that rejects a request for every entry.
164+
165+
The first entry fills the envelope ``adcp_error``; all of them,
166+
in order, fill the payload ``errors[]``. Entries are as for the
167+
``errors`` parameter; one without ``recovery`` takes the first
168+
entry's (``terminal`` when the first has none).
169+
170+
:raises ValueError: if ``errors`` is empty.
171+
"""
172+
if not errors:
173+
raise ValueError("AdcpError.from_errors needs at least one error")
174+
first = _error_entry_to_wire(errors[0], "terminal")
175+
details = first.get("details")
176+
return AdcpError(
177+
first["code"],
178+
message=first["message"],
179+
recovery=first["recovery"],
180+
field=first.get("field"),
181+
suggestion=first.get("suggestion"),
182+
retry_after=first.get("retry_after"),
183+
details=dict(details) if isinstance(details, Mapping) else None,
184+
errors=errors,
185+
)
132186

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

215+
def errors_to_wire(self) -> list[dict[str, Any]]:
216+
"""Project to the payload ``errors[]``: every entry, else this error."""
217+
if self.errors:
218+
return [dict(entry) for entry in self.errors]
219+
return [self.to_wire()]
220+
221+
222+
def _error_entry_to_wire(entry: Any, recovery: str) -> dict[str, Any]:
223+
"""Normalize one ``errors`` entry to its wire dict."""
224+
if isinstance(entry, AdcpError):
225+
return entry.to_wire()
226+
if hasattr(entry, "model_dump"):
227+
entry = entry.model_dump(mode="json", exclude_none=True)
228+
if not isinstance(entry, Mapping):
229+
raise TypeError(
230+
"AdcpError errors entries must be AdcpError, Error or a mapping, "
231+
f"got {type(entry).__name__}"
232+
)
233+
code, message = entry.get("code"), entry.get("message")
234+
if not isinstance(code, str) or not code or not isinstance(message, str):
235+
raise ValueError("AdcpError errors entries need a non-empty string code and a message")
236+
out = {key: value for key, value in entry.items() if value is not None}
237+
out.setdefault("recovery", recovery)
238+
if out.get("details"):
239+
details = sanitize_error_details(code, out["details"])
240+
if details:
241+
out["details"] = details
242+
else:
243+
del out["details"]
244+
return out
245+
246+
247+
def _lift_detail_errors(details: Mapping[str, Any], recovery: str) -> list[dict[str, Any]]:
248+
"""Read the documented ``details['errors']`` multi-error slot, if well formed."""
249+
entries = details.get("errors")
250+
if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes)) or not entries:
251+
return []
252+
if not all(
253+
isinstance(entry, Mapping)
254+
and isinstance(entry.get("code"), str)
255+
and entry["code"]
256+
and isinstance(entry.get("message"), str)
257+
for entry in entries
258+
):
259+
return []
260+
return [_error_entry_to_wire(entry, recovery) for entry in entries]
261+
161262

162263
#: Per-platform metadata generic. Defaults to ``dict[str, Any]`` for
163264
#: adopters who don't define a typed metadata shape; multi-tenant adopters

‎src/adcp/server/a2a_server.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ async def _send_adcp_error(
839839
# Lazy import — ``translate.py`` pulls in heavier server deps
840840
# (mcp.types) which the A2A module doesn't otherwise need.
841841
from adcp.server.helpers import inject_context
842-
from adcp.server.translate import _extract_structured_fields
842+
from adcp.server.translate import _extract_structured_fields, _payload_errors
843843

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

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

‎src/adcp/server/translate.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def _extract_structured_fields(
167167
suggestion = decisioning_exc.suggestion
168168
recovery = decisioning_exc.recovery
169169
details = decisioning_exc.details or None
170-
errors = None
170+
errors = decisioning_exc.errors_to_wire() if decisioning_exc.errors else None
171171
field = decisioning_exc.field
172172
elif isinstance(exc, ADCPError):
173173
code = _error_code_for_exception(exc)
@@ -192,6 +192,17 @@ def _extract_structured_fields(
192192
return code, message, recovery, field, suggestion, details, errors
193193

194194

195+
def _payload_errors(exc: Any, adcp_error: dict[str, Any]) -> list[dict[str, Any]]:
196+
"""The payload ``errors[]`` for a fatal failure.
197+
198+
Every error a raised decisioning ``AdcpError`` carries (see its
199+
``errors`` parameter), else a copy of the envelope ``adcp_error``.
200+
"""
201+
if isinstance(exc, _get_decisioning_adcp_error_types()) and cast(Any, exc).errors:
202+
return cast("list[dict[str, Any]]", cast(Any, exc).errors_to_wire())
203+
return [dict(adcp_error)]
204+
205+
195206
def build_mcp_error_result(
196207
exc: ADCPError | Error | Any,
197208
*,
@@ -208,7 +219,8 @@ def build_mcp_error_result(
208219
same envelope — the transport-errors.mdx §MCP Binding ``adcp_error``
209220
plus the payload ``errors[]`` that L3 error handling says a fatal
210221
failure SHOULD also carry (storyboards read both ``/adcp_error/code``
211-
and ``errors[0].field``).
222+
and ``errors[0].field``). A decisioning ``AdcpError`` raised with
223+
several ``errors`` puts all of them in ``errors[]``.
212224
213225
The text fallback in ``content[]`` preserves human-readable display
214226
for clients that do not consume ``structuredContent`` (LLM tool-use
@@ -263,7 +275,10 @@ def build_mcp_error_result(
263275

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

0 commit comments

Comments
 (0)