From e76618ed1056302956c59e9a774e0e4bad134010 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Tue, 12 May 2026 23:23:48 -0400 Subject: [PATCH 1/8] smithy-json: support non-finite number serde --- .../smithy-json/src/smithy_json/_private/deserializers.py | 8 +++----- .../smithy-json/src/smithy_json/_private/serializers.py | 2 +- packages/smithy-json/tests/unit/__init__.py | 6 ++++++ packages/smithy-json/tests/unit/test_deserializers.py | 7 +++++++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/smithy-json/src/smithy_json/_private/deserializers.py b/packages/smithy-json/src/smithy_json/_private/deserializers.py index bbbb16927..58d888edb 100644 --- a/packages/smithy-json/src/smithy_json/_private/deserializers.py +++ b/packages/smithy-json/src/smithy_json/_private/deserializers.py @@ -127,12 +127,10 @@ def read_integer(self, schema: Schema) -> int: def read_float(self, schema: Schema) -> float: event = next(self._stream) match event.value: - case Decimal(): + case Decimal() | "Infinity" | "-Infinity" | "NaN": return float(event.value) case int() | float(): return event.value - case "Infinity" | "-Infinity" | "NaN": - return float(event.value) case _: raise JSONTokenError("number", event) @@ -141,8 +139,8 @@ def read_big_decimal(self, schema: Schema) -> Decimal: match event.value: case Decimal(): return event.value - case int() | float(): - return Decimal.from_float(event.value) + case int() | float() | "Infinity" | "-Infinity" | "NaN": + return Decimal(event.value) case _: raise JSONTokenError("number", event) diff --git a/packages/smithy-json/src/smithy_json/_private/serializers.py b/packages/smithy-json/src/smithy_json/_private/serializers.py index 42f4ea476..eb19d7ae8 100644 --- a/packages/smithy-json/src/smithy_json/_private/serializers.py +++ b/packages/smithy-json/src/smithy_json/_private/serializers.py @@ -312,7 +312,7 @@ def write_float(self, value: float | Decimal) -> None: def _write_non_numeric_float(self, value: float | Decimal) -> bool: if value != value: - self._sink.write(b"NaN") + self._sink.write(b'"NaN"') return True if value == _INF: diff --git a/packages/smithy-json/tests/unit/__init__.py b/packages/smithy-json/tests/unit/__init__.py index 8e666cdb5..9fdcbb530 100644 --- a/packages/smithy-json/tests/unit/__init__.py +++ b/packages/smithy-json/tests/unit/__init__.py @@ -346,7 +346,13 @@ def _read_optional_map(k: str, d: ShapeDeserializer): (True, b"true"), (1, b"1"), (1.1, b"1.1"), + (float("nan"), b'"NaN"'), + (float("inf"), b'"Infinity"'), + (float("-inf"), b'"-Infinity"'), (Decimal("1.1"), b"1.1"), + (Decimal("NaN"), b'"NaN"'), + (Decimal("Infinity"), b'"Infinity"'), + (Decimal("-Infinity"), b'"-Infinity"'), (b"foo", b'"Zm9v"'), ("foo", b'"foo"'), # RFC 8259 ยง7: control characters must be escaped diff --git a/packages/smithy-json/tests/unit/test_deserializers.py b/packages/smithy-json/tests/unit/test_deserializers.py index 309a7feae..00a6ed2cd 100644 --- a/packages/smithy-json/tests/unit/test_deserializers.py +++ b/packages/smithy-json/tests/unit/test_deserializers.py @@ -1,5 +1,6 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +import math from datetime import datetime from decimal import Decimal from typing import Any @@ -89,6 +90,12 @@ def _read_optional_map(k: str, d: ShapeDeserializer): actual_value = actual.as_value() expected_value = expected.as_value() assert actual_value == expected_value + elif isinstance(expected, float) and math.isnan(expected): + assert isinstance(actual, float) + assert math.isnan(actual) + elif isinstance(expected, Decimal) and expected.is_nan(): + assert isinstance(actual, Decimal) + assert actual.is_nan() else: assert actual == expected From bb2f8ec4b4ed5ef918e15e740e5327f4cc60256b Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Tue, 12 May 2026 23:24:37 -0400 Subject: [PATCH 2/8] smithy-aws-core: add AWS JSON client protocols --- .../src/smithy_aws_core/aio/protocols.py | 195 +++++++++++++++++- .../src/smithy_aws_core/traits.py | 87 ++++++-- .../tests/unit/aio/test_protocols.py | 138 +++++++++++++ .../smithy-aws-core/tests/unit/test_traits.py | 19 +- .../smithy-aws-core/tests/unit/test_utils.py | 5 + .../tests/unit/aio/test_protocols.py | 10 + 6 files changed, 425 insertions(+), 29 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py b/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py index 68d2c2d01..94c4334f9 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py @@ -3,7 +3,7 @@ from collections.abc import Callable from inspect import iscoroutinefunction from io import BytesIO -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, ClassVar, Final from smithy_core import URI as _URI from smithy_core.aio.interfaces import AsyncWriter @@ -16,10 +16,13 @@ from smithy_core.exceptions import ( CallError, DiscriminatorError, + ExpectationNotMetError, MissingDependencyError, + ModeledError, UnsupportedStreamError, ) from smithy_core.interfaces import TypedProperties, URI +from smithy_core.prelude import DOCUMENT from smithy_core.schemas import APIOperation, Schema from smithy_core.serializers import SerializeableShape from smithy_core.shapes import ShapeID, ShapeType @@ -30,11 +33,9 @@ from smithy_http.aio.protocols import HttpBindingClientProtocol, HttpClientProtocol from smithy_http.deserializers import HTTPResponseDeserializer -from .._private.query.errors import ( - create_aws_query_error, -) +from .._private.query.errors import create_aws_query_error from .._private.query.serializers import QueryShapeSerializer -from ..traits import AwsQueryTrait, RestJson1Trait +from ..traits import AwsJson1_0Trait, AwsJson1_1Trait, AwsQueryTrait, RestJson1Trait from ..utils import parse_document_discriminator, parse_error_code try: @@ -114,7 +115,7 @@ def identify( return None -if _HAS_JSON: +if TYPE_CHECKING or _HAS_JSON: class AWSJSONDocument(JSONDocument): @property @@ -129,13 +130,17 @@ def discriminator(self) -> ShapeID: f"Unable to parse discriminator for {self.shape_type} document." ) return parsed +else: + + class AWSJSONDocument: # type: ignore[no-redef] + pass class RestJsonClientProtocol(HttpBindingClientProtocol): """An implementation of the aws.protocols#restJson1 protocol.""" _id: Final = RestJson1Trait.id - _contentType: Final = "application/json" + _content_type: Final = "application/json" _error_identifier: Final = AWSErrorIdentifier() def __init__(self, service_schema: Schema) -> None: @@ -160,7 +165,7 @@ def payload_codec(self) -> Codec: @property def content_type(self) -> str: - return self._contentType + return self._content_type @property def error_identifier(self) -> HTTPErrorIdentifier: @@ -241,6 +246,180 @@ def create_event_receiver[ ) +class _AWSJSONClientProtocol(HttpClientProtocol): + _error_identifier: Final = AWSErrorIdentifier() + + _id: ClassVar[ShapeID] + _content_type: ClassVar[str] + + def __init__(self, service_schema: Schema) -> None: + _assert_json() + self._service_name: Final = service_schema.id.name + self._codec: Final = JSONCodec( + document_class=AWSJSONDocument, + default_namespace=service_schema.id.namespace, + default_timestamp_format=TimestampFormat.EPOCH_SECONDS, + use_json_name=False, + ) + + @property + def id(self) -> ShapeID: + return self._id + + @property + def payload_codec(self) -> Codec: + return self._codec + + @property + def content_type(self) -> str: + return self._content_type + + @property + def error_identifier(self) -> HTTPErrorIdentifier: + return self._error_identifier + + def serialize_request[ + OperationInput: SerializeableShape, + OperationOutput: DeserializeableShape, + ]( + self, + *, + operation: APIOperation[OperationInput, OperationOutput], + input: OperationInput, + endpoint: URI, + context: TypedProperties, + ) -> HTTPRequest: + payload = self.payload_codec.serialize(shape=input) + return _HTTPRequest( + method="POST", + destination=_URI(host="", path="/"), + fields=tuples_to_fields( + [ + ("content-type", self.content_type), + ("content-length", str(len(payload))), + ( + "x-amz-target", + f"{self._service_name}.{operation.schema.id.name}", + ), + ] + ), + body=AsyncBytesReader(payload), + ) + + async def deserialize_response[ + OperationInput: SerializeableShape, + OperationOutput: DeserializeableShape, + ]( + self, + *, + operation: APIOperation[OperationInput, OperationOutput], + request: HTTPRequest, + response: HTTPResponse, + error_registry: TypeRegistry, + context: TypedProperties, + ) -> OperationOutput: + body = await response.consume_body_async() + + if not self._is_success(operation, context, response): + raise await self._create_error( + operation=operation, + response=response, + response_body=body, + error_registry=error_registry, + context=context, + ) + + if len(body) == 0: + body = b"{}" + return self.payload_codec.deserialize(source=body, shape=operation.output) + + def _is_success( + self, + operation: APIOperation[Any, Any], + context: TypedProperties, + response: HTTPResponse, + ) -> bool: + return 200 <= response.status < 300 + + async def _create_error( + self, + *, + operation: APIOperation[Any, Any], + response: HTTPResponse, + response_body: bytes, + error_registry: TypeRegistry, + context: TypedProperties, + ) -> CallError: + error_id = self.error_identifier.identify( + operation=operation, response=response + ) + + if ( + error_id is None + and len(response_body) > 0 + and self._matches_content_type(response) + ): + deserializer = self.payload_codec.create_deserializer(response_body) + document = deserializer.read_document(schema=DOCUMENT) + if document.discriminator in error_registry: + error_id = document.discriminator + + if error_id is not None and error_id in error_registry: + error_shape = error_registry.get(error_id) + + # make sure the error shape is derived from modeled exception + if not issubclass(error_shape, ModeledError): + raise ExpectationNotMetError( + f"Modeled errors must be derived from 'ModeledError', " + f"but got {error_shape}" + ) + + body = response_body if len(response_body) > 0 else b"{}" + deserializer = self.payload_codec.create_deserializer(body) + return error_shape.deserialize(deserializer) + + message = ( + f"Unknown error for operation {operation.schema.id} " + f"- status: {response.status}" + ) + if error_id is not None: + message += f" - id: {error_id}" + if response.reason is not None: + message += f" - reason: {response.reason}" + + is_timeout = response.status == 408 + is_throttle = response.status == 429 + fault = "client" if response.status < 500 else "server" + + return CallError( + message=message, + fault=fault, + is_throttling_error=is_throttle, + is_timeout_error=is_timeout, + is_retry_safe=is_throttle or is_timeout or None, + ) + + def _matches_content_type(self, response: HTTPResponse) -> bool: + if "content-type" not in response.fields: + return False + actual = response.fields["content-type"].as_string() + return actual.split(";", 1)[0].strip().lower() == self.content_type.lower() + + +class AwsJson10ClientProtocol(_AWSJSONClientProtocol): + """An implementation of the aws.protocols#awsJson1_0 protocol.""" + + _id: ClassVar[ShapeID] = AwsJson1_0Trait.id + _content_type: ClassVar[str] = "application/x-amz-json-1.0" + + +class AwsJson11ClientProtocol(_AWSJSONClientProtocol): + """An implementation of the aws.protocols#awsJson1_1 protocol.""" + + _id: ClassVar[ShapeID] = AwsJson1_1Trait.id + _content_type: ClassVar[str] = "application/x-amz-json-1.1" + + class AwsQueryClientProtocol(HttpClientProtocol): """An implementation of the aws.protocols#awsQuery protocol.""" diff --git a/packages/smithy-aws-core/src/smithy_aws_core/traits.py b/packages/smithy-aws-core/src/smithy_aws_core/traits.py index 2e0c370ab..3fd1ed5db 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/traits.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/traits.py @@ -15,6 +15,40 @@ from smithy_core.traits import DynamicTrait, Trait +def _parse_http_protocol_values( + value: DocumentValue | DynamicTrait | None, +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Parse aws.protocols HTTP settings from a trait document. + + The input is expected to be shaped like {"http": [...], "eventStreamHttp": [...]} + and returns (http_versions, event_stream_http_versions). If "eventStreamHttp" + is absent, event streams use the same versions as "http". If "http" is absent, + it defaults to ("http/1.1",). + """ + document_value = value or {} + assert isinstance(document_value, Mapping) + + http_versions_raw = document_value.get("http", ["http/1.1"]) + assert isinstance(http_versions_raw, Sequence) + http_versions_list: list[str] = [] + for entry in http_versions_raw: + assert isinstance(entry, str) + http_versions_list.append(entry) + http_versions = tuple(http_versions_list) + + event_stream_http_versions_raw = document_value.get("eventStreamHttp") + if not event_stream_http_versions_raw: + return http_versions, http_versions + + assert isinstance(event_stream_http_versions_raw, Sequence) + event_stream_http_versions_list: list[str] = [] + for entry in event_stream_http_versions_raw: + assert isinstance(entry, str) + event_stream_http_versions_list.append(entry) + + return http_versions, tuple(event_stream_http_versions_list) + + @dataclass(init=False, frozen=True) class RestJson1Trait(Trait, id=ShapeID("aws.protocols#restJson1")): http: Sequence[str] = field( @@ -26,24 +60,41 @@ class RestJson1Trait(Trait, id=ShapeID("aws.protocols#restJson1")): def __init__(self, value: DocumentValue | DynamicTrait = None): super().__init__(value) - document_value = value or {} - assert isinstance(document_value, Mapping) - - http_versions = document_value.get("http", ["http/1.1"]) - assert isinstance(http_versions, Sequence) - for val in http_versions: - assert isinstance(val, str) - object.__setattr__(self, "http", tuple(http_versions)) - event_stream_http_versions = document_value.get("eventStreamHttp") - if not event_stream_http_versions: - object.__setattr__(self, "event_stream_http", self.http) - else: - assert isinstance(event_stream_http_versions, Sequence) - for val in event_stream_http_versions: - assert isinstance(val, str) - object.__setattr__( - self, "event_stream_http", tuple(event_stream_http_versions) - ) + http, event_stream_http = _parse_http_protocol_values(value) + object.__setattr__(self, "http", http) + object.__setattr__(self, "event_stream_http", event_stream_http) + + +@dataclass(init=False, frozen=True) +class AwsJson1_0Trait(Trait, id=ShapeID("aws.protocols#awsJson1_0")): + http: Sequence[str] = field( + repr=False, hash=False, compare=False, default_factory=tuple + ) + event_stream_http: Sequence[str] = field( + repr=False, hash=False, compare=False, default_factory=tuple + ) + + def __init__(self, value: DocumentValue | DynamicTrait = None): + super().__init__(value) + http, event_stream_http = _parse_http_protocol_values(value) + object.__setattr__(self, "http", http) + object.__setattr__(self, "event_stream_http", event_stream_http) + + +@dataclass(init=False, frozen=True) +class AwsJson1_1Trait(Trait, id=ShapeID("aws.protocols#awsJson1_1")): + http: Sequence[str] = field( + repr=False, hash=False, compare=False, default_factory=tuple + ) + event_stream_http: Sequence[str] = field( + repr=False, hash=False, compare=False, default_factory=tuple + ) + + def __init__(self, value: DocumentValue | DynamicTrait = None): + super().__init__(value) + http, event_stream_http = _parse_http_protocol_values(value) + object.__setattr__(self, "http", http) + object.__setattr__(self, "event_stream_http", event_stream_http) @dataclass(frozen=True) diff --git a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py index 689095823..8759bc19c 100644 --- a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py +++ b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py @@ -8,10 +8,12 @@ import pytest from smithy_aws_core.aio.protocols import ( AWSErrorIdentifier, + AwsJson11ClientProtocol, AWSJSONDocument, AwsQueryClientProtocol, ) from smithy_aws_core.traits import AwsQueryTrait +from smithy_core import URI as _URI from smithy_core.deserializers import ShapeDeserializer from smithy_core.documents import TypeRegistry from smithy_core.exceptions import CallError, DiscriminatorError, ModeledError @@ -39,6 +41,7 @@ "com.test#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate", "com.test#FooError", ), + ("com.other#FooError", "com.other#FooError"), ("", None), (":", None), (None, None), @@ -111,6 +114,12 @@ def test_aws_json_document_discriminator( assert discriminator == expected +_EMPTY_INPUT_SCHEMA = Schema.collection( + id=ShapeID("com.test#EmptyInput"), +) +_EMPTY_OUTPUT_SCHEMA = Schema.collection( + id=ShapeID("com.test#EmptyOutput"), +) _INPUT_SCHEMA = Schema.collection( id=ShapeID("com.test#TestInput"), members={"name": {"target": STRING}}, @@ -133,6 +142,23 @@ def test_aws_json_document_discriminator( ) +@dataclass +class _EmptyInput: + def serialize(self, serializer: ShapeSerializer) -> None: + serializer.write_struct(_EMPTY_INPUT_SCHEMA, self) + + def serialize_members(self, serializer: ShapeSerializer) -> None: + pass + + +@dataclass +class _EmptyOutput: + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> "_EmptyOutput": + deserializer.read_struct(_EMPTY_OUTPUT_SCHEMA, lambda _schema, _de: None) + return cls() + + @dataclass class _TestInput: name: str | None = None @@ -178,6 +204,118 @@ def _mock_operation( return cast("APIOperation[Any, Any]", operation) +def _aws_json11_protocol() -> AwsJson11ClientProtocol: + return AwsJson11ClientProtocol( + Schema(id=ShapeID("com.test#JsonService"), shape_type=ShapeType.SERVICE) + ) + + +@pytest.mark.asyncio +async def test_aws_json11_serializes_base_request_shape() -> None: + protocol = _aws_json11_protocol() + request = protocol.serialize_request( + operation=_mock_operation(_operation_schema("EmptyOperation")), + input=_EmptyInput(), + endpoint=_URI(host="example.com"), + context=TypedProperties(), + ) + + assert request.method == "POST" + assert request.destination.path == "/" + assert request.fields["content-type"].as_string() == "application/x-amz-json-1.1" + assert request.fields["x-amz-target"].as_string() == "JsonService.EmptyOperation" + assert request.fields["content-length"].as_string() == "2" + assert await request.consume_body_async() == b"{}" + + +def test_aws_json_matches_content_type_with_parameters() -> None: + protocol = _aws_json11_protocol() + response = HTTPResponse( + status=500, + fields=tuples_to_fields( + [("content-type", "application/x-amz-json-1.1; charset=utf-8")] + ), + ) + assert getattr(protocol, "_matches_content_type")(response) + + +@pytest.mark.asyncio +async def test_aws_json11_deserializes_empty_response_body() -> None: + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("EmptyOperation")) + cast(Any, operation).output = _EmptyOutput + + output = await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=HTTPResponse(status=200, fields=Fields(), body=b""), + error_registry=TypeRegistry({}), + context=TypedProperties(), + ) + + assert isinstance(output, _EmptyOutput) + + +class _OtherNamespaceModeledError(ModeledError): + @classmethod + def deserialize(cls, deserializer: Any) -> "_OtherNamespaceModeledError": + return cls("other namespace") + + +@pytest.mark.asyncio +async def test_aws_json11_resolves_modeled_error_from_header_other_namespace() -> None: + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("FailingOperation")) + response = HTTPResponse( + status=400, + reason="Bad Request", + fields=tuples_to_fields( + [ + ("x-amzn-errortype", "com.other#OtherNsError"), + ("content-type", "application/x-amz-json-1.1"), + ] + ), + body=b'{"__type":"com.other#OtherNsError"}', + ) + + error = await getattr(protocol, "_create_error")( + operation=operation, + response=response, + response_body=response.body, + error_registry=TypeRegistry( + {ShapeID("com.other#OtherNsError"): _OtherNamespaceModeledError} + ), + context=TypedProperties(), + ) + + assert isinstance(error, _OtherNamespaceModeledError) + + +@pytest.mark.asyncio +async def test_aws_json11_resolves_modeled_error_from_header_only_shapeid() -> None: + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("FailingOperation")) + response = HTTPResponse( + status=400, + reason="Bad Request", + fields=tuples_to_fields([("x-amzn-errortype", "com.other#OtherNsError")]), + body=b"", + ) + + error = await getattr(protocol, "_create_error")( + operation=operation, + response=response, + response_body=response.body, + error_registry=TypeRegistry( + {ShapeID("com.other#OtherNsError"): _OtherNamespaceModeledError} + ), + context=TypedProperties(), + ) + + assert isinstance(error, _OtherNamespaceModeledError) + + +@pytest.mark.asyncio async def test_aws_query_serializes_base_request_shape() -> None: protocol = AwsQueryClientProtocol(_SERVICE_SCHEMA, "2020-01-08") request = protocol.serialize_request( diff --git a/packages/smithy-aws-core/tests/unit/test_traits.py b/packages/smithy-aws-core/tests/unit/test_traits.py index bf4d7fe6c..af77e038b 100644 --- a/packages/smithy-aws-core/tests/unit/test_traits.py +++ b/packages/smithy-aws-core/tests/unit/test_traits.py @@ -1,11 +1,24 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from smithy_aws_core.traits import AwsQueryErrorTrait, AwsQueryTrait, RestJson1Trait +import pytest +from smithy_aws_core.traits import ( + AwsJson1_0Trait, + AwsJson1_1Trait, + AwsQueryErrorTrait, + AwsQueryTrait, + RestJson1Trait, +) -def test_allows_empty_restjson1_value() -> None: - trait = RestJson1Trait(None) +@pytest.mark.parametrize( + "trait_type", + [RestJson1Trait, AwsJson1_0Trait, AwsJson1_1Trait], +) +def test_allows_empty_protocol_trait_value( + trait_type: type[RestJson1Trait] | type[AwsJson1_0Trait] | type[AwsJson1_1Trait], +) -> None: + trait = trait_type(None) assert trait.http == ("http/1.1",) assert trait.event_stream_http == ("http/1.1",) diff --git a/packages/smithy-aws-core/tests/unit/test_utils.py b/packages/smithy-aws-core/tests/unit/test_utils.py index 6927a2fce..cfaad670e 100644 --- a/packages/smithy-aws-core/tests/unit/test_utils.py +++ b/packages/smithy-aws-core/tests/unit/test_utils.py @@ -64,6 +64,11 @@ def test_aws_json_document_discriminator( "com.test#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate", "com.test#FooError", ), + ("com.other#FooError", "com.other#FooError"), + ( + "com.other#FooError:http://internal.amazon.com/coral/com.amazon.coral.validate", + "com.other#FooError", + ), ("", None), (":", None), ], diff --git a/packages/smithy-http/tests/unit/aio/test_protocols.py b/packages/smithy-http/tests/unit/aio/test_protocols.py index 4ae18ce67..cda2a79f8 100644 --- a/packages/smithy-http/tests/unit/aio/test_protocols.py +++ b/packages/smithy-http/tests/unit/aio/test_protocols.py @@ -120,6 +120,16 @@ def deserialize_response( URI(host="com.example"), URI(host="com.example", fragment="header"), ), + ( + URI(host="foo."), + URI(host="com.example"), + URI(host="com.example"), + ), + ( + URI(host="."), + URI(host="com.example"), + URI(host="com.example"), + ), ], ) def test_http_protocol_joins_uris( From e883dbb3df368683989020b696cd4f86260009a2 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Tue, 12 May 2026 23:25:40 -0400 Subject: [PATCH 3/8] codegen: add AWS JSON protocol generators --- .../codegen/AwsJson10ProtocolGenerator.java | 83 +++++++++++++++++++ .../codegen/AwsJson11ProtocolGenerator.java | 71 ++++++++++++++++ .../aws/codegen/AwsProtocolsIntegration.java | 5 +- .../RestJsonProtocolGenerator.java | 15 ++-- codegen/protocol-test/smithy-build.json | 44 ++++++++++ 5 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java create mode 100644 codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java new file mode 100644 index 000000000..cc7289a5b --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson10ProtocolGenerator.java @@ -0,0 +1,83 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.aws.codegen; + +import java.util.Set; +import software.amazon.smithy.aws.traits.protocols.AwsJson1_0Trait; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.python.codegen.ApplicationProtocol; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.HttpProtocolTestGenerator; +import software.amazon.smithy.python.codegen.SymbolProperties; +import software.amazon.smithy.python.codegen.generators.ProtocolGenerator; +import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.utils.SmithyInternalApi; + +@SmithyInternalApi +public final class AwsJson10ProtocolGenerator implements ProtocolGenerator { + private static final Set TESTS_TO_SKIP = Set.of( + // These tests essentially try to assert nan == nan, which is never true. + // The generator needs protocol-specific assertions before enabling them. + "AwsJson10SupportsNaNFloatInputs", + + // TODO: support the request compression trait. + "SDKAppliedContentEncoding_awsJson1_0", + "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_awsJson1_0", + + // TODO: fix default value behavior for JSON RPC. + "AwsJson10ClientPopulatesDefaultValuesInInput", + "AwsJson10ClientSkipsTopLevelDefaultValuesInInput", + "AwsJson10ClientUsesExplicitlyProvidedMemberValuesOverDefaults", + "AwsJson10ClientPopulatesDefaultsValuesWhenMissingInResponse", + "AwsJson10ClientIgnoresNonTopLevelDefaultsOnMembersWithClientOptional", + + // TODO: support the endpoint trait. + "AwsJson10EndpointTrait", + "AwsJson10EndpointTraitWithHostLabel", + + // TODO: support client error-correction behavior when the server + // omits required values in modeled error responses. + "AwsJson10ClientErrorCorrectsWhenServerFailsToSerializeRequiredValues", + "AwsJson10ClientErrorCorrectsWithDefaultValuesWhenServerFailsToSerializeRequiredValues"); + + @Override + public ShapeId getProtocol() { + return AwsJson1_0Trait.ID; + } + + @Override + public ApplicationProtocol getApplicationProtocol(GenerationContext context) { + var service = context.settings().service(context.model()); + var trait = service.expectTrait(AwsJson1_0Trait.class); + var config = ObjectNode.builder() + .withMember("http", ArrayNode.fromStrings(trait.getHttp())) + .withMember("eventStreamHttp", ArrayNode.fromStrings(trait.getEventStreamHttp())) + .build(); + return ApplicationProtocol.createDefaultHttpApplicationProtocol(config); + } + + @Override + public void initializeProtocol(GenerationContext context, PythonWriter writer) { + writer.addDependency(AwsPythonDependency.SMITHY_AWS_CORE.withOptionalDependencies("json")); + writer.addImport("smithy_aws_core.aio.protocols", "AwsJson10ClientProtocol"); + var serviceSymbol = context.symbolProvider().toSymbol(context.settings().service(context.model())); + var serviceSchema = serviceSymbol.expectProperty(SymbolProperties.SCHEMA); + writer.write("AwsJson10ClientProtocol($T)", serviceSchema); + } + + @Override + public void generateProtocolTests(GenerationContext context) { + context.writerDelegator() + .useFileWriter("./tests/test_awsjson10_protocol.py", "tests.test_awsjson10_protocol", writer -> { + new HttpProtocolTestGenerator( + context, + getProtocol(), + writer, + (shape, testCase) -> TESTS_TO_SKIP.contains(testCase.getId())).run(); + }); + } +} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java new file mode 100644 index 000000000..ea9f80458 --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsJson11ProtocolGenerator.java @@ -0,0 +1,71 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.aws.codegen; + +import java.util.Set; +import software.amazon.smithy.aws.traits.protocols.AwsJson1_1Trait; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.python.codegen.ApplicationProtocol; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.HttpProtocolTestGenerator; +import software.amazon.smithy.python.codegen.SymbolProperties; +import software.amazon.smithy.python.codegen.generators.ProtocolGenerator; +import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.utils.SmithyInternalApi; + +@SmithyInternalApi +public final class AwsJson11ProtocolGenerator implements ProtocolGenerator { + private static final Set TESTS_TO_SKIP = Set.of( + // These tests essentially try to assert nan == nan, which is never true. + // The generator needs protocol-specific assertions before enabling them. + "AwsJson11SupportsNaNFloatInputs", + + // TODO: support the request compression trait. + "SDKAppliedContentEncoding_awsJson1_1", + "SDKAppendsGzipAndIgnoresHttpProvidedEncoding_awsJson1_1", + + // TODO: support the endpoint trait. + "AwsJson11EndpointTrait", + "AwsJson11EndpointTraitWithHostLabel"); + + @Override + public ShapeId getProtocol() { + return AwsJson1_1Trait.ID; + } + + @Override + public ApplicationProtocol getApplicationProtocol(GenerationContext context) { + var service = context.settings().service(context.model()); + var trait = service.expectTrait(AwsJson1_1Trait.class); + var config = ObjectNode.builder() + .withMember("http", ArrayNode.fromStrings(trait.getHttp())) + .withMember("eventStreamHttp", ArrayNode.fromStrings(trait.getEventStreamHttp())) + .build(); + return ApplicationProtocol.createDefaultHttpApplicationProtocol(config); + } + + @Override + public void initializeProtocol(GenerationContext context, PythonWriter writer) { + writer.addDependency(AwsPythonDependency.SMITHY_AWS_CORE.withOptionalDependencies("json")); + writer.addImport("smithy_aws_core.aio.protocols", "AwsJson11ClientProtocol"); + var serviceSymbol = context.symbolProvider().toSymbol(context.settings().service(context.model())); + var serviceSchema = serviceSymbol.expectProperty(SymbolProperties.SCHEMA); + writer.write("AwsJson11ClientProtocol($T)", serviceSchema); + } + + @Override + public void generateProtocolTests(GenerationContext context) { + context.writerDelegator() + .useFileWriter("./tests/test_awsjson11_protocol.py", "tests.test_awsjson11_protocol", writer -> { + new HttpProtocolTestGenerator( + context, + getProtocol(), + writer, + (shape, testCase) -> TESTS_TO_SKIP.contains(testCase.getId())).run(); + }); + } +} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsProtocolsIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsProtocolsIntegration.java index d7d24a4af..6c714beed 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsProtocolsIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsProtocolsIntegration.java @@ -16,6 +16,9 @@ public class AwsProtocolsIntegration implements PythonIntegration { @Override public List getProtocolGenerators() { - return List.of(new AwsQueryProtocolGenerator()); + return List.of( + new AwsQueryProtocolGenerator(), + new AwsJson10ProtocolGenerator(), + new AwsJson11ProtocolGenerator()); } } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java index 1a31050e7..619226200 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/integrations/RestJsonProtocolGenerator.java @@ -94,13 +94,14 @@ public void initializeProtocol(GenerationContext context, PythonWriter writer) { // it will need to generate some protocol-specific comparators. @Override public void generateProtocolTests(GenerationContext context) { - context.writerDelegator().useFileWriter("./tests/test_protocol.py", "tests.test_protocol", writer -> { - new HttpProtocolTestGenerator( - context, - getProtocol(), - writer, - (shape, testCase) -> filterTests(testCase)).run(); - }); + context.writerDelegator() + .useFileWriter("./tests/test_restjson_protocol.py", "tests.test_restjson_protocol", writer -> { + new HttpProtocolTestGenerator( + context, + getProtocol(), + writer, + (shape, testCase) -> filterTests(testCase)).run(); + }); } private boolean filterTests(HttpMessageTestCase testCase) { diff --git a/codegen/protocol-test/smithy-build.json b/codegen/protocol-test/smithy-build.json index f5ec825f2..052eeca64 100644 --- a/codegen/protocol-test/smithy-build.json +++ b/codegen/protocol-test/smithy-build.json @@ -23,6 +23,50 @@ } } }, + "aws-json-1-0": { + "transforms": [ + { + "name": "includeServices", + "args": { + "services": [ + "aws.protocoltests.json10#JsonRpc10" + ] + } + }, + { + "name": "removeUnusedShapes" + } + ], + "plugins": { + "python-client-codegen": { + "service": "aws.protocoltests.json10#JsonRpc10", + "module": "awsjson10", + "moduleVersion": "0.0.1" + } + } + }, + "aws-json-1-1": { + "transforms": [ + { + "name": "includeServices", + "args": { + "services": [ + "aws.protocoltests.json#JsonProtocol" + ] + } + }, + { + "name": "removeUnusedShapes" + } + ], + "plugins": { + "python-client-codegen": { + "service": "aws.protocoltests.json#JsonProtocol", + "module": "awsjson11", + "moduleVersion": "0.0.1" + } + } + }, "aws-query": { "transforms": [ { From 59b9c4db8ab113f2eea635193128d75b74b4110b Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Wed, 13 May 2026 11:13:34 -0400 Subject: [PATCH 4/8] Update tests --- .../tests/unit/aio/test_protocols.py | 108 +++++++++++++----- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py index 8759bc19c..bcd3c84cf 100644 --- a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py +++ b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py @@ -6,6 +6,7 @@ from unittest.mock import Mock import pytest +from ijson.common import IncompleteJSONError # type: ignore[reportMissingTypeStubs] from smithy_aws_core.aio.protocols import ( AWSErrorIdentifier, AwsJson11ClientProtocol, @@ -210,7 +211,6 @@ def _aws_json11_protocol() -> AwsJson11ClientProtocol: ) -@pytest.mark.asyncio async def test_aws_json11_serializes_base_request_shape() -> None: protocol = _aws_json11_protocol() request = protocol.serialize_request( @@ -228,18 +228,52 @@ async def test_aws_json11_serializes_base_request_shape() -> None: assert await request.consume_body_async() == b"{}" -def test_aws_json_matches_content_type_with_parameters() -> None: +async def test_aws_json11_resolves_body_error_with_content_type_parameters() -> None: protocol = _aws_json11_protocol() response = HTTPResponse( status=500, fields=tuples_to_fields( [("content-type", "application/x-amz-json-1.1; charset=utf-8")] ), + body=b'{"__type":"com.test#OtherNsError"}', ) - assert getattr(protocol, "_matches_content_type")(response) + operation = _mock_operation(_operation_schema("FailingOperation")) + + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.test#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + +async def test_aws_json11_ignores_body_error_with_unexpected_content_type() -> None: + protocol = _aws_json11_protocol() + response = HTTPResponse( + status=500, + fields=tuples_to_fields([("content-type", "application/json")]), + body=b'{"__type":"com.test#OtherNsError"}', + ) + operation = _mock_operation(_operation_schema("FailingOperation")) + + with pytest.raises(CallError) as exc_info: + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.test#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + assert not isinstance(exc_info.value, ModeledError) -@pytest.mark.asyncio async def test_aws_json11_deserializes_empty_response_body() -> None: protocol = _aws_json11_protocol() operation = _mock_operation(_operation_schema("EmptyOperation")) @@ -256,13 +290,12 @@ async def test_aws_json11_deserializes_empty_response_body() -> None: assert isinstance(output, _EmptyOutput) -class _OtherNamespaceModeledError(ModeledError): +class _ModeledJSONError(ModeledError): @classmethod - def deserialize(cls, deserializer: Any) -> "_OtherNamespaceModeledError": - return cls("other namespace") + def deserialize(cls, deserializer: Any) -> "_ModeledJSONError": + return cls("modeled JSON error") -@pytest.mark.asyncio async def test_aws_json11_resolves_modeled_error_from_header_other_namespace() -> None: protocol = _aws_json11_protocol() operation = _mock_operation(_operation_schema("FailingOperation")) @@ -278,20 +311,18 @@ async def test_aws_json11_resolves_modeled_error_from_header_other_namespace() - body=b'{"__type":"com.other#OtherNsError"}', ) - error = await getattr(protocol, "_create_error")( - operation=operation, - response=response, - response_body=response.body, - error_registry=TypeRegistry( - {ShapeID("com.other#OtherNsError"): _OtherNamespaceModeledError} - ), - context=TypedProperties(), - ) - - assert isinstance(error, _OtherNamespaceModeledError) + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.other#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) -@pytest.mark.asyncio async def test_aws_json11_resolves_modeled_error_from_header_only_shapeid() -> None: protocol = _aws_json11_protocol() operation = _mock_operation(_operation_schema("FailingOperation")) @@ -302,20 +333,37 @@ async def test_aws_json11_resolves_modeled_error_from_header_only_shapeid() -> N body=b"", ) - error = await getattr(protocol, "_create_error")( - operation=operation, - response=response, - response_body=response.body, - error_registry=TypeRegistry( - {ShapeID("com.other#OtherNsError"): _OtherNamespaceModeledError} - ), - context=TypedProperties(), + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.other#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + +async def test_aws_json11_raises_parse_error_for_invalid_error_body() -> None: + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("FailingOperation")) + response = HTTPResponse( + status=400, + fields=tuples_to_fields([("content-type", "application/x-amz-json-1.1")]), + body=b'{"__type":', ) - assert isinstance(error, _OtherNamespaceModeledError) + with pytest.raises(IncompleteJSONError, match="premature EOF|parse error"): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry({}), + context=TypedProperties(), + ) -@pytest.mark.asyncio async def test_aws_query_serializes_base_request_shape() -> None: protocol = AwsQueryClientProtocol(_SERVICE_SCHEMA, "2020-01-08") request = protocol.serialize_request( From c67e2309f1298aa8c45203ec58ffeb64740dec5b Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Sun, 26 Jul 2026 19:23:46 -0400 Subject: [PATCH 5/8] smithy-aws-core: resolve awsJson errors by shape name across namespaces The awsJson protocols discriminate errors using only the shape name (the portion after '#'), so an error wire ID whose namespace differs from the modeled error shape must still resolve. Add namespace-fallback resolution in the awsJson _create_error path, matching the Smithy protocol test '*_foo_error_with_dunder_type_and_different_namespace'. --- .../src/smithy_aws_core/aio/protocols.py | 43 +++++++++++++- .../tests/unit/aio/test_protocols.py | 56 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py b/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py index 94c4334f9..5c3c4e0b5 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py @@ -361,10 +361,16 @@ async def _create_error( ): deserializer = self.payload_codec.create_deserializer(response_body) document = deserializer.read_document(schema=DOCUMENT) - if document.discriminator in error_registry: - error_id = document.discriminator + error_id = document.discriminator - if error_id is not None and error_id in error_registry: + if error_id is not None: + error_id = self._resolve_error_id( + operation=operation, + error_id=error_id, + error_registry=error_registry, + ) + + if error_id is not None: error_shape = error_registry.get(error_id) # make sure the error shape is derived from modeled exception @@ -405,6 +411,37 @@ def _matches_content_type(self, response: HTTPResponse) -> bool: actual = response.fields["content-type"].as_string() return actual.split(";", 1)[0].strip().lower() == self.content_type.lower() + def _resolve_error_id( + self, + *, + operation: APIOperation[Any, Any], + error_id: ShapeID, + error_registry: TypeRegistry, + ) -> ShapeID | None: + """Resolve a wire error ID against the modeled error registry. + + Error registries are keyed by modeled ShapeIDs. The awsJson protocols only + consider the shape name (the portion after ``#``) when discriminating errors, + so a fully-qualified wire error ID whose namespace differs from the modeled + shape is retried with the operation's namespace and the same shape name. + """ + if error_id in error_registry: + return error_id + + default_namespace = operation.schema.id.namespace + if error_id.namespace == default_namespace: + return None + + fallback_error_id = ShapeID.from_parts( + namespace=default_namespace, + name=error_id.name, + member=error_id.member, + ) + if fallback_error_id in error_registry: + return fallback_error_id + + return None + class AwsJson10ClientProtocol(_AWSJSONClientProtocol): """An implementation of the aws.protocols#awsJson1_0 protocol.""" diff --git a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py index bcd3c84cf..edf070176 100644 --- a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py +++ b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py @@ -345,6 +345,62 @@ async def test_aws_json11_resolves_modeled_error_from_header_only_shapeid() -> N ) +async def test_aws_json11_resolves_modeled_error_from_header_namespace_fallback() -> ( + None +): + # The wire error ID uses a different namespace than the modeled error. The + # awsJson protocols only match on shape name, so it should fall back to the + # operation's namespace and still resolve the modeled error. + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("FailingOperation")) + response = HTTPResponse( + status=400, + reason="Bad Request", + fields=tuples_to_fields( + [ + ("x-amzn-errortype", "com.wire#OtherNsError"), + ("content-type", "application/x-amz-json-1.1"), + ] + ), + body=b'{"__type":"com.wire#OtherNsError"}', + ) + + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.test#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + +async def test_aws_json11_resolves_modeled_error_from_body_namespace_fallback() -> None: + # Same as above, but the discriminator comes from the body's __type rather + # than the x-amzn-errortype header. + protocol = _aws_json11_protocol() + operation = _mock_operation(_operation_schema("FailingOperation")) + response = HTTPResponse( + status=400, + reason="Bad Request", + fields=tuples_to_fields([("content-type", "application/x-amz-json-1.1")]), + body=b'{"__type":"com.wire#OtherNsError"}', + ) + + with pytest.raises(_ModeledJSONError): + await protocol.deserialize_response( + operation=operation, + request=cast(HTTPRequest, Mock()), + response=response, + error_registry=TypeRegistry( + {ShapeID("com.test#OtherNsError"): _ModeledJSONError} + ), + context=TypedProperties(), + ) + + async def test_aws_json11_raises_parse_error_for_invalid_error_body() -> None: protocol = _aws_json11_protocol() operation = _mock_operation(_operation_schema("FailingOperation")) From 8a80ab129fb519cbdf842162257a9ded309887b4 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Mon, 27 Jul 2026 00:50:50 -0400 Subject: [PATCH 6/8] smithy-aws-core: align awsJson error resolution with restJson Match the error-by-shape-name resolution introduced for restJson in #742: _resolve_error_id matches wire error identifiers against the operation's modeled error schemas by shape name and returns the modeled ShapeID, and _create_error only consults it on a registry miss (guarding the final lookup with 'error_id in error_registry'). Replaces the earlier bespoke namespace-substitution approach so both JSON protocols behave identically. --- .../src/smithy_aws_core/aio/protocols.py | 48 ++++++------------- .../tests/unit/aio/test_protocols.py | 29 +++++++---- 2 files changed, 35 insertions(+), 42 deletions(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py b/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py index 5c3c4e0b5..075c05f85 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/aio/protocols.py @@ -353,6 +353,8 @@ async def _create_error( error_id = self.error_identifier.identify( operation=operation, response=response ) + if error_id is not None and error_id not in error_registry: + error_id = self._resolve_error_id(operation=operation, error_id=error_id) if ( error_id is None @@ -361,16 +363,16 @@ async def _create_error( ): deserializer = self.payload_codec.create_deserializer(response_body) document = deserializer.read_document(schema=DOCUMENT) - error_id = document.discriminator + document_error_id = document.discriminator + if document_error_id not in error_registry: + document_error_id = self._resolve_error_id( + operation=operation, error_id=document_error_id + ) - if error_id is not None: - error_id = self._resolve_error_id( - operation=operation, - error_id=error_id, - error_registry=error_registry, - ) + if document_error_id in error_registry: + error_id = document_error_id - if error_id is not None: + if error_id is not None and error_id in error_registry: error_shape = error_registry.get(error_id) # make sure the error shape is derived from modeled exception @@ -416,31 +418,11 @@ def _resolve_error_id( *, operation: APIOperation[Any, Any], error_id: ShapeID, - error_registry: TypeRegistry, - ) -> ShapeID | None: - """Resolve a wire error ID against the modeled error registry. - - Error registries are keyed by modeled ShapeIDs. The awsJson protocols only - consider the shape name (the portion after ``#``) when discriminating errors, - so a fully-qualified wire error ID whose namespace differs from the modeled - shape is retried with the operation's namespace and the same shape name. - """ - if error_id in error_registry: - return error_id - - default_namespace = operation.schema.id.namespace - if error_id.namespace == default_namespace: - return None - - fallback_error_id = ShapeID.from_parts( - namespace=default_namespace, - name=error_id.name, - member=error_id.member, - ) - if fallback_error_id in error_registry: - return fallback_error_id - - return None + ) -> ShapeID: + for error_schema in operation.error_schemas: + if error_schema.id.name == error_id.name: + return error_schema.id + return error_id class AwsJson10ClientProtocol(_AWSJSONClientProtocol): diff --git a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py index edf070176..dc901d9fe 100644 --- a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py +++ b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py @@ -345,14 +345,22 @@ async def test_aws_json11_resolves_modeled_error_from_header_only_shapeid() -> N ) -async def test_aws_json11_resolves_modeled_error_from_header_namespace_fallback() -> ( - None -): - # The wire error ID uses a different namespace than the modeled error. The - # awsJson protocols only match on shape name, so it should fall back to the - # operation's namespace and still resolve the modeled error. +_OTHER_NS_ERROR_SCHEMA = Schema.collection( + id=ShapeID("com.test#OtherNsError"), + traits=[Trait.new(id=ShapeID("smithy.api#error"), value="client")], + members={"message": {"target": STRING}}, +) + + +async def test_aws_json11_resolves_modeled_error_from_header_name_fallback() -> None: + # The wire error ID carries a different namespace than the modeled error. The + # awsJson protocols discriminate on shape name only, so it should resolve to the + # operation's modeled error by matching the shape name. protocol = _aws_json11_protocol() - operation = _mock_operation(_operation_schema("FailingOperation")) + operation = _mock_operation( + _operation_schema("FailingOperation"), + error_schemas=[_OTHER_NS_ERROR_SCHEMA], + ) response = HTTPResponse( status=400, reason="Bad Request", @@ -377,11 +385,14 @@ async def test_aws_json11_resolves_modeled_error_from_header_namespace_fallback( ) -async def test_aws_json11_resolves_modeled_error_from_body_namespace_fallback() -> None: +async def test_aws_json11_resolves_modeled_error_from_body_name_fallback() -> None: # Same as above, but the discriminator comes from the body's __type rather # than the x-amzn-errortype header. protocol = _aws_json11_protocol() - operation = _mock_operation(_operation_schema("FailingOperation")) + operation = _mock_operation( + _operation_schema("FailingOperation"), + error_schemas=[_OTHER_NS_ERROR_SCHEMA], + ) response = HTTPResponse( status=400, reason="Bad Request", From d6e903e9cb6f876f6d4f9657d87f6053dbc38e7f Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Mon, 27 Jul 2026 15:03:00 -0400 Subject: [PATCH 7/8] smithy-aws-core: mark pytest.raises match pattern as a raw string Silences ruff RUF043; the pattern intentionally uses regex alternation. --- packages/smithy-aws-core/tests/unit/aio/test_protocols.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py index dc901d9fe..eae263441 100644 --- a/packages/smithy-aws-core/tests/unit/aio/test_protocols.py +++ b/packages/smithy-aws-core/tests/unit/aio/test_protocols.py @@ -421,7 +421,7 @@ async def test_aws_json11_raises_parse_error_for_invalid_error_body() -> None: body=b'{"__type":', ) - with pytest.raises(IncompleteJSONError, match="premature EOF|parse error"): + with pytest.raises(IncompleteJSONError, match=r"premature EOF|parse error"): await protocol.deserialize_response( operation=operation, request=cast(HTTPRequest, Mock()), From db3d072527a8d42081cbbcf33c8f62073636db02 Mon Sep 17 00:00:00 2001 From: jonathan343 Date: Mon, 27 Jul 2026 15:03:01 -0400 Subject: [PATCH 8/8] smithy-core: import RetryStrategyOptions from its public module It's re-exported from smithy_core.aio.retries but pyright flags importing it from there as a private-import violation; import from smithy_core.retries where it's actually defined. --- packages/smithy-core/tests/unit/aio/test_retries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smithy-core/tests/unit/aio/test_retries.py b/packages/smithy-core/tests/unit/aio/test_retries.py index f35c50750..771236d43 100644 --- a/packages/smithy-core/tests/unit/aio/test_retries.py +++ b/packages/smithy-core/tests/unit/aio/test_retries.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import pytest from smithy_core.aio.retries import ( - RetryStrategyOptions, RetryStrategyResolver, SimpleRetryStrategy, StandardRetryStrategy, @@ -10,6 +9,7 @@ from smithy_core.exceptions import CallError, RetryError from smithy_core.retries import ( ExponentialRetryBackoffStrategy, + RetryStrategyOptions, )