diff --git a/packages/smithy-aws-core/src/smithy_aws_core/aio/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/aio/__init__.py index 33cbe867a..a33f07613 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/aio/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/aio/__init__.py @@ -1,2 +1,14 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +from smithy_core.shapes import ShapeID +from smithy_http.aio.interfaces import ErrorExtractor, HTTPResponse + + +class AmznErrorExtractor(ErrorExtractor): + """Attempts to extract the Amazon-specific 'X-Amzn-Errortype' error header from a + response.""" + + def get_error(self, response: HTTPResponse): + if "x-amzn-errortype" in response.fields: + val = response.fields["x-amzn-errortype"].values[0] + return ShapeID(val) 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 4f58b9d3e..632eff611 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 @@ -2,10 +2,12 @@ from smithy_core.codecs import Codec from smithy_core.shapes import ShapeID +from smithy_http.aio.interfaces import ErrorExtractor from smithy_http.aio.protocols import HttpBindingClientProtocol from smithy_json import JSONCodec from ..traits import RestJson1Trait +from . import AmznErrorExtractor class RestJsonClientProtocol(HttpBindingClientProtocol): @@ -13,7 +15,8 @@ class RestJsonClientProtocol(HttpBindingClientProtocol): _id: ShapeID = RestJson1Trait.id _codec: JSONCodec = JSONCodec() - _contentType: Final = "application/json" + _content_type: Final = "application/json" + _error_extractor: ErrorExtractor = AmznErrorExtractor() @property def id(self) -> ShapeID: @@ -25,4 +28,8 @@ def payload_codec(self) -> Codec: @property def content_type(self) -> str: - return self._contentType + return self._content_type + + @property + def error_extractor(self) -> ErrorExtractor: + return self._error_extractor diff --git a/packages/smithy-core/src/smithy_core/errors.py b/packages/smithy-core/src/smithy_core/errors.py new file mode 100644 index 000000000..5cecbddc1 --- /dev/null +++ b/packages/smithy-core/src/smithy_core/errors.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import Literal + +from smithy_core.deserializers import DeserializeableShape + +type Fault = Literal["client", "server", "other"] + + +@dataclass(kw_only=True, frozen=True) +class CallException(RuntimeError): + """The top-level exception that should be used to throw application-level errors + from clients and servers. + + This should be used in protocol error deserialization, throwing errors based on + protocol-hints, network errors, and shape validation errors. It should not be used + for illegal arguments, null argument validation, or other kinds of logic errors + sufficiently covered by the Java standard library. + """ + + fault: Fault = "other" + """The party that is at fault for the error, if any.""" + + message: str = "" + """The error message.""" + + # TODO: retry-ability and associated information (throttling, duration, etc.), perhaps 'Retryability' dataclass? + + +@dataclass(kw_only=True, frozen=True) +class ModeledException(CallException, DeserializeableShape): + """The top-level exception that should be used to throw modeled errors from clients + and servers.""" diff --git a/packages/smithy-http/src/smithy_http/aio/interfaces/__init__.py b/packages/smithy-http/src/smithy_http/aio/interfaces/__init__.py index c3aaa390b..53afaa83b 100644 --- a/packages/smithy-http/src/smithy_http/aio/interfaces/__init__.py +++ b/packages/smithy-http/src/smithy_http/aio/interfaces/__init__.py @@ -4,6 +4,7 @@ from smithy_core.aio.interfaces import ClientTransport, Request, Response from smithy_core.aio.utils import read_streaming_blob, read_streaming_blob_async +from smithy_core.shapes import ShapeID from ...interfaces import ( Fields, @@ -83,3 +84,18 @@ async def send( :param request_config: Configuration specific to this request. """ ... + + +class ErrorExtractor(Protocol): + """Extract error shape IDs from an HTTP response.""" + + def get_error( + self, + response: HTTPResponse, + ) -> ShapeID | None: + """Get the shape id for an error by using information (such as headers) from a + response. + + :param response: The response object to derive an error shape from. + """ + ... diff --git a/packages/smithy-http/src/smithy_http/aio/protocols.py b/packages/smithy-http/src/smithy_http/aio/protocols.py index 6d6d5d4f2..57c8002bc 100644 --- a/packages/smithy-http/src/smithy_http/aio/protocols.py +++ b/packages/smithy-http/src/smithy_http/aio/protocols.py @@ -12,8 +12,8 @@ from smithy_core.serializers import SerializeableShape from smithy_core.traits import EndpointTrait, HTTPTrait -from smithy_http.aio.interfaces import HTTPRequest, HTTPResponse -from smithy_http.deserializers import HTTPResponseDeserializer +from smithy_http.aio.interfaces import ErrorExtractor, HTTPRequest, HTTPResponse +from smithy_http.deserializers import HTTPErrorDeserializer, HTTPResponseDeserializer from smithy_http.serializers import HTTPRequestSerializer @@ -47,12 +47,17 @@ class HttpBindingClientProtocol(HttpClientProtocol): @property def payload_codec(self) -> Codec: """The codec used for the serde of input and output payloads.""" - raise NotImplementedError() + raise NotImplementedError @property def content_type(self) -> str: """The media type of the http payload.""" - raise NotImplementedError() + raise NotImplementedError + + @property + def error_extractor(self) -> ErrorExtractor: + """The error extractor used to extract errors from the response.""" + raise NotImplementedError def serialize_request[ OperationInput: "SerializeableShape", @@ -94,19 +99,25 @@ async def deserialize_response[ error_registry: TypeRegistry, context: TypedProperties, ) -> OperationOutput: - if not (200 <= response.status <= 299): - # TODO: implement error serde from type registry - raise NotImplementedError - - body = response.body - # if body is not streaming and is async, we have to buffer it + body = response.body if not operation.output_stream_member: if ( read := getattr(body, "read", None) ) is not None and iscoroutinefunction(read): body = BytesIO(await read()) + # handle error response + if not (200 <= response.status <= 299): + error_deserializer = HTTPErrorDeserializer( + payload_codec=self.payload_codec, + extractor=self.error_extractor, + response=response, + body=body, # type: ignore + ) + + raise error_deserializer.read_error(operation, error_registry, context) + # TODO(optimization): response binding cache like done in SJ deserializer = HTTPResponseDeserializer( payload_codec=self.payload_codec, diff --git a/packages/smithy-http/src/smithy_http/deserializers.py b/packages/smithy-http/src/smithy_http/deserializers.py index 6e60e1296..1983ebf84 100644 --- a/packages/smithy-http/src/smithy_http/deserializers.py +++ b/packages/smithy-http/src/smithy_http/deserializers.py @@ -3,13 +3,19 @@ import datetime from collections.abc import Callable from decimal import Decimal -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from smithy_core.codecs import Codec from smithy_core.deserializers import ShapeDeserializer, SpecificShapeDeserializer -from smithy_core.exceptions import UnsupportedStreamException -from smithy_core.interfaces import is_bytes_reader, is_streaming_blob -from smithy_core.schemas import Schema +from smithy_core.documents import TypeRegistry +from smithy_core.errors import CallException, ModeledException +from smithy_core.exceptions import ( + ExpectationNotMetException, + UnsupportedStreamException, +) +from smithy_core.interfaces import TypedProperties, is_bytes_reader, is_streaming_blob +from smithy_core.prelude import DOCUMENT +from smithy_core.schemas import APIOperation, Schema from smithy_core.shapes import ShapeType from smithy_core.traits import ( HTTPHeaderTrait, @@ -22,15 +28,15 @@ from smithy_core.types import TimestampFormat from smithy_core.utils import ensure_utc, strict_parse_bool, strict_parse_float -from .aio.interfaces import HTTPResponse -from .interfaces import Field, Fields +from smithy_http.aio.interfaces import ErrorExtractor, HTTPResponse +from smithy_http.interfaces import Field, Fields if TYPE_CHECKING: from smithy_core.aio.interfaces import StreamingBlob as AsyncStreamingBlob from smithy_core.interfaces import StreamingBlob as SyncStreamingBlob -__all__ = ["HTTPResponseDeserializer"] +__all__ = ["HTTPErrorDeserializer", "HTTPResponseDeserializer"] class HTTPResponseDeserializer(SpecificShapeDeserializer): @@ -257,3 +263,72 @@ def _consume_payload(self) -> bytes: "Unable to read async stream. This stream must be buffered prior " "to creating the deserializer." ) + + +class HTTPErrorDeserializer: + """Binds an error response to a modelled or unknown exception.""" + + def __init__( + self, + payload_codec: Codec, + extractor: ErrorExtractor, + response: HTTPResponse, + body: "SyncStreamingBlob", + ) -> None: + """Initialize an HTTPErrorDeserializer. + + :param payload_codec: The Codec to use to deserialize the payload, if present. + :param extractor: The error extractor to get error shape id from the response. + :param response: The HTTP response to read from. + :param body: The HTTP response body in a synchronously readable form. This is + necessary for async response bodies when there is no streaming member. + """ + self._payload_codec = payload_codec + self._response = response + self._body = body + self._extractor = extractor + self._codec = payload_codec + + def read_error( + self, + operation: APIOperation[Any, Any], + error_registry: TypeRegistry, + context: TypedProperties, + ) -> CallException: + body = self._body + if isinstance(body, bytearray): + body = bytes(body) + deserializer = self._payload_codec.create_deserializer(body) + document = deserializer.read_document(DOCUMENT) + + # try to get the error shape-id from the extractor + error_id = self._extractor.get_error(self._response) + + # if none, get it from the parsed document (e.g. '__type') + if error_id is None: + error_id = document.discriminator + + if error_id is not None: + error_shape = error_registry.get(error_id) + # make sure the error shape is derived from modeled exception + if not isinstance(error_shape, ModeledException): + raise ExpectationNotMetException( + f"Modeled errors must be derived from 'ModeledException', but got {error_shape}" + ) + + # return the deserialized error + return error_shape.deserialize(deserializer) + + # unknown error (no header, no type/unrecognized type) + fault = "other" + if 400 <= self._response.status < 500: + fault = "client" + elif self._response.status >= 500: + fault = "server" + message = ( + f"Unknown error: {operation.output_schema.id} " + f"- code: {self._response.status} " + f"- reason: {self._response.reason}" + ) + + return CallException(message=message, fault=fault) diff --git a/uv.lock b/uv.lock index d716ebc20..6856c99ed 100644 --- a/uv.lock +++ b/uv.lock @@ -686,6 +686,7 @@ dependencies = [ [package.optional-dependencies] aiohttp = [ { name = "aiohttp" }, + { name = "yarl" }, ] awscrt = [ { name = "awscrt" }, @@ -693,9 +694,10 @@ awscrt = [ [package.metadata] requires-dist = [ - { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.11.12" }, + { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.11.12,<4.0" }, { name = "awscrt", marker = "extra == 'awscrt'", specifier = ">=0.23.10" }, { name = "smithy-core", editable = "packages/smithy-core" }, + { name = "yarl", marker = "extra == 'aiohttp'" }, ] provides-extras = ["awscrt", "aiohttp"]