2121
2222from __future__ import annotations
2323
24- from collections .abc import Awaitable , Callable
24+ from collections .abc import Awaitable , Callable , Mapping , Sequence
2525from dataclasses import dataclass , field
2626from typing import TYPE_CHECKING , Any , Generic , Literal
2727
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
0 commit comments