Skip to content

Commit efe0185

Browse files
committed
Add error serde for http-binding client protocols
1 parent a678b9b commit efe0185

File tree

6 files changed

+166
-18
lines changed

6 files changed

+166
-18
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,14 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
3+
from smithy_core.shapes import ShapeID
4+
from smithy_http.aio.interfaces import ErrorExtractor, HTTPResponse
5+
6+
7+
class AmznErrorExtractor(ErrorExtractor):
8+
"""Attempts to extract the Amazon-specific 'X-Amzn-Errortype' error header from a
9+
response."""
10+
11+
def get_error(self, response: HTTPResponse):
12+
if "x-amzn-errortype" in response.fields:
13+
val = response.fields["x-amzn-errortype"].values[0]
14+
return ShapeID(val)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from dataclasses import dataclass
2+
from typing import Literal
3+
4+
from smithy_core.deserializers import DeserializeableShape
5+
6+
type Fault = Literal["client", "server", "other"]
7+
8+
9+
@dataclass(kw_only=True, frozen=True)
10+
class CallException(RuntimeError):
11+
"""The top-level exception that should be used to throw application-level errors
12+
from clients and servers.
13+
14+
This should be used in protocol error deserialization, throwing errors based on
15+
protocol-hints, network errors, and shape validation errors. It should not be used
16+
for illegal arguments, null argument validation, or other kinds of logic errors
17+
sufficiently covered by the Java standard library.
18+
"""
19+
20+
fault: Fault = "other"
21+
"""The party that is at fault for the error, if any."""
22+
23+
message: str = ""
24+
"""The error message."""
25+
26+
# TODO: retry-ability and associated information (throttling, duration, etc.), perhaps 'Retryability' dataclass?
27+
28+
29+
@dataclass(kw_only=True, frozen=True)
30+
class ModeledException(CallException, DeserializeableShape):
31+
"""The top-level exception that should be used to throw modeled errors from clients
32+
and servers."""

packages/smithy-http/src/smithy_http/aio/interfaces/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from smithy_core.aio.interfaces import ClientTransport, Request, Response
66
from smithy_core.aio.utils import read_streaming_blob, read_streaming_blob_async
7+
from smithy_core.shapes import ShapeID
78

89
from ...interfaces import (
910
Fields,
@@ -83,3 +84,18 @@ async def send(
8384
:param request_config: Configuration specific to this request.
8485
"""
8586
...
87+
88+
89+
class ErrorExtractor(Protocol):
90+
"""Extract error shape IDs from an HTTP response."""
91+
92+
def get_error(
93+
self,
94+
response: HTTPResponse,
95+
) -> ShapeID | None:
96+
"""Get the shape id for an error by using information (such as headers) from a
97+
response.
98+
99+
:param response: The response object to derive an error shape from.
100+
"""
101+
...

packages/smithy-http/src/smithy_http/aio/protocols.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from smithy_core.serializers import SerializeableShape
1313
from smithy_core.traits import EndpointTrait, HTTPTrait
1414

15-
from smithy_http.aio.interfaces import HTTPRequest, HTTPResponse
16-
from smithy_http.deserializers import HTTPResponseDeserializer
15+
from smithy_http.aio.interfaces import ErrorExtractor, HTTPRequest, HTTPResponse
16+
from smithy_http.deserializers import HTTPErrorDeserializer, HTTPResponseDeserializer
1717
from smithy_http.serializers import HTTPRequestSerializer
1818

1919

@@ -47,12 +47,17 @@ class HttpBindingClientProtocol(HttpClientProtocol):
4747
@property
4848
def payload_codec(self) -> Codec:
4949
"""The codec used for the serde of input and output payloads."""
50-
raise NotImplementedError()
50+
raise NotImplementedError
5151

5252
@property
5353
def content_type(self) -> str:
5454
"""The media type of the http payload."""
55-
raise NotImplementedError()
55+
raise NotImplementedError
56+
57+
@property
58+
def error_extractor(self) -> ErrorExtractor:
59+
"""The error extractor used to extract errors from the response."""
60+
raise NotImplementedError
5661

5762
def serialize_request[
5863
OperationInput: "SerializeableShape",
@@ -94,19 +99,25 @@ async def deserialize_response[
9499
error_registry: TypeRegistry,
95100
context: TypedProperties,
96101
) -> OperationOutput:
97-
if not (200 <= response.status <= 299):
98-
# TODO: implement error serde from type registry
99-
raise NotImplementedError
100-
101-
body = response.body
102-
103102
# if body is not streaming and is async, we have to buffer it
103+
body = response.body
104104
if not operation.output_stream_member:
105105
if (
106106
read := getattr(body, "read", None)
107107
) is not None and iscoroutinefunction(read):
108108
body = BytesIO(await read())
109109

110+
# handle error response
111+
if not (200 <= response.status <= 299):
112+
error_deserializer = HTTPErrorDeserializer(
113+
payload_codec=self.payload_codec,
114+
extractor=self.error_extractor,
115+
response=response,
116+
body=body, # type: ignore
117+
)
118+
119+
raise error_deserializer.read_error(operation, error_registry, context)
120+
110121
# TODO(optimization): response binding cache like done in SJ
111122
deserializer = HTTPResponseDeserializer(
112123
payload_codec=self.payload_codec,

packages/smithy-http/src/smithy_http/deserializers.py

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
import datetime
44
from collections.abc import Callable
55
from decimal import Decimal
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Any
77

88
from smithy_core.codecs import Codec
99
from smithy_core.deserializers import ShapeDeserializer, SpecificShapeDeserializer
10-
from smithy_core.exceptions import UnsupportedStreamException
11-
from smithy_core.interfaces import is_bytes_reader, is_streaming_blob
12-
from smithy_core.schemas import Schema
10+
from smithy_core.documents import TypeRegistry
11+
from smithy_core.errors import CallException, ModeledException
12+
from smithy_core.exceptions import (
13+
ExpectationNotMetException,
14+
UnsupportedStreamException,
15+
)
16+
from smithy_core.interfaces import TypedProperties, is_bytes_reader, is_streaming_blob
17+
from smithy_core.prelude import DOCUMENT
18+
from smithy_core.schemas import APIOperation, Schema
1319
from smithy_core.shapes import ShapeType
1420
from smithy_core.traits import (
1521
HTTPHeaderTrait,
@@ -22,15 +28,15 @@
2228
from smithy_core.types import TimestampFormat
2329
from smithy_core.utils import ensure_utc, strict_parse_bool, strict_parse_float
2430

25-
from .aio.interfaces import HTTPResponse
26-
from .interfaces import Field, Fields
31+
from smithy_http.aio.interfaces import ErrorExtractor, HTTPResponse
32+
from smithy_http.interfaces import Field, Fields
2733

2834
if TYPE_CHECKING:
2935
from smithy_core.aio.interfaces import StreamingBlob as AsyncStreamingBlob
3036
from smithy_core.interfaces import StreamingBlob as SyncStreamingBlob
3137

3238

33-
__all__ = ["HTTPResponseDeserializer"]
39+
__all__ = ["HTTPErrorDeserializer", "HTTPResponseDeserializer"]
3440

3541

3642
class HTTPResponseDeserializer(SpecificShapeDeserializer):
@@ -257,3 +263,72 @@ def _consume_payload(self) -> bytes:
257263
"Unable to read async stream. This stream must be buffered prior "
258264
"to creating the deserializer."
259265
)
266+
267+
268+
class HTTPErrorDeserializer:
269+
"""Binds an error response to a modelled or unknown exception."""
270+
271+
def __init__(
272+
self,
273+
payload_codec: Codec,
274+
extractor: ErrorExtractor,
275+
response: HTTPResponse,
276+
body: "SyncStreamingBlob",
277+
) -> None:
278+
"""Initialize an HTTPErrorDeserializer.
279+
280+
:param payload_codec: The Codec to use to deserialize the payload, if present.
281+
:param extractor: The error extractor to get error shape id from the response.
282+
:param response: The HTTP response to read from.
283+
:param body: The HTTP response body in a synchronously readable form. This is
284+
necessary for async response bodies when there is no streaming member.
285+
"""
286+
self._payload_codec = payload_codec
287+
self._response = response
288+
self._body = body
289+
self._extractor = extractor
290+
self._codec = payload_codec
291+
292+
def read_error(
293+
self,
294+
operation: APIOperation[Any, Any],
295+
error_registry: TypeRegistry,
296+
context: TypedProperties,
297+
) -> CallException:
298+
body = self._body
299+
if isinstance(body, bytearray):
300+
body = bytes(body)
301+
deserializer = self._payload_codec.create_deserializer(body)
302+
document = deserializer.read_document(DOCUMENT)
303+
304+
# try to get the error shape-id from the extractor
305+
error_id = self._extractor.get_error(self._response)
306+
307+
# if none, get it from the parsed document (e.g. '__type')
308+
if error_id is None:
309+
error_id = document.discriminator
310+
311+
if error_id is not None:
312+
error_shape = error_registry.get(error_id)
313+
# make sure the error shape is derived from modeled exception
314+
if not isinstance(error_shape, ModeledException):
315+
raise ExpectationNotMetException(
316+
f"Modeled errors must be derived from 'ModeledException', but got {error_shape}"
317+
)
318+
319+
# return the deserialized error
320+
return error_shape.deserialize(deserializer)
321+
322+
# unknown error (no header, no type/unrecognized type)
323+
fault = "other"
324+
if 400 <= self._response.status < 500:
325+
fault = "client"
326+
elif self._response.status >= 500:
327+
fault = "server"
328+
message = (
329+
f"Unknown error: {operation.output_schema.id} "
330+
f"- code: {self._response.status} "
331+
f"- reason: {self._response.reason}"
332+
)
333+
334+
return CallException(message=message, fault=fault)

uv.lock

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)