Skip to content
Closed
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
30 changes: 30 additions & 0 deletions src/adcp/decisioning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ def create_media_buy(
AuthInfo,
RequestContext,
)
from adcp.decisioning.exceptions import (
AccountNotFoundError,
AuthRequiredError,
BillingNotPermittedForAgentError,
MediaBuyNotFoundError,
PermissionDeniedError,
RateLimitedError,
RequestValidationError,
ServiceUnavailableError,
)
from adcp.decisioning.mock_ad_server import (
InMemoryMockAdServer,
MockAdServer,
Expand Down Expand Up @@ -129,6 +139,13 @@ def create_media_buy(
TaskRegistry,
TaskState,
)
from adcp.decisioning.transitions import (
CREATIVE_TRANSITIONS,
MEDIA_BUY_TRANSITIONS,
ref_account_id,
validate_creative_transition,
validate_media_buy_transition,
)
from adcp.decisioning.types import (
Account,
AdcpError,
Expand Down Expand Up @@ -174,9 +191,12 @@ def __init__(self, *args: object, **kwargs: object) -> None:

__all__ = [
"Account",
"AccountNotFoundError",
"AccountStore",
"AdcpError",
"AuthRequiredError",
"ApiKeyCredential",
"BillingNotPermittedForAgentError",
"AudiencePlatform",
"AuditingBuyerAgentRegistry",
"AuthInfo",
Expand All @@ -186,6 +206,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"BuyerAgentDefaultTerms",
"BuyerAgentRegistry",
"BuyerAgentStatus",
"CREATIVE_TRANSITIONS",
"CachingBuyerAgentRegistry",
"CampaignGovernancePlatform",
"CollectionList",
Expand All @@ -202,23 +223,29 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"FromAuthAccounts",
"GOVERNANCE_SPECIALISMS",
"GovernanceContextJWS",
"MEDIA_BUY_TRANSITIONS",
"MediaBuyNotFoundError",
"HttpSigCredential",
"InMemoryMockAdServer",
"InMemoryTaskRegistry",
"MaybeAsync",
"MockAdServer",
"OAuthCredential",
"PermissionDeniedError",
"PgTaskRegistry",
"PostgresTaskRegistry",
"Proposal",
"PropertyList",
"PropertyListReference",
"PropertyListsPlatform",
"RateLimitedBuyerAgentRegistry",
"RateLimitedError",
"RequestContext",
"RequestValidationError",
"ResourceResolver",
"SalesPlatform",
"SalesResult",
"ServiceUnavailableError",
"SignalsPlatform",
"SingletonAccounts",
"StateReader",
Expand All @@ -234,7 +261,10 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"mixed_registry",
"project_account_for_response",
"project_business_entity_for_response",
"ref_account_id",
"serve",
"signing_only_registry",
"validate_billing_for_agent",
"validate_creative_transition",
"validate_media_buy_transition",
]
170 changes: 170 additions & 0 deletions src/adcp/decisioning/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Typed AdcpError subclasses for common rejection patterns.

Adopters raise these in Platform method bodies instead of constructing
``AdcpError(code=..., recovery=...)`` inline. The framework's dispatcher
catches :class:`~adcp.decisioning.types.AdcpError` at the dispatch seam and
serialises to the wire ``adcp_error`` envelope — these subclasses are caught
by the same handler.

All subclasses hard-code the correct ``code`` and ``recovery`` values from
the AdCP spec (``schemas/cache/3.0.0/enums/error-code.json``). Keyword
``details`` captures free-form extras; any unknown kwargs are forwarded as the
``details`` dict to :class:`~adcp.decisioning.types.AdcpError`.
"""

from __future__ import annotations

from typing import Any

from adcp.decisioning.types import AdcpError


class PermissionDeniedError(AdcpError):
"""Raised when the authenticated principal lacks permission for ``action``.

Maps to wire code ``PERMISSION_DENIED`` with ``recovery='correctable'``
(spec ``enumMetadata`` classification — the request can be retried after
the underlying permission is resolved, e.g. minting a valid governance
token or contacting the seller).
"""

def __init__(self, action: str = "", **details: Any) -> None:
msg = f"Permission denied: {action}" if action else "Permission denied"
super().__init__(
"PERMISSION_DENIED",
message=msg,
recovery="correctable",
details=details if details else None,
)


class AuthRequiredError(AdcpError):
"""Raised when a request arrives without valid authentication.

Maps to ``AUTH_REQUIRED`` with ``recovery='correctable'`` (per AdCP 3.0.4
prose — missing-credentials case; the buyer should re-present credentials
rather than abandon). See the note in ``adcp.server.helpers`` about the
planned 3.1 split into ``AUTH_MISSING`` / ``AUTH_INVALID``.
"""

def __init__(self, **details: Any) -> None:
super().__init__(
"AUTH_REQUIRED",
message="Authentication required",
recovery="correctable",
details=details if details else None,
)


class ServiceUnavailableError(AdcpError):
"""Raised when the seller service is temporarily unavailable.

Maps to ``SERVICE_UNAVAILABLE`` with ``recovery='transient'``.
"""

def __init__(self, message: str = "Service temporarily unavailable", **details: Any) -> None:
super().__init__(
"SERVICE_UNAVAILABLE",
message=message,
recovery="transient",
details=details if details else None,
)


class RateLimitedError(AdcpError):
"""Raised when the buyer has exceeded the request rate limit.

Maps to ``RATE_LIMITED`` with ``recovery='transient'``. Pass
``retry_after`` to tell the buyer how long to wait.
"""

def __init__(self, *, retry_after: int | None = None, **details: Any) -> None:
super().__init__(
"RATE_LIMITED",
message="Too many requests",
recovery="transient",
retry_after=retry_after,
details=details if details else None,
)


class MediaBuyNotFoundError(AdcpError):
"""Raised when the referenced media buy does not exist.

Maps to ``MEDIA_BUY_NOT_FOUND`` with ``recovery='correctable'``.
"""

def __init__(self, **details: Any) -> None:
super().__init__(
"MEDIA_BUY_NOT_FOUND",
message="Media buy not found",
recovery="correctable",
details=details if details else None,
)


class AccountNotFoundError(AdcpError):
"""Raised when the referenced account does not exist.

Maps to ``ACCOUNT_NOT_FOUND`` with ``recovery='terminal'``.
"""

def __init__(self, **details: Any) -> None:
super().__init__(
"ACCOUNT_NOT_FOUND",
message="Account not found",
recovery="terminal",
details=details if details else None,
)


class BillingNotPermittedForAgentError(AdcpError):
"""Raised when a buyer agent attempts a billing operation it is not
authorised to perform.

Maps to ``BILLING_NOT_PERMITTED_FOR_AGENT`` with ``recovery='correctable'``
(spec ``enumMetadata`` classification — retry with a permitted billing value
from ``error.details.suggested_billing``, or surface to a human when absent).
"""

def __init__(self, **details: Any) -> None:
super().__init__(
"BILLING_NOT_PERMITTED_FOR_AGENT",
message="Billing operations are not permitted for this agent",
recovery="correctable",
details=details if details else None,
)


class RequestValidationError(AdcpError):
"""Raised when the request fails validation (malformed fields, constraint
violations, etc.).

Maps to ``VALIDATION_ERROR`` with ``recovery='correctable'``.

Named ``RequestValidationError`` (not ``ValidationError``) to avoid
shadowing ``pydantic.ValidationError`` and ``adcp.validation.legacy.ValidationError``
in adopter import namespaces.
"""

def __init__(
self, message: str = "Request validation failed", **details: Any
) -> None:
super().__init__(
"VALIDATION_ERROR",
message=message,
recovery="correctable",
details=details if details else None,
)


__all__ = [
"AccountNotFoundError",
"AuthRequiredError",
"BillingNotPermittedForAgentError",
"MediaBuyNotFoundError",
"PermissionDeniedError",
"RateLimitedError",
"RequestValidationError",
"ServiceUnavailableError",
]
Loading
Loading