Skip to content

Commit 9301acf

Browse files
bokelleyclaude
andauthored
feat(decisioning): state-machine helpers + typed exception classes + ref_account_id (#465)
* feat(decisioning): state-machine helpers + typed exception classes + ref_account_id Three small mechanical helpers ported from JS @adcp/sdk@6.7, bundled because each is independent but shares an audience: adopters who want to drop hand-rolled boilerplate. * MEDIA_BUY_TRANSITIONS / assert_media_buy_transition and CREATIVE_ASSET_TRANSITIONS / assert_creative_transition project the spec state graph as a single source of truth and raise INVALID_STATE with recovery=correctable on illegal transitions. * Typed AdcpError subclasses (PermissionDeniedError, AuthRequiredError, ServiceUnavailableError, RateLimitedError, MediaBuyNotFoundError, AccountNotFoundError, BillingNotPermittedForAgentError, ValidationError, UnsupportedFeatureError) bind each spec code to its canonical recovery classification per schemas/cache/enums/error-code.json#enumMetadata, with sensible default messages adopters can override. * ref_account_id(ref) extracts account_id from an AccountReference, returning None for the natural-key arm or None input. Handles both Pydantic models and raw dicts. No existing call sites refactored — refactor PRs follow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(state-machines): allow spec-permitted creative transitions (#465 fix-pack) Reviewer caught CREATIVE_ASSET_TRANSITIONS was too restrictive in 3 places that contradict creative-status.json spec metadata: - approved -> pending_review (re-review by seller) - rejected -> processing (buyer fixes and resubmits via sync_creatives) - archived -> approved (unarchive) Was raising INVALID_STATE on legitimate, spec-allowed transitions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2affd00 commit 9301acf

5 files changed

Lines changed: 892 additions & 0 deletions

File tree

src/adcp/decisioning/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ def create_media_buy(
6363
AuthInfo,
6464
RequestContext,
6565
)
66+
from adcp.decisioning.errors import (
67+
AccountNotFoundError,
68+
AuthRequiredError,
69+
BillingNotPermittedForAgentError,
70+
MediaBuyNotFoundError,
71+
PermissionDeniedError,
72+
RateLimitedError,
73+
ServiceUnavailableError,
74+
UnsupportedFeatureError,
75+
ValidationError,
76+
)
77+
from adcp.decisioning.helpers import ref_account_id
6678
from adcp.decisioning.mock_ad_server import (
6779
InMemoryMockAdServer,
6880
MockAdServer,
@@ -123,6 +135,12 @@ def create_media_buy(
123135
WorkflowObjectType,
124136
WorkflowStep,
125137
)
138+
from adcp.decisioning.state_machines import (
139+
CREATIVE_ASSET_TRANSITIONS,
140+
MEDIA_BUY_TRANSITIONS,
141+
assert_creative_transition,
142+
assert_media_buy_transition,
143+
)
126144
from adcp.decisioning.task_registry import (
127145
InMemoryTaskRegistry,
128146
TaskHandoffContext,
@@ -174,20 +192,24 @@ def __init__(self, *args: object, **kwargs: object) -> None:
174192

175193
__all__ = [
176194
"Account",
195+
"AccountNotFoundError",
177196
"AccountStore",
178197
"AdcpError",
179198
"ApiKeyCredential",
180199
"AudiencePlatform",
181200
"AuditingBuyerAgentRegistry",
182201
"AuthInfo",
202+
"AuthRequiredError",
183203
"BillingMode",
204+
"BillingNotPermittedForAgentError",
184205
"BrandRightsPlatform",
185206
"BuyerAgent",
186207
"BuyerAgentDefaultTerms",
187208
"BuyerAgentRegistry",
188209
"BuyerAgentStatus",
189210
"CachingBuyerAgentRegistry",
190211
"CampaignGovernancePlatform",
212+
"CREATIVE_ASSET_TRANSITIONS",
191213
"CollectionList",
192214
"CollectionListsPlatform",
193215
"ContentStandardsPlatform",
@@ -205,35 +227,45 @@ def __init__(self, *args: object, **kwargs: object) -> None:
205227
"HttpSigCredential",
206228
"InMemoryMockAdServer",
207229
"InMemoryTaskRegistry",
230+
"MEDIA_BUY_TRANSITIONS",
208231
"MaybeAsync",
232+
"MediaBuyNotFoundError",
209233
"MockAdServer",
210234
"OAuthCredential",
235+
"PermissionDeniedError",
211236
"PgTaskRegistry",
212237
"PostgresTaskRegistry",
213238
"Proposal",
214239
"PropertyList",
215240
"PropertyListReference",
216241
"PropertyListsPlatform",
217242
"RateLimitedBuyerAgentRegistry",
243+
"RateLimitedError",
218244
"RequestContext",
219245
"ResourceResolver",
220246
"SalesPlatform",
221247
"SalesResult",
248+
"ServiceUnavailableError",
222249
"SignalsPlatform",
223250
"SingletonAccounts",
224251
"StateReader",
225252
"TaskHandoff",
226253
"TaskHandoffContext",
227254
"TaskRegistry",
228255
"TaskState",
256+
"UnsupportedFeatureError",
257+
"ValidationError",
229258
"WorkflowHandoff",
230259
"WorkflowObjectType",
231260
"WorkflowStep",
261+
"assert_creative_transition",
262+
"assert_media_buy_transition",
232263
"bearer_only_registry",
233264
"create_adcp_server_from_platform",
234265
"mixed_registry",
235266
"project_account_for_response",
236267
"project_business_entity_for_response",
268+
"ref_account_id",
237269
"serve",
238270
"signing_only_registry",
239271
"validate_billing_for_agent",

src/adcp/decisioning/errors.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"""Typed exception subclasses for the AdCP error code vocabulary.
2+
3+
:class:`adcp.decisioning.AdcpError` is the wire-shaped structured error
4+
adopters raise from inside Protocol method bodies — the framework
5+
catches at the dispatch seam and projects to the ``adcp_error``
6+
envelope. The base class accepts any spec code as a string, but
7+
adopters who want catch-by-type ergonomics (``except PermissionDeniedError:``)
8+
or who want the correct ``recovery`` semantic auto-applied repeatedly
9+
re-derive the boilerplate. These subclasses bind each spec code to its
10+
canonical recovery classification (per
11+
``schemas/cache/enums/error-code.json#enumMetadata``) and offer a
12+
sensible default message adopters can override.
13+
14+
Recovery values are normative — they MUST match the ``enumMetadata``
15+
block in the error-code schema. Adopters MUST NOT override
16+
``recovery`` on these subclasses; if a different recovery is needed
17+
for a vendor variant, raise the base :class:`AdcpError` directly.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Any, Literal
23+
24+
from adcp.decisioning.types import AdcpError
25+
26+
__all__ = [
27+
"AccountNotFoundError",
28+
"AuthRequiredError",
29+
"BillingNotPermittedForAgentError",
30+
"MediaBuyNotFoundError",
31+
"PermissionDeniedError",
32+
"RateLimitedError",
33+
"ServiceUnavailableError",
34+
"UnsupportedFeatureError",
35+
"ValidationError",
36+
]
37+
38+
39+
class PermissionDeniedError(AdcpError):
40+
"""Spec ``PERMISSION_DENIED`` (``recovery='correctable'``).
41+
42+
Raised when the authenticated caller is not authorized for the
43+
requested action under the seller's policies, or a required signed
44+
credential is missing/invalid.
45+
46+
:param scope: When the gate is a per-agent provisioning constraint,
47+
set to ``'agent'`` (with ``status``); when billing-relationship,
48+
set to ``'billing'``. Sellers MUST emit ``scope='agent'`` only
49+
when buyer-agent identity has been established (signed-request
50+
derivation or credential-to-agent mapping); otherwise omit.
51+
:param status: When ``scope='agent'``, the per-agent state
52+
(``'sandbox_only'``, etc.) — see
53+
``error-details/agent-permission-denied.json``.
54+
:param message: Optional human-readable override of the default.
55+
:param details: Additional fields merged into ``error.details``.
56+
"""
57+
58+
def __init__(
59+
self,
60+
*,
61+
scope: Literal["agent", "billing"] | None = None,
62+
status: str | None = None,
63+
message: str | None = None,
64+
field: str | None = None,
65+
suggestion: str | None = None,
66+
**details: Any,
67+
) -> None:
68+
merged_details: dict[str, Any] = dict(details)
69+
if scope is not None:
70+
merged_details["scope"] = scope
71+
if status is not None:
72+
merged_details["status"] = status
73+
74+
super().__init__(
75+
"PERMISSION_DENIED",
76+
message=message or "Caller is not authorized for the requested action.",
77+
recovery="correctable",
78+
field=field,
79+
suggestion=suggestion,
80+
details=merged_details or None,
81+
)
82+
83+
84+
class AuthRequiredError(AdcpError):
85+
"""Spec ``AUTH_REQUIRED`` (``recovery='correctable'``).
86+
87+
Raised when no credentials were presented. (The schema classifies
88+
this as ``correctable`` — the buyer fixes by attaching credentials
89+
and retrying.)
90+
"""
91+
92+
def __init__(
93+
self,
94+
*,
95+
message: str | None = None,
96+
field: str | None = None,
97+
suggestion: str | None = None,
98+
**details: Any,
99+
) -> None:
100+
super().__init__(
101+
"AUTH_REQUIRED",
102+
message=message or "Authentication is required to access this resource.",
103+
recovery="correctable",
104+
field=field,
105+
suggestion=suggestion,
106+
details=dict(details) or None,
107+
)
108+
109+
110+
class ServiceUnavailableError(AdcpError):
111+
"""Spec ``SERVICE_UNAVAILABLE`` (``recovery='transient'``).
112+
113+
Raised when the seller service is temporarily unavailable. The
114+
buyer retries with exponential backoff; ``retry_after`` MAY be set
115+
to hint a minimum delay.
116+
"""
117+
118+
def __init__(
119+
self,
120+
*,
121+
message: str | None = None,
122+
retry_after: int | None = None,
123+
**details: Any,
124+
) -> None:
125+
super().__init__(
126+
"SERVICE_UNAVAILABLE",
127+
message=message or "Service is temporarily unavailable.",
128+
recovery="transient",
129+
retry_after=retry_after,
130+
details=dict(details) or None,
131+
)
132+
133+
134+
class RateLimitedError(AdcpError):
135+
"""Spec ``RATE_LIMITED`` (``recovery='transient'``).
136+
137+
Raised when the request rate exceeds the seller's threshold. The
138+
buyer retries after the ``retry_after`` interval.
139+
"""
140+
141+
def __init__(
142+
self,
143+
*,
144+
message: str | None = None,
145+
retry_after: int | None = None,
146+
**details: Any,
147+
) -> None:
148+
super().__init__(
149+
"RATE_LIMITED",
150+
message=message or "Request rate exceeded.",
151+
recovery="transient",
152+
retry_after=retry_after,
153+
details=dict(details) or None,
154+
)
155+
156+
157+
class MediaBuyNotFoundError(AdcpError):
158+
"""Spec ``MEDIA_BUY_NOT_FOUND`` (``recovery='correctable'``).
159+
160+
Raised when the referenced media buy does not exist or is not
161+
accessible to the requesting agent. Sellers MUST return this code
162+
uniformly for any ``media_buy_id`` not owned by the calling
163+
account — never distinguish "exists in another tenant" from
164+
"does not exist".
165+
"""
166+
167+
def __init__(
168+
self,
169+
*,
170+
media_buy_id: str | None = None,
171+
message: str | None = None,
172+
field: str | None = None,
173+
**details: Any,
174+
) -> None:
175+
merged: dict[str, Any] = dict(details)
176+
if media_buy_id is not None:
177+
merged["media_buy_id"] = media_buy_id
178+
super().__init__(
179+
"MEDIA_BUY_NOT_FOUND",
180+
message=message or "Media buy not found.",
181+
recovery="correctable",
182+
field=field,
183+
details=merged or None,
184+
)
185+
186+
187+
class AccountNotFoundError(AdcpError):
188+
"""Spec ``ACCOUNT_NOT_FOUND`` (``recovery='terminal'``).
189+
190+
Raised when the account reference cannot be resolved. The buyer
191+
verifies the account via ``list_accounts`` or contacts the seller.
192+
"""
193+
194+
def __init__(
195+
self,
196+
*,
197+
message: str | None = None,
198+
field: str | None = None,
199+
**details: Any,
200+
) -> None:
201+
super().__init__(
202+
"ACCOUNT_NOT_FOUND",
203+
message=message or "Account not found.",
204+
recovery="terminal",
205+
field=field,
206+
details=dict(details) or None,
207+
)
208+
209+
210+
class BillingNotPermittedForAgentError(AdcpError):
211+
"""Spec ``BILLING_NOT_PERMITTED_FOR_AGENT`` (``recovery='correctable'``).
212+
213+
Raised when the seller's ``supported_billing`` capability accepts
214+
the requested billing model, but the calling buyer agent's
215+
commercial relationship with the seller does not. The recovery
216+
shape is deliberately minimal — ``error.details`` MUST conform to
217+
``error-details/billing-not-permitted-for-agent.json``
218+
(``rejected_billing`` plus an optional single ``suggested_billing``
219+
retry value, typically ``'operator'``).
220+
221+
:param rejected_billing: The billing values the agent attempted that
222+
are not permitted (echoed in ``error.details.rejected_billing``).
223+
:param suggested_billing: Optional single retry value the seller
224+
recommends (echoed in ``error.details.suggested_billing``).
225+
"""
226+
227+
def __init__(
228+
self,
229+
*,
230+
rejected_billing: list[str],
231+
suggested_billing: list[str] | None = None,
232+
message: str | None = None,
233+
) -> None:
234+
merged: dict[str, Any] = {"rejected_billing": list(rejected_billing)}
235+
if suggested_billing is not None:
236+
merged["suggested_billing"] = list(suggested_billing)
237+
super().__init__(
238+
"BILLING_NOT_PERMITTED_FOR_AGENT",
239+
message=(
240+
message or "Calling agent is not permitted to use the requested billing value."
241+
),
242+
recovery="correctable",
243+
details=merged,
244+
)
245+
246+
247+
class ValidationError(AdcpError):
248+
"""Spec ``VALIDATION_ERROR`` (``recovery='correctable'``).
249+
250+
Raised when a request contains invalid field values or violates
251+
business rules beyond schema validation. ``field`` SHOULD identify
252+
the offending path so buyers can highlight the input.
253+
"""
254+
255+
def __init__(
256+
self,
257+
*,
258+
message: str | None = None,
259+
field: str | None = None,
260+
suggestion: str | None = None,
261+
**details: Any,
262+
) -> None:
263+
super().__init__(
264+
"VALIDATION_ERROR",
265+
message=message or "Request failed validation.",
266+
recovery="correctable",
267+
field=field,
268+
suggestion=suggestion,
269+
details=dict(details) or None,
270+
)
271+
272+
273+
class UnsupportedFeatureError(AdcpError):
274+
"""Spec ``UNSUPPORTED_FEATURE`` (``recovery='correctable'``).
275+
276+
Raised when a requested feature or field is not supported by this
277+
seller. The buyer checks ``get_adcp_capabilities`` and removes the
278+
unsupported field.
279+
"""
280+
281+
def __init__(
282+
self,
283+
*,
284+
message: str | None = None,
285+
field: str | None = None,
286+
**details: Any,
287+
) -> None:
288+
super().__init__(
289+
"UNSUPPORTED_FEATURE",
290+
message=message or "Requested feature is not supported by this seller.",
291+
recovery="correctable",
292+
field=field,
293+
details=dict(details) or None,
294+
)

0 commit comments

Comments
 (0)