From dc0c985397ffa14487be08b0f9573d03830ebd26 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 30 Oct 2026 18:00:19 +0000 Subject: [PATCH] feat(client): mark local refusals as undispatched and classify every error's recovery Python parity for adcontextprotocol/adcp-client#3507, plus the recovery classification from the reporting-subscriptions DX plan (section 2.12). - ADCPError gains dispatched (False when the SDK refused the request locally and no bytes of the task's request left the process; None otherwise) and recovery ("transient" | "correctable" | "terminal" | None, the AdCP error.recovery vocabulary). - is_pre_dispatch_error(error) checks the attribute, not the class, so it accepts a TaskResult and an error from another copy of the SDK. - RequestValidationError, raised by strict request validation, is both a SchemaValidationError (existing handlers still match) and an ADCPError. It carries task, fields (deduplicated JSON Pointers, never values), field, dispatched=False and recovery="correctable"; the message is unchanged. - TaskResult.dispatched is False when the MCP or A2A adapter refuses a request in strict mode, and when version negotiation or request conversion refuses a task before sending it. - ADCPFeatureUnsupportedError takes dispatched=False at the sites that run before any send: the capability check, the reporting-version request gate, version negotiation and request conversion. A refusal that follows a response stays unmarked. IdempotencyUnsupportedError is always local. - recovery: connection errors are transient; authentication, feature, idempotency and request validation errors are correctable; ADCPTaskError takes its most demanding entry (wire recovery, else the code table, else transient). ADCPTimeoutError keeps recovery as its TaskRecoveryMetadata, the safe-retry identity. Endpoint discovery, OAuth and capability fetches contact the agent and are never marked. Refs #1695 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/__init__.py | 6 ++ src/adcp/capabilities.py | 3 +- src/adcp/client.py | 4 + src/adcp/compat/reporting_version.py | 3 +- src/adcp/compat/versions.py | 8 +- src/adcp/exceptions.py | 85 ++++++++++++++++-- src/adcp/protocols/a2a.py | 1 + src/adcp/protocols/mcp.py | 4 +- src/adcp/types/core.py | 5 ++ src/adcp/validation/__init__.py | 2 + src/adcp/validation/schema_errors.py | 6 +- src/adcp/validation/schema_validator.py | 34 ++++++- tests/fixtures/public_api_snapshot.json | 1 + tests/test_pre_dispatch_errors.py | 113 ++++++++++++++++++++++++ tests/test_schema_validation_client.py | 2 + tests/test_version_client_boundaries.py | 1 + 16 files changed, 260 insertions(+), 18 deletions(-) create mode 100644 tests/test_pre_dispatch_errors.py diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 1a354434f..f17c8bb6b 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -272,6 +272,8 @@ def _resolve_version() -> str: "AccountPaymentRequiredError", "ConfigurationError", "extract_adcp_error_info", + "is_pre_dispatch_error", + "is_pre_dispatch_error", "IdempotencyConflictError", "IdempotencyExpiredError", "IdempotencyScopeError", @@ -853,6 +855,7 @@ def _resolve_version() -> str: "uses_deprecated_assets_field", ), "adcp.validation": ( + "RequestValidationError", "SchemaValidationError", "UnknownFieldPolicy", "ValidationError", @@ -1652,6 +1655,7 @@ def get_adcp_version() -> str: "RegistryErrorDetails", "RegistryValidationIssue", # Validation utilities + "RequestValidationError", "SchemaValidationError", "UnknownFieldPolicy", "ValidationError", @@ -1902,6 +1906,7 @@ def get_adcp_version() -> str: RegistryErrorDetails, RegistryValidationIssue, extract_adcp_error_info, + is_pre_dispatch_error, ) from adcp.feed_mirror import ( EventHandler, @@ -2576,6 +2581,7 @@ def get_adcp_version() -> str: uses_deprecated_assets_field, ) from adcp.validation import ( + RequestValidationError, SchemaValidationError, UnknownFieldPolicy, ValidationError, diff --git a/src/adcp/capabilities.py b/src/adcp/capabilities.py index d981893b6..5b9811056 100644 --- a/src/adcp/capabilities.py +++ b/src/adcp/capabilities.py @@ -304,6 +304,7 @@ def require( declared_features=declared, agent_id=agent_id, agent_uri=agent_uri, + dispatched=False, # a check against capabilities already held ) def get_declared_features(self) -> list[str]: @@ -377,7 +378,7 @@ def validate_capabilities( for method_name in handler_methods: if not hasattr(handler, method_name): warnings.append( - f"Feature '{feature}' is declared but handler has no " f"'{method_name}' method" + f"Feature '{feature}' is declared but handler has no '{method_name}' method" ) continue diff --git a/src/adcp/client.py b/src/adcp/client.py index 195ac8e59..cdcdabc2e 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -1082,6 +1082,9 @@ async def _prepare_versioned_task(self, tool: str, params: dict[str, Any]) -> Co try: return await self._convert_outgoing_request(tool, params) except ADCPFeatureUnsupportedError as exc: + # Version negotiation and request conversion run before any of + # the task's request is sent. + exc.dispatched = False message = str(exc) details: dict[str, Any] = {"wire_response_received": False} for name in ("pointer", "source_version", "target_version", "reason"): @@ -1093,6 +1096,7 @@ async def _prepare_versioned_task(self, tool: str, params: dict[str, Any]) -> Co error=message, adcp_error={"code": "UNSUPPORTED_FEATURE", "message": message, "details": details}, idempotency_key=params.get("idempotency_key"), + dispatched=False, metadata={ CONVERSION_METADATA_KEY: { "wire_version": details.get("target_version"), diff --git a/src/adcp/compat/reporting_version.py b/src/adcp/compat/reporting_version.py index 2e118f7d1..1fb503bfb 100644 --- a/src/adcp/compat/reporting_version.py +++ b/src/adcp/compat/reporting_version.py @@ -168,5 +168,6 @@ def assert_reporting_request_supported(tool_name: str, params: Any) -> None: [ f"{issue.detail} at {issue.field} requires AdCP 3.2-beta.6 or newer " f"(target is {target_version}); no request was sent" - ] + ], + dispatched=False, ) diff --git a/src/adcp/compat/versions.py b/src/adcp/compat/versions.py index 00ae94674..d810a5ed8 100644 --- a/src/adcp/compat/versions.py +++ b/src/adcp/compat/versions.py @@ -189,7 +189,9 @@ def negotiate_version(supported: list[str], *, maximum: str) -> str: and is_adcp_version_at_least(maximum, _release(version)) ] if not common: - raise ADCPFeatureUnsupportedError(["No common AdCP version with this seller"]) + raise ADCPFeatureUnsupportedError( + ["No common AdCP version with this seller"], dispatched=False + ) selected = common[0] for candidate in common[1:]: candidate_release, selected_release = _release(candidate), _release(selected) @@ -271,7 +273,9 @@ def _score(schema: dict[str, Any], value: Any, root: dict[str, Any]) -> int: else ( "string" if isinstance(value, str) - else "number" if isinstance(value, (int, float)) else "object" + else "number" + if isinstance(value, (int, float)) + else "object" ) ) ) diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index 8520cb610..d27a1f7ee 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -129,8 +129,45 @@ def extract_adcp_error_info(err: Any) -> AdcpErrorInfo: ) +def is_pre_dispatch_error(error: object) -> bool: + """Whether the SDK refused this request locally, before sending any of it. + + Such errors carry ``dispatched = False``: no bytes of the task's request + left this process, so the agent cannot have acted on it, and the failure + says nothing about the agent's health. The SDK sets the marker only on + refusals it decides by itself: request schema validation, the version and + feature gates, and refusals that follow already-known capabilities. + + Failures that involve contacting the agent are never marked, even when the + task itself was not sent: endpoint discovery, OAuth token fetches and the + ``get_adcp_capabilities`` fetch. A refused connection, a 5xx or a 429 there + is an agent failure. + + This checks the attribute, not the class, so it also accepts a + :class:`~adcp.types.core.TaskResult` carrying ``dispatched=False`` and an + error from another copy of the SDK. + """ + return getattr(error, "dispatched", None) is False + + class ADCPError(Exception): - """Base exception for all AdCP client errors.""" + """Base exception for all AdCP client errors. + + Attributes: + dispatched: ``False`` when the SDK refused the request locally and no + bytes of the task's request left this process (see + :func:`is_pre_dispatch_error`). ``None`` otherwise: the request + may have been sent. + recovery: How to recover, in the AdCP ``error.recovery`` vocabulary: + ``"transient"`` (retry later), ``"correctable"`` (fix the request + or configuration, then retry) or ``"terminal"`` (needs human + action). ``None`` when the SDK cannot classify the failure. + :class:`ADCPTimeoutError` keeps its own ``recovery``: the + :class:`~adcp.task_options.TaskRecoveryMetadata` for a safe retry. + """ + + dispatched: Literal[False] | None = None + recovery: RecoveryLiteral | None = None def __init__( self, @@ -164,6 +201,8 @@ def is_retryable(self) -> bool: class ADCPConnectionError(ADCPError): """Connection to agent failed.""" + recovery: RecoveryLiteral | None = "transient" + def __init__(self, message: str, agent_id: str | None = None, agent_uri: str | None = None): """Initialize connection error.""" suggestion = ( @@ -194,6 +233,8 @@ class ADCPAuthenticationError(ADCPError): for credential recovery. """ + recovery: RecoveryLiteral | None = "correctable" + def __init__( self, message: str, @@ -233,7 +274,10 @@ def __init__( self.timeout = timeout self.task_name = task_name self.operation_id = operation_id - self.recovery = recovery + # The safe-retry identity, not the base class's classification: a + # timeout is always retryable (see ``is_retryable``), and + # ``recovery.outcome_unknown`` says the mutation may have applied. + self.recovery = recovery # type: ignore[assignment] suggestion = ( f"The request took longer than {timeout}s." if timeout else "The request timed out." ) @@ -479,7 +523,15 @@ def __init__( class ADCPFeatureUnsupportedError(ADCPError): - """Seller does not support one or more required features.""" + """Seller does not support one or more required features. + + ``dispatched`` is ``False`` where the SDK refused the task from + already-known capabilities or its own version and feature gates, before + sending any of it. It is ``None`` where the refusal follows a response, + for example a response that cannot be converted to the caller's version. + """ + + recovery: RecoveryLiteral | None = "correctable" def __init__( self, @@ -487,6 +539,8 @@ def __init__( declared_features: list[str] | None = None, agent_id: str | None = None, agent_uri: str | None = None, + *, + dispatched: Literal[False] | None = None, ): """Initialize feature unsupported error. @@ -495,7 +549,11 @@ def __init__( declared_features: Features the seller does declare. agent_id: Optional agent ID for context. agent_uri: Optional agent URI for context. + dispatched: Pass ``False`` only where no bytes of the task's + request left the process. See :func:`is_pre_dispatch_error`. """ + if dispatched is False: + self.dispatched = False self.unsupported_features = unsupported_features self.declared_features = declared_features or [] @@ -639,6 +697,11 @@ def __init__( self.error_codes = [ code for err in errors if isinstance((code := _access(err, "code")), str) and code ] + recoveries = self._effective_recoveries() + # The most demanding entry decides: terminal over correctable over transient. + self.recovery = next( + (r for r in ("terminal", "correctable", "transient") if r in recoveries), None + ) message = f"{operation} failed" if errors: @@ -703,6 +766,11 @@ def is_retryable(self) -> bool: `correctable` entry blocks (caller must fix the request first — a retry as-is would re-trigger the same error). """ + effective = self._effective_recoveries() + return bool(effective) and all(r == "transient" for r in effective) + + def _effective_recoveries(self) -> list[RecoveryLiteral]: + """Each entry's recovery: the wire value, else the code table, else transient.""" from adcp.server.helpers import CORRECTABLE_CODES, TERMINAL_CODES, TRANSIENT_CODES effective: list[RecoveryLiteral] = [] @@ -725,10 +793,7 @@ def is_retryable(self) -> bool: # against a producer that ships a new code before the SDK # learns about it. effective.append("transient") - - if not effective: - return False - return not any(r in ("terminal", "correctable") for r in effective) + return effective class IdempotencyConflictError(ADCPTaskError): @@ -827,9 +892,13 @@ class IdempotencyUnsupportedError(ADCPError): declares ``supported=False``, or declares ``supported=True`` without a ``replay_ttl_seconds`` window. Per AdCP spec, clients MUST NOT assume a default — a seller that does not positively declare support cannot be - safely retried. + safely retried. The mutating task is never sent (``dispatched`` is + ``False``). """ + dispatched: Literal[False] | None = False + recovery: RecoveryLiteral | None = "correctable" + def __init__( self, agent_id: str | None = None, diff --git a/src/adcp/protocols/a2a.py b/src/adcp/protocols/a2a.py index 9493ea024..a50e9b79f 100644 --- a/src/adcp/protocols/a2a.py +++ b/src/adcp/protocols/a2a.py @@ -476,6 +476,7 @@ async def _call_a2a_tool_wire( error=str(exc), success=False, idempotency_key=idempotency_key, + dispatched=False, ) if tool_name != "get_adcp_capabilities" and self.signing_capability_check: diff --git a/src/adcp/protocols/mcp.py b/src/adcp/protocols/mcp.py index 49a07eda5..a1ea8581d 100644 --- a/src/adcp/protocols/mcp.py +++ b/src/adcp/protocols/mcp.py @@ -934,8 +934,7 @@ def _log_cleanup_error(self, exc: BaseException, context: str) -> None: and ("cancel scope" in exc_str or "async context" in exc_str) ) or ( # HTTP errors during cleanup (if httpx is available) - HTTPX_AVAILABLE - and isinstance(exc, _ALL_HTTP_STATUS_ERROR_TYPES) + HTTPX_AVAILABLE and isinstance(exc, _ALL_HTTP_STATUS_ERROR_TYPES) ) if is_known_cleanup_error: @@ -1231,6 +1230,7 @@ async def _call_mcp_tool_wire(self, tool_name: str, params: dict[str, Any]) -> T error=str(exc), success=False, idempotency_key=idempotency_key, + dispatched=False, ) # Streamable HTTP sends from a long-lived writer task whose diff --git a/src/adcp/types/core.py b/src/adcp/types/core.py index 29b1198ed..a33d257f2 100644 --- a/src/adcp/types/core.py +++ b/src/adcp/types/core.py @@ -265,6 +265,11 @@ class TaskResult(BaseModel, Generic[T]): # Agents that emit side effects on success (notifications, memory writes, # downstream tool calls) must check this flag and suppress duplicates. replayed: bool = False + # False when the SDK refused the task locally and no bytes of its request + # left the process (request schema validation, version and feature gates): + # the failure says nothing about the agent's health. None otherwise. See + # ``adcp.exceptions.is_pre_dispatch_error``. + dispatched: Literal[False] | None = None class ActivityType(str, Enum): diff --git a/src/adcp/validation/__init__.py b/src/adcp/validation/__init__.py index 4b13f2559..962ff459f 100644 --- a/src/adcp/validation/__init__.py +++ b/src/adcp/validation/__init__.py @@ -55,6 +55,7 @@ list_validator_keys, ) from adcp.validation.schema_validator import ( + RequestValidationError, SchemaValidationError, ValidationIssue, ValidationOutcome, @@ -74,6 +75,7 @@ # Schema core "Direction", "ResponseVariant", + "RequestValidationError", "SchemaValidationError", "ValidationIssue", "ValidationOutcome", diff --git a/src/adcp/validation/schema_errors.py b/src/adcp/validation/schema_errors.py index f42c93d05..d7cc1db3c 100644 --- a/src/adcp/validation/schema_errors.py +++ b/src/adcp/validation/schema_errors.py @@ -7,6 +7,7 @@ from typing import Any from adcp.validation.schema_validator import ( + RequestValidationError, SchemaValidationError, ValidationIssue, _issue_to_wire, @@ -43,8 +44,11 @@ def build_validation_error( """Build a :class:`SchemaValidationError` carrying every failure. Strict-mode client hooks raise this so callers can inspect the full - pointer list via ``.issues`` and the ``details`` dict. + pointer list via ``.issues`` and the ``details`` dict. A request-side + failure is a :class:`RequestValidationError` (``dispatched`` is ``False``). """ + if side == "request": + return RequestValidationError(tool, issues) return SchemaValidationError(tool, side, issues) diff --git a/src/adcp/validation/schema_validator.py b/src/adcp/validation/schema_validator.py index d97308780..029075dde 100644 --- a/src/adcp/validation/schema_validator.py +++ b/src/adcp/validation/schema_validator.py @@ -19,11 +19,12 @@ import re from dataclasses import dataclass, field from itertools import islice -from typing import Any +from typing import Any, Literal from pydantic import BaseModel from adcp._version import ADCP_MAJOR_VERSION, normalize_to_release_precision +from adcp.exceptions import ADCPError, RecoveryLiteral from adcp.validation._response_envelope import uses_task_status_envelope from adcp.validation.oneof_hints import compute_oneof_hint from adcp.validation.schema_loader import Direction, ResponseVariant, get_validator @@ -124,14 +125,41 @@ def __init__( first = issues[0] if issues else None if first is not None: message = ( - f"{tool} {side} failed schema validation at " - f"{first.pointer}: {first.message}" + f"{tool} {side} failed schema validation at {first.pointer}: {first.message}" ) else: message = f"{tool} {side} failed schema validation" super().__init__(message) +class RequestValidationError(SchemaValidationError, ADCPError): + """The client refused a request against the request schema before sending it. + + ``dispatched`` is always ``False``: no bytes of the task's request left + this process (see :func:`adcp.exceptions.is_pre_dispatch_error`). Carries + the task name and the invalid field paths as JSON Pointers (``/`` for the + root), never the submitted values. The message is the validator's + existing text, so callers that match on it keep working. + + It is a :class:`SchemaValidationError`, so existing ``except`` clauses + still match, and an :class:`~adcp.exceptions.ADCPError` with ``recovery`` + ``"correctable"``. ``code`` is ``VALIDATION_ERROR``, or + ``VERSION_UNSUPPORTED`` when no schema exists for the requested version. + """ + + dispatched: Literal[False] | None = False + recovery: RecoveryLiteral | None = "correctable" + + def __init__(self, tool: str, issues: list[ValidationIssue]) -> None: + super().__init__(tool, "request", issues) + #: The task whose request was refused, e.g. ``sync_creatives``. + self.task = tool + #: JSON Pointers to the invalid fields, without their values, deduplicated. + self.fields: tuple[str, ...] = tuple(dict.fromkeys(i.pointer or "/" for i in issues)) + #: The first invalid field. + self.field = self.fields[0] if self.fields else "/" + + _OK_SKIPPED = ValidationOutcome(valid=True, issues=[], variant="skipped") diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 0bd0ff4a7..61642ee41 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -516,6 +516,7 @@ "ReportingTrustedOriginPolicy": "union[builtins:bool,instance:builtins:list]", "RequestProposalsRequest": "media_buy.request_proposals_request", "RequestProposalsResponse": "union[instance:pydantic.fields:FieldInfo,union[media_buy.request_proposals_response:RequestProposalsResponse1,media_buy.request_proposals_response:RequestProposalsResponse2,media_buy.request_proposals_response:RequestProposalsResponse3,media_buy.request_proposals_response:RequestProposalsResponse4]]", + "RequestValidationError": "adcp.validation.schema_validator", "ResolvedBrand": "adcp.types.core", "ResolvedProperty": "adcp.types.core", "ResolvedPropertyList": "adcp.property_lists", diff --git a/tests/test_pre_dispatch_errors.py b/tests/test_pre_dispatch_errors.py new file mode 100644 index 000000000..6af54693c --- /dev/null +++ b/tests/test_pre_dispatch_errors.py @@ -0,0 +1,113 @@ +"""Local refusals carry ``dispatched = False``; every SDK error classifies its recovery. + +Python parity for adcontextprotocol/adcp-client#3507. +""" + +from __future__ import annotations + +import pytest + +from adcp import ( + ADCPConnectionError, + ADCPError, + ADCPFeatureUnsupportedError, + ADCPTaskError, + ADCPTimeoutError, + IdempotencyUnsupportedError, + RequestValidationError, + SchemaValidationError, + is_pre_dispatch_error, +) +from adcp.compat.versions import negotiate_version +from adcp.task_options import TaskRecoveryMetadata +from adcp.types.core import TaskResult, TaskStatus +from adcp.validation import validate_outgoing_request + +SENTINEL = "sentinel-value-7c1d" + + +def test_a_strict_request_refusal_is_a_typed_pre_dispatch_error() -> None: + with pytest.raises(RequestValidationError) as info: + validate_outgoing_request( + "create_media_buy", {"buyer_ref": 7, "po_number": SENTINEL, "x": 1}, "strict" + ) + error = info.value + # Existing handlers still match, and the error is an ADCPError. + assert isinstance(error, SchemaValidationError) and isinstance(error, ADCPError) + assert error.dispatched is False and is_pre_dispatch_error(error) + assert error.code == "VALIDATION_ERROR" and error.recovery == "correctable" + assert error.task == "create_media_buy" and error.side == "request" + assert error.fields and error.field == error.fields[0] + assert len(set(error.fields)) == len(error.fields) + assert all(pointer.startswith("/") for pointer in error.fields) + # Pointers only: the submitted values never reach the error. + assert SENTINEL not in str(error) and SENTINEL not in repr(error.details) + + +def test_a_response_validation_error_is_not_marked() -> None: + from adcp.validation import build_validation_error + + error = build_validation_error("get_products", "response", []) + assert type(error) is SchemaValidationError + assert not is_pre_dispatch_error(error) + + +def test_feature_refusals_are_marked_only_where_nothing_was_sent() -> None: + with pytest.raises(ADCPFeatureUnsupportedError) as negotiated: + negotiate_version(["2.5"], maximum="3.1") + assert is_pre_dispatch_error(negotiated.value) + assert negotiated.value.recovery == "correctable" + # A refusal that follows a response (for example an unconvertible one) is not. + assert ADCPFeatureUnsupportedError(["x"]).dispatched is None + assert is_pre_dispatch_error(ADCPFeatureUnsupportedError(["x"], dispatched=False)) + + +def test_idempotency_refusal_is_local() -> None: + error = IdempotencyUnsupportedError() + assert is_pre_dispatch_error(error) and error.recovery == "correctable" + + +def test_agent_failures_are_never_marked() -> None: + for error in ( + ADCPConnectionError("refused"), + ADCPTaskError("get_products", [{"code": "SERVICE_UNAVAILABLE"}]), + ADCPTimeoutError("slow"), + ): + assert not is_pre_dispatch_error(error) + + +def test_is_pre_dispatch_error_checks_the_attribute() -> None: + result = TaskResult(status=TaskStatus.FAILED, success=False, error="x", dispatched=False) + assert is_pre_dispatch_error(result) + assert not is_pre_dispatch_error(TaskResult(status=TaskStatus.FAILED, success=False)) + + class ForeignError(Exception): + dispatched = False + + assert is_pre_dispatch_error(ForeignError()) + for value in (None, object(), {"dispatched": False}, ValueError(), ADCPError("x")): + assert not is_pre_dispatch_error(value) + + +@pytest.mark.parametrize( + ("errors", "recovery"), + [ + ([{"code": "RATE_LIMITED", "recovery": "transient"}], "transient"), + ([{"code": "RATE_LIMITED"}, {"code": "INVALID_REQUEST"}], "correctable"), + ([{"code": "INVALID_REQUEST"}, {"code": "X", "recovery": "terminal"}], "terminal"), + ([{"code": "X_VENDOR_NEW"}], "transient"), # unknown code, no wire recovery + ([], None), + ], +) +def test_task_errors_classify_by_their_most_demanding_entry(errors, recovery) -> None: + error = ADCPTaskError("create_media_buy", errors) + assert error.recovery == recovery + assert error.is_retryable is (recovery == "transient") + + +def test_recovery_defaults() -> None: + assert ADCPError("x").recovery is None + assert ADCPConnectionError("x").recovery == "transient" + # A timeout keeps its safe-retry identity under ``recovery``. + metadata = TaskRecoveryMetadata("create_media_buy", "op-1", "key-1") + assert ADCPTimeoutError("slow", recovery=metadata).recovery is metadata diff --git a/tests/test_schema_validation_client.py b/tests/test_schema_validation_client.py index ef511e295..0bc00091d 100644 --- a/tests/test_schema_validation_client.py +++ b/tests/test_schema_validation_client.py @@ -67,6 +67,8 @@ async def test_strict_request_blocks_call_before_transport(self) -> None: result.error or "" ) session.call_tool.assert_not_called() + # Nothing was sent, so the result is marked as a local refusal. + assert result.dispatched is False @pytest.mark.asyncio async def test_strict_response_fails_task_on_drift(self) -> None: diff --git a/tests/test_version_client_boundaries.py b/tests/test_version_client_boundaries.py index 1e4f70606..302ba8caa 100644 --- a/tests/test_version_client_boundaries.py +++ b/tests/test_version_client_boundaries.py @@ -132,6 +132,7 @@ async def test_version_preflight_failure_is_a_failed_result_without_dispatch(pro assert result.data is None assert result.adcp_error["code"] == "UNSUPPORTED_FEATURE" assert result.adcp_error["details"]["wire_response_received"] is False + assert result.dispatched is False # refused before any of the task was sent if case == "null_clear": assert result.adcp_error["details"]["pointer"] == "/frequency_cap" assert result.idempotency_key == "unsupported-null-clear-1551"