diff --git a/MIGRATION_v9_to_v10.md b/MIGRATION_v9_to_v10.md index 70dafe287..cd705b1e5 100644 --- a/MIGRATION_v9_to_v10.md +++ b/MIGRATION_v9_to_v10.md @@ -37,6 +37,7 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk | Schema defaults appear in dumps for new 3.3 fields | `GetProductsRequest.include_collections` (`false`) and `proposal_refinement.hold_extension` (`false`) carry schema defaults, so a dumped model declares them. Tests that compare whole dictionaries need the new keys; a 3.3 → 3.2 downgrade drops them with a recorded loss. | | 3.3 → 3.2 downgrades apply semantic rules | A buyer below 3.3 of a seller answering in 3.3 no longer gets `VERSION_UNSUPPORTED` for these responses: `by_spot` rows without `aired_at` (`scheduled` / `preempted`) and delivery rows without `impressions` are omitted (losses `row_omitted_not_aired`, `row_omitted_impressions_not_measured`), never sent with `impressions: 0`; `media_buy.features.catalog_ingestion` and a `price_breakdown` carrying an `operator_fee` adjustment are dropped; `age_determination_bases` moves to `ext.adcp.age_determination_bases`, and a 3.2 response upgraded to 3.3 reads it back into an absent top-level field. Requests the 3.2 contract cannot express fail with `ADCPVersionConversionError` (`UNSUPPORTED_FEATURE` on the client) before sending: a `reporting_webhook` / `artifact_webhook` without `authentication` (keep sending it while you buy from 3.2 sellers; the SDK never invents one) and a daypart with `start_time` / `end_time` (use `start_hour` / `end_hour`; the SDK never rounds). The downgrade's final check now validates formats as the SDK validators do: earlier betas let such a request through invalid when it carried `start_time: "asap"`. See [docs/cross-version-conversion.md](docs/cross-version-conversion.md#33--32-semantic-rules). | | Reporting and artifact webhooks without `authentication` must be signed | On a request served at AdCP 3.3, `reporting_webhook` / `artifact_webhook` may omit `authentication`; the seller then signs every delivery under the registration's `signing_profile` (`adcp/webhook-signing/v1` when omitted). `WebhookSender.send_media_buy_delivery`, `prepare_media_buy_delivery` + `send_prepared`, and `prepare_artifact_webhook` / `send_artifact_webhook` with the new `registration=` argument now raise `WebhookSigningProfileUnavailableError` before any network I/O when such a registration would be sent by a bearer or HMAC sender (SDK 9 sent the sender's bearer token or HMAC anyway) or names `web-bot-auth`, which the SDK does not yet sign. Deliver to these registrations with `WebhookSender.from_jwk` / `from_pem`. Check registrations on receipt with `adcp.webhooks.webhook_signing_profile_for(registration, adcp_version=...)`, which raises `WebhookRegistrationError` (`INVALID_REQUEST`) for a missing `authentication` below 3.3, for `authentication` combined with `signing_profile`, and for an unknown profile. `PreparedWebhook` gains `required_signing_profile` (default `None`). `WebhookReceiver` now drops `aggregated_totals` from a `media_buy_delivery` result before parsing instead of passing it through; read totals from `media_buy_deliveries[]`. See [docs/webhooks/reporting-operation-id-3.2.md](docs/webhooks/reporting-operation-id-3.2.md#adcp-33-registrations-without-authentication). | +| Errors can state `mutation_outcome` | AdCP 3.3 `error.mutation_outcome` says whether a failed state-changing request changed seller state. A structured rejection the framework raises before a mutating handler runs (schema or model validation, version gates, pre-validation hooks) and `IDEMPOTENCY_CONFLICT` now carry `"mutation_outcome": "not_applied"` in `adcp_error` and `errors[]`; a test that compares whole error envelopes sees the new key. Set it on your own raises with `AdcpError(..., mutation_outcome=...)` only with the evidence the spec requires; leave it unset otherwise. Buyers read `TaskResult.mutation_outcome`, `ADCPTaskError.mutation_outcome` or `AdcpErrorInfo.mutation_outcome` (unrecognized values read as `"unknown"`). Reject an unsupported or removed targeting dimension with `UnsupportedTargetingDimensionError` (`UNSUPPORTED_FEATURE` with the `unsupported-targeting-dimension` details) instead of minting `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION`. | | Delivery rows may omit `impressions` | Every `by_*` delivery-metrics row makes `impressions` optional. Treat absence as "not measured", never as zero. | | Pixel trackers for AdCP 3.3 playback events are dropped when downgraded to the v1 url-tracker shape | `downgrade_pixel_tracker` returns `PixelTrackerDowngrade(v1=None, ...)` with a `PIXEL_TRACKER_LOSSY_DOWNGRADE` advisory for `start`, `first_quartile`, `midpoint`, `third_quartile` and `complete`, and for their 3.2 interim form (`event: custom` with `custom_event_name` set to one of them). SDK 9 put the interim form in the `impression_tracker` slot, which counted every impression as a quartile view. Check `result.v1 is not None` before reading it. `downgrade_pixel_trackers` leaves dropped trackers out of `items`. See the AdCP [3.2 to 3.3 migration guide](https://docs.adcontextprotocol.org/docs/reference/migration/3-2-to-3-3#hosted-video-and-audio-playback-trackers). | | Pixel trackers for AdCP 3.3 playback events reach 3.2 peers in the interim `custom` form | The 3.3 → 3.2 conversion (a buyer's request to a 3.2 seller, a seller's response to a 3.2 buyer) rewrites a `pixel_tracker` whose `event` is `start`, `first_quartile`, `midpoint`, `third_quartile` or `complete` to `event: "custom"` with `custom_event_name` set to that value, and records a `playback_event_as_custom` loss on the `event` pointer. Earlier SDK 10 betas refused it with `ADCPVersionConversionError` (`UNSUPPORTED_FEATURE` on the client, `VERSION_UNSUPPORTED` on the server). A 3.2 seller that does not recognize the custom name no-ops it, or rejects it (`tracker_contract_mismatch`) under a `complete: true` tracker execution contract that does not list it. Upgrades leave the `custom` form as sent: a tracker execution selector matches it, not the 3.3 event. Nothing to change unless you match on conversion loss reasons. | diff --git a/docs/adcp-3.3-sdk-inventory.md b/docs/adcp-3.3-sdk-inventory.md index 3a712f6db..17fde1fb9 100644 --- a/docs/adcp-3.3-sdk-inventory.md +++ b/docs/adcp-3.3-sdk-inventory.md @@ -55,7 +55,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and | `hold_policy`, `extend_hold`, `holds`, `hold_rank`, `hold_placed_at`, `proposal_initiator` | `core/hold-policy.json`; `Product`, `CanonicalProduct`, `Proposal` (`refine_proposals`), `ProposalRefinement.Action.extend_hold`; capabilities `proposal_refinement.hold_extension`, `hold_semantics` | Experimental holds. `hold_extension` has a schema default `false`, so the generated capability model emits it. | generated | | `include_collections` / `collections[]` | `get_products`, `list_products`, `request_proposals`, `refine_proposals` requests and responses; `GetBrandIdentityResponse.collections`; `Collection` gains `images`, `sample_content`, `url`, `publisher_domain`; `CollectionDistribution.followers` | Experimental collection cards. `include_collections` defaults to `false` and is materialized on dump; the 3.3 → 3.2 downgrade drops it with a recorded loss. | generated | | `later_proposals` | `request_proposals` responses, `OpportunityContext`, `core/later-proposals-state.json`, enum `later-proposal-reason` | Experimental `open_opportunities`. | generated | -| `mutation_outcome` | `core/error.json` (`not_applied`, `unknown`, `applied`) | Every error can state whether the mutation applied. | generated; populate from the dispatcher on mutation errors: (3), #1642 | +| `mutation_outcome` | `core/error.json` (`not_applied`, `unknown`, `applied`) | Every error can state whether the mutation applied. | generated; `AdcpError(mutation_outcome=...)` projected on MCP, A2A and `errors[]`; the framework sets `not_applied` on pre-dispatch rejections of mutating tools and on `IDEMPOTENCY_CONFLICT`; buyer `TaskResult.mutation_outcome` / `AdcpErrorInfo.mutation_outcome`: done (3), #1642 | | `operator_fee` | enum `adjustment-kind` | New price adjustment kind; send only to 3.3 peers. | generated; the 3.2 downgrade omits a price breakdown carrying it: done (3), #1667 | | `change_cutoff` | `core/installment-deadlines.json` | Installment change cutoff. | generated | | `budget.periods[]`, `budget_period_id` | `governance/sync-plans-request.json` `Period`; `check_governance` request/response; capability `governance.supports_budget_periods`; `x-entity-types` `governance_budget_period` | Experimental budget periods; buyer sends `periods[]` only to 3.3 peers. | generated; governance conversion: (3) | @@ -116,7 +116,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and | `request_signing.profiles`, `operation_sources`; `webhook_signing.profiles` | capabilities `RequestSigning.profiles` (`adcp/request-signing/v1`, `web-bot-auth`), `operation_sources` (`mcp_tools_call`, `a2a_invocation_skill`) | `required_for` enforced over A2A with `operation_sources` as the advertisement. | A2A hardening enforcement: done (3), #1659 (33 vectors pass); advertising `operation_sources`: (3), #1637 | | `ext.adcp` reserved | `extensions/adcp.json` `AdcpOwnedExtensions` | Reserved namespace for the working group. | generated | | `media_buy.features.catalog_ingestion` | `core/catalog-ingestion-capability.json` (`IngestionMode`, `ItemStatusReporting`); requires `catalog_management: true` | An object: fails 3.2 capability schemas (booleans only); the 3.3 → 3.2 downgrade must coarsen or drop it. `media_buy.features` is no longer boolean-only. | generated; downgrade drops it (`field_unavailable`): done (3), #1667 | -| `error-details/unsupported-targeting-dimension.json` | under `UNSUPPORTED_FEATURE`; adapters minting `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` should migrate | | generated; decisioning error minting: (3), #1642 | +| `error-details/unsupported-targeting-dimension.json` | under `UNSUPPORTED_FEATURE`; adapters minting `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` should migrate | | generated; `UnsupportedTargetingDimensionError` and `unsupported_targeting_dimension_details`: done (3), #1642 | | `error-details/agent-permission-denied.json` `Reason` | new nested enum | | generated | | `brand.json` fetch cap 2 MiB | all fetch paths; `MAX_JWKS_BYTES`, `MAX_CAPABILITIES_BYTES` unchanged | 3.2 verifiers may reject files between 256 KiB and 2 MiB. | done (3), #1654: 2 MiB, depth 32, 10 s total deadline, bounded concurrent fetches; follow-ups on #1635 | | Property `domain` / `subdomain` lowercase ASCII hostnames | property schemas; invalid values → `INVALID_REQUEST` | | `PlatformHandler.create_property_list` / `update_property_list` reject invalid `domain` / `subdomain` identifiers with `INVALID_REQUEST` (`validate_property_list_identifiers`); `MCPPropertyListFetcher` rejects a snapshot carrying one instead of canonicalizing it; sender normalizer `adcp.property_lists.normalize_property_list_identifiers`: done (3), #1668 | @@ -154,8 +154,8 @@ Every new declaration is optional; 3.2 behaviour stands when undeclared. | ~~`REFERENCE_DEFINITION_CHANGED`~~ (withdrawn in 3.3.0-beta.2, section 12) | `error-details/reference-definition-changed.json` | `accept_proposal` when a pinned definition drifted; send only to 3.3 peers | generated; until then the 3.2 downgrade keeps it, since 3.2 `error.code` is open (#1667); minting and 3.2 downgrade mapping: not built, #1641 closed as withdrawn (moved to 4.0, adcontextprotocol/adcp#8537) | | `CREATIVE_MERGE_REJECTED` | `error-details/creative-merge-rejected.json` (`Rejection`, reason enum) | `sync_creatives` merges | `CreativeMergeRejectedError`, `creative_merge_rejected_details`: done (3), #1668 | | `RIGHTS_CHANNEL_VIOLATION` | `enums/error-code.json` | rights constraints `channels` / `excluded_channels` | `RightsChannelViolationError` (details `rights_id`, `channel`, `capability_id`; usable as an advisory `errors[]` entry via `to_wire()`), `rights_channel_violation_details`: done (3), #1668 | -| `UNSUPPORTED_FEATURE` + `unsupported-targeting-dimension` | `error-details/unsupported-targeting-dimension.json` (`SupportedDimension`) | replaces adapter-minted `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` | generated; (3), #1642 | -| `mutation_outcome` | `core/error.json` | every error (`not_applied`, `unknown`, `applied`) | generated; (3), #1642 | +| `UNSUPPORTED_FEATURE` + `unsupported-targeting-dimension` | `error-details/unsupported-targeting-dimension.json` (`SupportedDimension`) | replaces adapter-minted `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` | generated; `UnsupportedTargetingDimensionError`: done (3), #1642 | +| `mutation_outcome` | `core/error.json` | every error (`not_applied`, `unknown`, `applied`) | generated; done (3), #1642 | | ~~`definition_changed`~~ (withdrawn in 3.3.0-beta.2, section 12) | `enums/proposal-refinement-reason.json` | `refine_proposals` `unable` with `drifted_pins[]` | generated | | `REVISION_UNREADABLE` with `reporting_revision_id` | 3.2.4 | already in SDK 9 | done | | `request_body_malformed` | A2A hardening (strict unsigned-body parsing, Part shape rules, task-continuation binding) becomes MUST | | done (3), #1659 | diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index abde5e381..906079ad2 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -829,6 +829,49 @@ 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. +### Did the failed mutation apply? (`mutation_outcome`) + +AdCP 3.3 lets an error on a state-changing request say whether seller state +changed, so the buyer knows whether to re-plan: + +- `not_applied`: you hold pre-commit evidence that nothing was written, such + as a typed rejection from validation that runs before the first write. +- `unknown`: some of the change may have committed, and the idempotency claim + is fenced so a same-key resend cannot run the mutation again. +- `applied`: the change committed and the error reports a later failure. + +Leave it unset on reads, on warnings and whenever you make no claim. A +timeout, a crash, or anything raised during or after a write is not +pre-commit evidence. + +```python +raise AdcpError("BUDGET_TOO_LOW", message="budget below minimum", + recovery="correctable", field="packages[0].budget", + mutation_outcome="not_applied") +``` + +The framework sets `not_applied` itself on the rejections it raises before a +mutating handler runs (schema and model validation, version gates, +pre-validation hooks) and on `IDEMPOTENCY_CONFLICT`. It never sets the field +on an error your method raises, and never on the `INTERNAL_ERROR` that wraps an +unexpected exception. + +Reject an unsupported or removed targeting dimension with +`UnsupportedTargetingDimensionError`, which emits `UNSUPPORTED_FEATURE` with +the `unsupported-targeting-dimension` details: + +```python +from adcp.decisioning import UnsupportedTargetingDimensionError + +raise UnsupportedTargetingDimensionError( + unsupported_dimension="geo_metros", + field="packages[0].targeting_overlay.geo_metros", + deprecated=True, + replacement_dimension="geo_regions", + mutation_outcome="not_applied", +) +``` + ## Response builders Manual `model_dump()` on response Pydantic objects is error-prone — diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 8142eede1..276e066f2 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -297,6 +297,7 @@ def _resolve_version() -> str: "AccountRequestError", "ConfigurationError", "extract_adcp_error_info", + "normalize_mutation_outcome", "is_pre_dispatch_error", "is_pre_dispatch_error", "IdempotencyConflictError", @@ -1688,6 +1689,7 @@ def get_adcp_version() -> str: "AdcpErrorInfo", "BuyerReasonInfo", "extract_adcp_error_info", + "normalize_mutation_outcome", "AccountNotFoundError", "AccountSetupRequiredError", "AccountPaymentRequiredError", @@ -1974,6 +1976,7 @@ def get_adcp_version() -> str: RegistryValidationIssue, extract_adcp_error_info, is_pre_dispatch_error, + normalize_mutation_outcome, ) from adcp.feed_mirror import ( EventHandler, diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 618bf6c6c..894e37ae0 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -107,9 +107,11 @@ def create_media_buy( ServiceUnavailableError, TermsRejectedError, UnsupportedFeatureError, + UnsupportedTargetingDimensionError, ValidationError, creative_merge_rejected_details, rights_channel_violation_details, + unsupported_targeting_dimension_details, ) from adcp.decisioning.helpers import ref_account_id from adcp.decisioning.implementation_config import ProductConfigStore @@ -278,6 +280,7 @@ def create_media_buy( AdcpError, DiscoveryResult, MaybeAsync, + MutationOutcome, SalesResult, SyncAccountsResultRow, SyncGovernanceEntry, @@ -401,6 +404,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "AccountStoreUpsert", "AccountStoreUpsertRequest", "AdcpError", + "MutationOutcome", "ApiKey", "ApiKeyCredential", "AuthContext", @@ -531,6 +535,8 @@ def __init__(self, *args: object, **kwargs: object) -> None: "TranslationMap", "UNKNOWN_UPDATE_ACTION", "UnsupportedFeatureError", + "UnsupportedTargetingDimensionError", + "unsupported_targeting_dimension_details", "TermsRejectedError", "CreativeMergeRejectedError", "RightsChannelViolationError", diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 905a471f2..f7c7fb80e 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -737,7 +737,7 @@ def _task_error_to_adcp_error(exc: ADCPTaskError) -> AdcpError: both paths answer with the same code, message, recovery, field, suggestion and sanitized details. """ - from adcp.server.translate import _extract_structured_fields + from adcp.server.translate import _extract_structured_fields, _mutation_outcome code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields(exc) return AdcpError( @@ -747,6 +747,7 @@ def _task_error_to_adcp_error(exc: ADCPTaskError) -> AdcpError: field=field, suggestion=suggestion, details=details, + mutation_outcome=typing.cast(Any, _mutation_outcome(exc)), ) diff --git a/src/adcp/decisioning/errors.py b/src/adcp/decisioning/errors.py index 3fe1b89ae..8c8496076 100644 --- a/src/adcp/decisioning/errors.py +++ b/src/adcp/decisioning/errors.py @@ -23,10 +23,14 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal -from adcp.decisioning.types import AdcpError +from adcp.decisioning.types import AdcpError, MutationOutcome if TYPE_CHECKING: - from adcp.types.error_details import CreativeMergeRejectedDetails, Rejection + from adcp.types.error_details import ( + CreativeMergeRejectedDetails, + Rejection, + UnsupportedTargetingDimensionDetails, + ) __all__ = [ "AccountNotFoundError", @@ -41,9 +45,11 @@ "ServiceUnavailableError", "TermsRejectedError", "UnsupportedFeatureError", + "UnsupportedTargetingDimensionError", "ValidationError", "creative_merge_rejected_details", "rights_channel_violation_details", + "unsupported_targeting_dimension_details", ] @@ -332,6 +338,80 @@ def __init__( ) +class UnsupportedTargetingDimensionError(UnsupportedFeatureError): + """``UNSUPPORTED_FEATURE`` for an unsupported or removed targeting dimension. + + AdCP 3.3 (adcp#8052): the rejection SHOULD carry + ``error-details/unsupported-targeting-dimension.json`` and ``error.field`` + SHOULD point at the targeting path, for example + ``packages[0].targeting_overlay.geo_postal_areas``. It replaces the + adapter-minted ``UNSUPPORTED_TARGETING_DIMENSION`` and + ``DEPRECATED_TARGETING_DIMENSION`` codes. The details are validated + against the generated ``UnsupportedTargetingDimensionDetails`` model. + + :param unsupported_dimension: The rejected targeting field name + (``geo_countries``), not an individual value. + :param field: The targeting path in the request; authoritative over + ``package_index``. + :param supported_dimensions: Dimensions the caller could already read + from ``get_adcp_capabilities`` or the product's ``overlay_support``. + :param deprecated: ``True`` when the dimension was accepted before and + has been removed. + :param replacement_dimension: The dimension that replaces it. + :param product_id: The product, when support is product-scoped. + :param package_index: Zero-based index of the offending package. + :param mutation_outcome: Usually ``'not_applied'``: the rejection comes + from validation that runs before any write. + """ + + def __init__( + self, + *, + unsupported_dimension: str, + field: str, + supported_dimensions: Sequence[str] | None = None, + deprecated: bool | None = None, + replacement_dimension: str | None = None, + product_id: str | None = None, + package_index: int | None = None, + message: str | None = None, + suggestion: str | None = None, + mutation_outcome: MutationOutcome | None = None, + ) -> None: + from adcp.types.error_details import UnsupportedTargetingDimensionDetails + + details = UnsupportedTargetingDimensionDetails.model_validate( + { + "unsupported_dimension": unsupported_dimension, + "supported_dimensions": ( + list(supported_dimensions) if supported_dimensions is not None else None + ), + "deprecated": deprecated, + "replacement_dimension": replacement_dimension, + "product_id": product_id, + "package_index": package_index, + } + ).model_dump(mode="json", exclude_none=True) + if message is None: + message = ( + f"Targeting dimension {unsupported_dimension!r} is no longer supported." + if deprecated + else f"Targeting dimension {unsupported_dimension!r} is not supported." + ) + if suggestion is None and replacement_dimension is not None: + suggestion = f"Use {replacement_dimension!r} instead." + AdcpError.__init__( + self, + "UNSUPPORTED_FEATURE", + message=message, + recovery="correctable", + field=field, + suggestion=suggestion, + details=details, + mutation_outcome=mutation_outcome, + ) + + class TermsRejectedError(AdcpError): """Spec ``TERMS_REJECTED`` (``recovery='correctable'``). @@ -535,3 +615,26 @@ def _text(key: str) -> str | None: channel=_text("channel"), capability_id=_text("capability_id"), ) + + +def unsupported_targeting_dimension_details( + error: Any, +) -> UnsupportedTargetingDimensionDetails | None: + """Narrow an ``UNSUPPORTED_FEATURE`` error's details to the targeting model. + + Accepts an :class:`AdcpError`, a generated ``Error`` or a wire mapping. + Returns ``None`` for any other code, or when the details are absent or are + not ``error-details/unsupported-targeting-dimension.json`` (an + ``UNSUPPORTED_FEATURE`` for something other than a targeting dimension). + """ + from pydantic import ValidationError as PydanticValidationError + + from adcp.types.error_details import UnsupportedTargetingDimensionDetails + + code, details = _code_and_details(error) + if code != "UNSUPPORTED_FEATURE" or details is None: + return None + try: + return UnsupportedTargetingDimensionDetails.model_validate(details) + except PydanticValidationError: + return None diff --git a/src/adcp/decisioning/types.py b/src/adcp/decisioning/types.py index dfc864a46..b6b6184e7 100644 --- a/src/adcp/decisioning/types.py +++ b/src/adcp/decisioning/types.py @@ -60,6 +60,10 @@ ) +#: AdCP 3.3 ``error.mutation_outcome`` values (``core/error.json``). +MutationOutcome = Literal["not_applied", "unknown", "applied"] + + class AdcpError(Exception): """Wire-shaped structured error raised by platform methods. @@ -107,6 +111,16 @@ class AdcpError(Exception): :param retry_after: Seconds to wait before retrying. Only meaningful with ``recovery='transient'``. :param details: Free-form extras for codes that need them. + :param mutation_outcome: Whether the failed state-changing request + changed seller state (AdCP 3.3 ``error.mutation_outcome``): + ``'not_applied'`` only with pre-commit evidence that no write was + attempted (a typed rejection from validation that runs before the + first write), ``'unknown'`` when some of the change may have + committed and the idempotency claim is fenced, ``'applied'`` when + the change committed and the error reports a later failure. Leave + it ``None`` on reads and whenever the seller makes no claim. The + framework sets ``'not_applied'`` on the rejections it raises + before a mutating handler runs. :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 @@ -143,6 +157,7 @@ def __init__( retry_after: int | None = None, details: dict[str, Any] | None = None, errors: Sequence[AdcpError | Error | Mapping[str, Any]] | None = None, + mutation_outcome: MutationOutcome | None = None, ) -> None: super().__init__(message or code) self.code = code @@ -151,6 +166,7 @@ def __init__( self.suggestion = suggestion self.retry_after = retry_after self.details = details or {} + self.mutation_outcome = mutation_outcome if errors is not None: self.errors: list[dict[str, Any]] = [ _error_entry_to_wire(entry, recovery) for entry in errors @@ -182,6 +198,7 @@ def from_errors(cls, errors: Sequence[AdcpError | Error | Mapping[str, Any]], /) retry_after=first.get("retry_after"), details=dict(details) if isinstance(details, Mapping) else None, errors=errors, + mutation_outcome=first.get("mutation_outcome"), ) def __str__(self) -> str: @@ -206,6 +223,8 @@ def to_wire(self) -> dict[str, Any]: out["suggestion"] = self.suggestion if self.retry_after is not None: out["retry_after"] = self.retry_after + if self.mutation_outcome is not None: + out["mutation_outcome"] = self.mutation_outcome if self.details: details = sanitize_error_details(self.code, self.details) if details: diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index 451b38c08..0548ab9d5 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -46,6 +46,32 @@ def _normalize_recovery(raw: Any) -> RecoveryLiteral | None: return None +# Wire values of AdCP 3.3 `error.mutation_outcome`. +MutationOutcomeLiteral = Literal["not_applied", "unknown", "applied"] +_MUTATION_OUTCOME_VALUES: frozenset[str] = frozenset(("not_applied", "unknown", "applied")) + + +def normalize_mutation_outcome(error: Any) -> MutationOutcomeLiteral | None: + """Read an error's AdCP 3.3 `mutation_outcome` the way the spec tells a buyer to. + + `None` when the error makes no claim (the field is absent): apply the + absent-field idempotency guidance. An unrecognized, empty or non-string + value reads as `"unknown"`, never as a reason to discard the error. + Accepts a pydantic `Error`, a raw dict or a duck-typed object. + """ + if isinstance(error, dict): + if "mutation_outcome" not in error: + return None + raw = error["mutation_outcome"] + else: + raw = getattr(error, "mutation_outcome", None) + if raw is None: + return None + if isinstance(raw, str) and raw in _MUTATION_OUTCOME_VALUES: + return raw # type: ignore[return-value] + return "unknown" + + @dataclass(frozen=True, slots=True) class BuyerReasonInfo: """Typed extraction of an AdCP `error.buyer_reason` object. @@ -79,6 +105,9 @@ class AdcpErrorInfo: suggestion: str | None = None retry_after: float | None = None details: dict[str, Any] | None = None + #: AdCP 3.3: whether the failed request changed seller state, normalized + #: by :func:`normalize_mutation_outcome` (`None` = no claim). + mutation_outcome: MutationOutcomeLiteral | None = None def extract_adcp_error_info(err: Any) -> AdcpErrorInfo: @@ -126,6 +155,7 @@ def extract_adcp_error_info(err: Any) -> AdcpErrorInfo: suggestion=_access(err, "suggestion"), retry_after=retry_after, details=details, + mutation_outcome=normalize_mutation_outcome(err), ) @@ -743,6 +773,15 @@ def first_buyer_reason(self) -> BuyerReasonInfo | None: reasons = self.buyer_reasons return reasons[0] if reasons else None + @property + def mutation_outcome(self) -> MutationOutcomeLiteral | None: + """AdCP 3.3: whether the failed request changed seller state. + + Read off the first error, the one that terminates the request (and + fills ``adcp_error``). ``None`` means the seller made no claim. + """ + return normalize_mutation_outcome(self.errors[0]) if self.errors else None + @property def wire_recoveries(self) -> tuple[RecoveryLiteral, ...]: """The `recovery` classification on each error, in wire order. diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 1fc55e08e..112ab5629 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -1245,7 +1245,11 @@ 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, _payload_errors + from adcp.server.translate import ( + _extract_structured_fields, + _mutation_outcome, + _payload_errors, + ) code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields( exc @@ -1264,6 +1268,9 @@ async def _send_adcp_error( retry_after = getattr(exc, "retry_after", None) if retry_after is not None: adcp_error["retry_after"] = retry_after + mutation_outcome = _mutation_outcome(exc) + if mutation_outcome is not None: + adcp_error["mutation_outcome"] = mutation_outcome if details: adcp_error["details"] = dict(details) diff --git a/src/adcp/server/idempotency/reservation.py b/src/adcp/server/idempotency/reservation.py index 55d3a5476..c887066f4 100644 --- a/src/adcp/server/idempotency/reservation.py +++ b/src/adcp/server/idempotency/reservation.py @@ -252,6 +252,8 @@ async def business_transaction(conn: AsyncConnection[Any]) -> AsyncIterator[None { "code": "IDEMPOTENCY_CONFLICT", "message": "idempotency_key reused with a different payload", + # Detected on lookup: this request wrote nothing. + "mutation_outcome": "not_applied", } ], ) diff --git a/src/adcp/server/idempotency/store.py b/src/adcp/server/idempotency/store.py index f25628026..fb0185eb5 100644 --- a/src/adcp/server/idempotency/store.py +++ b/src/adcp/server/idempotency/store.py @@ -365,6 +365,8 @@ def _replay_cached(cached: CachedResponse) -> Any: "idempotency_key reused with a different payload " "(canonical hash mismatch)" ), + # Detected on lookup: this request wrote nothing. + "mutation_outcome": "not_applied", } ], ) diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index 27040fdb7..a68ddc495 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -24,6 +24,7 @@ from collections.abc import Callable, Iterable, Sequence from typing import Any, Literal +from adcp._idempotency import is_mutating from adcp.server._hooks import ( PreValidationHookChain, PreValidationHookError, @@ -3420,6 +3421,32 @@ def _creative_capabilities_for_handler(handler: Any) -> Any: return getattr(handler, "_framework_adcp_capabilities", None) +def mark_not_applied(exc: BaseException) -> None: + """Set ``mutation_outcome='not_applied'`` on a pre-commit rejection. + + For a structured error the framework raised before a mutating handler + ran (schema and model validation, version gates, pre-validation hooks): + AdCP 3.3 lets a seller claim ``not_applied`` only with pre-commit + evidence, which a rejection that never reached the handler is. A value + the raise already states is kept; any other exception (a crash the + catch-all turns into ``INTERNAL_ERROR``) is left without a claim. + """ + from adcp.decisioning.types import AdcpError + from adcp.exceptions import ADCPTaskError + + if isinstance(exc, AdcpError): + if exc.mutation_outcome is None: + exc.mutation_outcome = "not_applied" + elif isinstance(exc, ADCPTaskError): + for entry in exc.errors: + if isinstance(entry, dict): + entry.setdefault("mutation_outcome", "not_applied") + elif hasattr(entry, "mutation_outcome") and entry.mutation_outcome is None: + entry.mutation_outcome = "not_applied" + # ``error_info`` is cached from the entries as they were at __init__. + exc.__dict__.pop("error_info", None) + + def create_tool_caller( handler: ADCPHandler[Any], method_name: str, @@ -3695,7 +3722,23 @@ def convert_version_payload( ) return converted + mutating = is_mutating(method_name) + async def call_tool(params: dict[str, Any], context: ToolContext | None = None) -> Any: + if not mutating: + return await _call_tool(params, context, None) + dispatched = [False] + try: + return await _call_tool(params, context, dispatched) + except Exception as exc: + if not dispatched[0]: + # Rejected before the handler ran: no write was attempted. + mark_not_applied(exc) + raise + + async def _call_tool( + params: dict[str, Any], context: ToolContext | None, dispatched: list[bool] | None + ) -> Any: ctx = context if context is not None else ToolContext() ctx.application_adcp_version = application_version @@ -4156,6 +4199,8 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) ) ], ) from exc + if dispatched is not None: + dispatched[0] = True result = await method(call_params, ctx) if method_name == "list_account_changes": result = _project_account_change_feed(result, ctx, application_version) diff --git a/src/adcp/server/translate.py b/src/adcp/server/translate.py index cdc486bbc..0f92c0587 100644 --- a/src/adcp/server/translate.py +++ b/src/adcp/server/translate.py @@ -110,12 +110,15 @@ def _build_error_data( suggestion: str | None = None, details: dict[str, Any] | None = None, errors: list[Any] | None = None, + mutation_outcome: str | None = None, ) -> dict[str, Any]: """Build the structured data payload for protocol error responses.""" data: dict[str, Any] = { "error_code": code, "recovery": recovery or _recovery_for_code(code), } + if mutation_outcome: + data["mutation_outcome"] = mutation_outcome if suggestion: data["suggestion"] = suggestion if details: @@ -192,6 +195,25 @@ def _extract_structured_fields( return code, message, recovery, field, suggestion, details, errors +def _mutation_outcome(exc: Any) -> str | None: + """The AdCP 3.3 ``mutation_outcome`` a raised error states, if any. + + Read off a decisioning ``AdcpError`` or an ``Error`` model, or off the + first entry of an ``ADCPTaskError`` (the entry that fills ``adcp_error``). + """ + value = getattr(exc, "mutation_outcome", None) + if value is None and isinstance(exc, ADCPError): + entries = getattr(exc, "errors", None) + if entries: + first = entries[0] + value = ( + first.get("mutation_outcome") + if isinstance(first, dict) + else getattr(first, "mutation_outcome", None) + ) + return value if isinstance(value, str) and value else None + + def _payload_errors(exc: Any, adcp_error: dict[str, Any]) -> list[dict[str, Any]]: """The payload ``errors[]`` for a fatal failure. @@ -262,6 +284,9 @@ def build_mcp_error_result( retry_after = getattr(exc, "retry_after", None) if retry_after is not None: adcp_error["retry_after"] = retry_after + mutation_outcome = _mutation_outcome(exc) + if mutation_outcome is not None: + adcp_error["mutation_outcome"] = mutation_outcome if details: adcp_error["details"] = dict(details) @@ -351,6 +376,7 @@ def translate_error( suggestion=suggestion, details=details, errors=errors, + mutation_outcome=_mutation_outcome(exc), ) @@ -490,6 +516,7 @@ def _to_a2a( suggestion: str | None = None, details: dict[str, Any] | None = None, errors: list[Any] | None = None, + mutation_outcome: str | None = None, ) -> A2AError: """Format error as an A2AError subclass for A2A servers. @@ -504,6 +531,7 @@ def _to_a2a( suggestion=suggestion, details=details, errors=errors, + mutation_outcome=mutation_outcome, ) # Use InvalidParamsError for correctable errors (client can fix), diff --git a/src/adcp/types/core.py b/src/adcp/types/core.py index 1f3ea7019..1ec00dcc0 100644 --- a/src/adcp/types/core.py +++ b/src/adcp/types/core.py @@ -287,6 +287,27 @@ def expected_confirmation_at(self) -> datetime | None: return expected_confirmation_at(self) + @property + def mutation_outcome(self) -> Literal["not_applied", "unknown", "applied"] | None: + """AdCP 3.3: whether the failed state-changing request changed seller state. + + Read off ``adcp_error``, else the first payload ``errors[]`` entry. + ``"not_applied"``: nothing was written, re-plan freely. + ``"unknown"``: resend with the same ``idempotency_key`` only. + ``"applied"``: the change committed. ``None``: the seller made no + claim. An unrecognized value reads as ``"unknown"``. + """ + from adcp.exceptions import normalize_mutation_outcome + + if self.adcp_error is not None: + return normalize_mutation_outcome(self.adcp_error) + errors = getattr(self.data, "errors", None) + if errors is None and isinstance(self.data, dict): + errors = self.data.get("errors") + if isinstance(errors, list) and errors: + return normalize_mutation_outcome(errors[0]) + return None + class ActivityType(str, Enum): """Types of activity events.""" diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 4996307ee..6c21c58f2 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -737,6 +737,7 @@ "normalize_assets_required": "adcp.utils.format_assets", "normalize_domain": "adcp.identifiers", "normalize_identifier": "adcp.identifiers", + "normalize_mutation_outcome": "adcp.exceptions", "parse_managerdomains": "adcp.adagents", "project_available_actions": "adcp.media_buy_actions", "properties_for_publisher_domain": "adcp.adagents", diff --git a/tests/test_mutation_outcome.py b/tests/test_mutation_outcome.py new file mode 100644 index 000000000..0bfcb6923 --- /dev/null +++ b/tests/test_mutation_outcome.py @@ -0,0 +1,301 @@ +"""AdCP 3.3 ``error.mutation_outcome`` and unsupported-targeting-dimension details (#1642).""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import ValidationError as PydanticValidationError + +from adcp.decisioning import ( + AdcpError, + UnsupportedFeatureError, + UnsupportedTargetingDimensionError, + unsupported_targeting_dimension_details, +) +from adcp.exceptions import ( + ADCPTaskError, + IdempotencyConflictError, + extract_adcp_error_info, + normalize_mutation_outcome, +) +from adcp.server import ADCPHandler, IdempotencyStore, MemoryBackend +from adcp.server.base import ToolContext +from adcp.server.mcp_tools import create_tool_caller, mark_not_applied +from adcp.server.translate import build_mcp_error_result, translate_error +from adcp.types import Error +from adcp.types.core import TaskResult, TaskStatus +from adcp.types.error_details import UnsupportedTargetingDimensionDetails + + +class TestAdcpErrorField: + def test_to_wire_emits_mutation_outcome(self) -> None: + error = AdcpError("INVALID_REQUEST", message="bad", mutation_outcome="not_applied") + assert error.to_wire()["mutation_outcome"] == "not_applied" + + def test_absent_by_default(self) -> None: + assert "mutation_outcome" not in AdcpError("INVALID_REQUEST").to_wire() + + def test_from_errors_takes_the_first_entry_claim(self) -> None: + error = AdcpError.from_errors( + [ + AdcpError("INVALID_REQUEST", message="a", mutation_outcome="not_applied"), + AdcpError("INVALID_REQUEST", message="b"), + ] + ) + assert error.mutation_outcome == "not_applied" + + @pytest.mark.parametrize("outcome", ["not_applied", "unknown", "applied"]) + def test_mcp_projection(self, outcome: Any) -> None: + structured = build_mcp_error_result( + AdcpError("SERVICE_UNAVAILABLE", recovery="transient", mutation_outcome=outcome) + ).structured_content + assert structured["adcp_error"]["mutation_outcome"] == outcome + assert structured["errors"][0]["mutation_outcome"] == outcome + + def test_task_error_projects_its_first_entry_claim(self) -> None: + exc = ADCPTaskError( + "create_media_buy", + [Error(code="INVALID_REQUEST", message="bad", mutation_outcome="unknown")], + ) + structured = build_mcp_error_result(exc).structured_content + assert structured["adcp_error"]["mutation_outcome"] == "unknown" + + def test_translate_error_a2a_data(self) -> None: + a2a_error = translate_error( + AdcpError("INVALID_REQUEST", mutation_outcome="not_applied"), "a2a" + ) + data: Any = a2a_error.data # type: ignore[attr-defined] + assert data["mutation_outcome"] == "not_applied" + + def test_dispatch_keeps_a_task_error_claim(self) -> None: + from adcp.decisioning.dispatch import _task_error_to_adcp_error + + exc = IdempotencyConflictError( + operation="create_media_buy", + errors=[ + {"code": "IDEMPOTENCY_CONFLICT", "message": "x", "mutation_outcome": "not_applied"} + ], + ) + assert _task_error_to_adcp_error(exc).mutation_outcome == "not_applied" + + +class TestMarkNotApplied: + def test_sets_absent_claims_only(self) -> None: + error = AdcpError("INVALID_REQUEST") + mark_not_applied(error) + assert error.mutation_outcome == "not_applied" + + claimed = AdcpError("INVALID_REQUEST", mutation_outcome="unknown") + mark_not_applied(claimed) + assert claimed.mutation_outcome == "unknown" + + def test_task_error_entries(self) -> None: + exc = ADCPTaskError( + "create_media_buy", + [Error(code="INVALID_REQUEST", message="a"), {"code": "X", "message": "b"}], + ) + mark_not_applied(exc) + assert [normalize_mutation_outcome(e) for e in exc.errors] == ["not_applied"] * 2 + + def test_other_exceptions_are_ignored(self) -> None: + mark_not_applied(ValueError("crash")) + + +class _Seller(ADCPHandler): + def __init__(self, exc: BaseException | None = None) -> None: + self.exc = exc + + async def create_media_buy(self, params: Any, context: Any = None) -> Any: + if self.exc is not None: + raise self.exc + return {"media_buy_id": "mb-1", "packages": []} + + async def get_products(self, params: Any, context: Any = None) -> Any: + if self.exc is not None: + raise self.exc + return {"products": []} + + +def _reject(method_name: str, params: dict[str, Any]) -> dict[str, Any]: + raise AdcpError("INVALID_REQUEST", message="rejected by hook", recovery="correctable") + + +class TestFrameworkNotApplied: + @pytest.mark.asyncio + async def test_pre_dispatch_rejection_on_a_mutating_tool(self) -> None: + caller = create_tool_caller(_Seller(), "create_media_buy", pre_validation_hook=_reject) + with pytest.raises(AdcpError) as caught: + await caller({}) + wire = build_mcp_error_result(caught.value).structured_content + assert wire["adcp_error"]["mutation_outcome"] == "not_applied" + + @pytest.mark.asyncio + async def test_schema_rejection_on_a_mutating_tool(self) -> None: + from adcp.validation.client_hooks import ValidationHookConfig + + caller = create_tool_caller( + _Seller(), + "create_media_buy", + validation=ValidationHookConfig(requests="strict", responses="off"), + ) + with pytest.raises(ADCPTaskError) as caught: + await caller({"packages": "not-a-list"}) + assert caught.value.mutation_outcome == "not_applied" + + @pytest.mark.asyncio + async def test_a_read_carries_no_claim(self) -> None: + caller = create_tool_caller(_Seller(), "get_products", pre_validation_hook=_reject) + with pytest.raises(AdcpError) as caught: + await caller({}) + assert caught.value.mutation_outcome is None + + @pytest.mark.asyncio + async def test_a_handler_raise_carries_no_claim(self) -> None: + caller = create_tool_caller( + _Seller(AdcpError("INVALID_REQUEST", message="handler")), "create_media_buy" + ) + with pytest.raises(AdcpError) as caught: + await caller({}) + assert caught.value.mutation_outcome is None + + @pytest.mark.asyncio + async def test_a_handler_claim_is_kept(self) -> None: + caller = create_tool_caller( + _Seller(AdcpError("PRODUCT_UNAVAILABLE", mutation_outcome="unknown")), + "create_media_buy", + ) + with pytest.raises(AdcpError) as caught: + await caller({}) + assert caught.value.mutation_outcome == "unknown" + + +@pytest.mark.asyncio +async def test_idempotency_conflict_is_not_applied() -> None: + store = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400) + + class _Wrapped(ADCPHandler): + @store.wrap + async def create_media_buy(self, params: Any, context: Any = None) -> Any: + return {"media_buy_id": "mb-1", "packages": []} + + seller = _Wrapped() + ctx = ToolContext(caller_identity="buyer-1") + key = "k-0123456789abcdef" + await seller.create_media_buy({"idempotency_key": key, "total_budget": 1}, ctx) + with pytest.raises(IdempotencyConflictError) as caught: + await seller.create_media_buy({"idempotency_key": key, "total_budget": 2}, ctx) + assert caught.value.mutation_outcome == "not_applied" + wire = build_mcp_error_result(caught.value).structured_content + assert wire["adcp_error"]["mutation_outcome"] == "not_applied" + + +class TestBuyerSide: + @pytest.mark.parametrize( + ("error", "expected"), + [ + ({"code": "X", "message": "m"}, None), + ({"code": "X", "message": "m", "mutation_outcome": "not_applied"}, "not_applied"), + ({"code": "X", "message": "m", "mutation_outcome": "applied"}, "applied"), + ({"code": "X", "message": "m", "mutation_outcome": "rolled_back"}, "unknown"), + ({"code": "X", "message": "m", "mutation_outcome": ""}, "unknown"), + ({"code": "X", "message": "m", "mutation_outcome": 3}, "unknown"), + ({"code": "X", "message": "m", "mutation_outcome": None}, "unknown"), + ], + ) + def test_normalize(self, error: dict[str, Any], expected: str | None) -> None: + assert normalize_mutation_outcome(error) == expected + assert extract_adcp_error_info(error).mutation_outcome == expected + + def test_model_input(self) -> None: + assert normalize_mutation_outcome(Error(code="X", message="m")) is None + assert ( + normalize_mutation_outcome(Error(code="X", message="m", mutation_outcome="applied")) + == "applied" + ) + + def test_task_error_reads_the_first_entry(self) -> None: + exc = ADCPTaskError( + "update_media_buy", + [ + {"code": "A", "message": "a", "mutation_outcome": "unknown"}, + {"code": "B", "message": "b"}, + ], + ) + assert exc.mutation_outcome == "unknown" + assert ADCPTaskError("update_media_buy", []).mutation_outcome is None + + def test_task_result_reads_adcp_error_then_errors(self) -> None: + failed = TaskResult[Any]( + status=TaskStatus.FAILED, + success=False, + adcp_error={"code": "X", "message": "m", "mutation_outcome": "not_applied"}, + ) + assert failed.mutation_outcome == "not_applied" + from_payload = TaskResult[Any]( + status=TaskStatus.FAILED, + success=False, + data={"errors": [{"code": "X", "message": "m", "mutation_outcome": "bogus"}]}, + ) + assert from_payload.mutation_outcome == "unknown" + assert TaskResult[Any](status=TaskStatus.COMPLETED, data={}).mutation_outcome is None + + +class TestUnsupportedTargetingDimension: + def test_wire_shape(self) -> None: + error = UnsupportedTargetingDimensionError( + unsupported_dimension="geo_postal_areas", + field="packages[1].targeting_overlay.geo_postal_areas", + supported_dimensions=["geo_countries", "geo_regions"], + product_id="prod-1", + package_index=1, + ) + assert isinstance(error, UnsupportedFeatureError) + wire = error.to_wire() + assert wire["code"] == "UNSUPPORTED_FEATURE" + assert wire["recovery"] == "correctable" + assert wire["field"] == "packages[1].targeting_overlay.geo_postal_areas" + assert wire["details"] == { + "unsupported_dimension": "geo_postal_areas", + "supported_dimensions": ["geo_countries", "geo_regions"], + "product_id": "prod-1", + "package_index": 1, + } + UnsupportedTargetingDimensionDetails.model_validate(wire["details"]) + + def test_deprecated_dimension_suggests_the_replacement(self) -> None: + error = UnsupportedTargetingDimensionError( + unsupported_dimension="geo_metros", + field="packages[0].targeting_overlay.geo_metros", + deprecated=True, + replacement_dimension="geo_regions", + mutation_outcome="not_applied", + ) + wire = error.to_wire() + assert wire["details"]["deprecated"] is True + assert "no longer supported" in wire["message"] + assert wire["suggestion"] == "Use 'geo_regions' instead." + assert wire["mutation_outcome"] == "not_applied" + + def test_invalid_details_are_refused(self) -> None: + with pytest.raises(PydanticValidationError): + UnsupportedTargetingDimensionError( + unsupported_dimension="geo_countries", field="x", package_index=-1 + ) + with pytest.raises(PydanticValidationError): + UnsupportedTargetingDimensionError(unsupported_dimension="", field="x") + + def test_reader(self) -> None: + error = UnsupportedTargetingDimensionError( + unsupported_dimension="geo_countries", field="packages[0].targeting_overlay" + ) + details = unsupported_targeting_dimension_details(error) + assert details is not None and details.unsupported_dimension == "geo_countries" + assert unsupported_targeting_dimension_details(error.to_wire()) is not None + assert unsupported_targeting_dimension_details(UnsupportedFeatureError()) is None + assert ( + unsupported_targeting_dimension_details( + {"code": "INVALID_REQUEST", "message": "x", "details": details.model_dump()} + ) + is None + )