smithy-http 0.4.3__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. smithy_http-0.5.0/.changes/0.4.4.json +16 -0
  2. smithy_http-0.5.0/.changes/0.5.0.json +20 -0
  3. {smithy_http-0.4.3 → smithy_http-0.5.0}/CHANGELOG.md +17 -0
  4. {smithy_http-0.4.3 → smithy_http-0.5.0}/PKG-INFO +2 -2
  5. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/__init__.py +1 -1
  6. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/aiohttp.py +58 -15
  7. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/crt.py +32 -1
  8. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/protocols.py +11 -1
  9. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/serializers.py +6 -2
  10. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/testing/mockhttp.py +5 -0
  11. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.0.1.json +0 -0
  12. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.1.0.json +0 -0
  13. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.2.0.json +0 -0
  14. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.2.1.json +0 -0
  15. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.3.0.json +0 -0
  16. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.3.1.json +0 -0
  17. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.4.0.json +0 -0
  18. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.4.1.json +0 -0
  19. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.4.2.json +0 -0
  20. {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.4.3.json +0 -0
  21. {smithy_http-0.4.3 → smithy_http-0.5.0}/.gitignore +0 -0
  22. {smithy_http-0.4.3 → smithy_http-0.5.0}/NOTICE +0 -0
  23. {smithy_http-0.4.3 → smithy_http-0.5.0}/README.md +0 -0
  24. {smithy_http-0.4.3 → smithy_http-0.5.0}/pyproject.toml +0 -0
  25. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/__init__.py +0 -0
  26. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/auth/__init__.py +0 -0
  27. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/auth/apikey.py +0 -0
  28. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/identity/__init__.py +0 -0
  29. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/identity/apikey.py +0 -0
  30. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/interfaces/__init__.py +0 -0
  31. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/restjson.py +0 -0
  32. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/bindings.py +0 -0
  33. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/deserializers.py +0 -0
  34. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/endpoints.py +0 -0
  35. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/exceptions.py +0 -0
  36. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/interceptors/__init__.py +0 -0
  37. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/interceptors/user_agent.py +0 -0
  38. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/interfaces/__init__.py +0 -0
  39. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/plugins/__init__.py +0 -0
  40. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/py.typed +0 -0
  41. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/restjson.py +0 -0
  42. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/testing/__init__.py +0 -0
  43. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/testing/utils.py +0 -0
  44. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/user_agent.py +0 -0
  45. {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/utils.py +0 -0
@@ -0,0 +1,16 @@
1
+ {
2
+ "changes": [
3
+ {
4
+ "type": "enhancement",
5
+ "description": "Added a protocol hook for extracting server-provided retry delays from error responses."
6
+ },
7
+ {
8
+ "type": "enhancement",
9
+ "description": "Declared `AWSCRTHTTPClient` as duplex (bidirectional) streaming capable via `SUPPORTS_DUPLEX_STREAMING`. `AIOHTTPClient` explicitly does not support duplex streaming."
10
+ },
11
+ {
12
+ "type": "enhancement",
13
+ "description": "Disabled automatic redirects in `AIOHTTPClient` so that redirect responses are returned to the caller instead of being followed transparently."
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "changes": [
3
+ {
4
+ "type": "feature",
5
+ "description": "Added deterministic connection-pool cleanup and async context manager support to HTTP clients."
6
+ },
7
+ {
8
+ "type": "bugfix",
9
+ "description": "Stream aiohttp response bodies incrementally to enable true output event streams."
10
+ },
11
+ {
12
+ "type": "bugfix",
13
+ "description": "Fixed `AIOHTTPClient` to stop dropping valueless query parameters and automatically adding a `Content-Type` to bodyless POST, PUT, and PATCH requests."
14
+ },
15
+ {
16
+ "type": "bugfix",
17
+ "description": "Fixed duplicate Content-Type headers when a member is bound to Content-Type."
18
+ }
19
+ ]
20
+ }
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.0
4
+
5
+ ### Features
6
+ * Added deterministic connection-pool cleanup and async context manager support to HTTP clients.
7
+
8
+ ### Bug fixes
9
+ * Stream aiohttp response bodies incrementally to enable true output event streams.
10
+ * Fixed `AIOHTTPClient` to stop dropping valueless query parameters and automatically adding a `Content-Type` to bodyless POST, PUT, and PATCH requests.
11
+ * Fixed duplicate Content-Type headers when a member is bound to Content-Type.
12
+
13
+ ## v0.4.4
14
+
15
+ ### Enhancements
16
+ * Added a protocol hook for extracting server-provided retry delays from error responses.
17
+ * Declared `AWSCRTHTTPClient` as duplex (bidirectional) streaming capable via `SUPPORTS_DUPLEX_STREAMING`. `AIOHTTPClient` explicitly does not support duplex streaming.
18
+ * Disabled automatic redirects in `AIOHTTPClient` so that redirect responses are returned to the caller instead of being followed transparently.
19
+
3
20
  ## v0.4.3
4
21
 
5
22
  ### Enhancements
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: smithy-http
3
- Version: 0.4.3
3
+ Version: 0.5.0
4
4
  Summary: HTTP components for Smithy tooling.
5
5
  Project-URL: Changelog, https://github.com/smithy-lang/smithy-python/blob/develop/packages/smithy-http/CHANGELOG.md
6
6
  Project-URL: Code, https://github.com/smithy-lang/smithy-python/blob/develop/packages/smithy-http/
@@ -7,7 +7,7 @@ from typing import get_args
7
7
  from . import interfaces
8
8
  from .interfaces import FieldPosition
9
9
 
10
- __version__ = "0.4.3"
10
+ __version__ = "0.5.0"
11
11
 
12
12
  _VALID_FIELD_POSITIONS = frozenset(get_args(FieldPosition))
13
13
 
@@ -1,9 +1,9 @@
1
1
  # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  # SPDX-License-Identifier: Apache-2.0
3
+ from collections.abc import AsyncIterable, AsyncIterator
3
4
  from copy import copy, deepcopy
4
5
  from itertools import chain
5
- from typing import TYPE_CHECKING, Any
6
- from urllib.parse import parse_qs
6
+ from typing import TYPE_CHECKING, Any, Self
7
7
 
8
8
  import yarl
9
9
 
@@ -22,11 +22,11 @@ except ImportError:
22
22
 
23
23
  from smithy_core.aio.interfaces import StreamingBlob
24
24
  from smithy_core.aio.types import AsyncBytesReader
25
- from smithy_core.aio.utils import async_list
26
25
  from smithy_core.exceptions import MissingDependencyError
27
26
  from smithy_core.interfaces import URI
28
27
 
29
28
  from .. import Field, Fields
29
+ from ..exceptions import SmithyHTTPError
30
30
  from ..interfaces import (
31
31
  HTTPClientConfiguration,
32
32
  HTTPRequestConfiguration,
@@ -48,11 +48,30 @@ class AIOHTTPClientConfig(HTTPClientConfiguration):
48
48
  _assert_aiohttp()
49
49
 
50
50
 
51
+ class _AIOHTTPStreamingBody(AsyncIterable[bytes]):
52
+ """Streams a response body, releasing the response once it is done."""
53
+
54
+ def __init__(self, response: "aiohttp.ClientResponse") -> None:
55
+ self._response = response
56
+
57
+ def __aiter__(self) -> AsyncIterator[bytes]:
58
+ # The reader iterates by line, so use iter_any to get raw chunks.
59
+ return self._response.content.iter_any()
60
+
61
+ async def close(self) -> None:
62
+ # Pools the connection if the body was read to completion, closes it if not.
63
+ self._response.release()
64
+
65
+
51
66
  class AIOHTTPClient(HTTPClient):
52
67
  """Implementation of :py:class:`.interfaces.HTTPClient` using aiohttp."""
53
68
 
54
69
  TIMEOUT_EXCEPTIONS = (TimeoutError,)
55
70
 
71
+ # aiohttp has no HTTP/2 support, so it can never interleave request and
72
+ # response data.
73
+ SUPPORTS_DUPLEX_STREAMING = False
74
+
56
75
  def __init__(
57
76
  self,
58
77
  *,
@@ -65,6 +84,7 @@ class AIOHTTPClient(HTTPClient):
65
84
  """
66
85
  _assert_aiohttp()
67
86
  self._config = client_config or AIOHTTPClientConfig()
87
+ self._closed = False
68
88
  # Disable transparent response decompression and advertise
69
89
  # 'identity' to request uncompressed responses.
70
90
  # TODO: add a functional test once the test client framework exists
@@ -84,6 +104,11 @@ class AIOHTTPClient(HTTPClient):
84
104
  :param request: The request including destination URI, fields, payload.
85
105
  :param request_config: Configuration specific to this request.
86
106
  """
107
+ if self._closed:
108
+ raise SmithyHTTPError(
109
+ "Cannot send a request after the HTTP client has been closed."
110
+ )
111
+
87
112
  request_config = request_config or HTTPRequestConfiguration()
88
113
 
89
114
  headers_list = list(
@@ -99,17 +124,34 @@ class AIOHTTPClient(HTTPClient):
99
124
  elif not isinstance(body, AsyncBytesReader):
100
125
  body = AsyncBytesReader(body)
101
126
 
102
- # The typing on `params` is incorrect, it'll happily accept a mapping whose
103
- # values are lists (or tuples) and produce expected values.
104
- # See: https://github.com/aio-libs/aiohttp/issues/8563
105
- async with self._session.request(
127
+ resp = await self._session.request(
106
128
  method=request.method,
107
- url=self._serialize_uri_without_query(request.destination),
108
- params=parse_qs(request.destination.query), # type: ignore
129
+ url=self._serialize_uri(request.destination),
109
130
  headers=headers_list,
110
131
  data=body,
111
- ) as resp:
112
- return await self._marshal_response(resp)
132
+ allow_redirects=False,
133
+ skip_auto_headers={"Content-Type"},
134
+ )
135
+ try:
136
+ return self._marshal_response(resp)
137
+ except BaseException:
138
+ resp.release()
139
+ raise
140
+
141
+ async def close(self) -> None:
142
+ """Close the underlying aiohttp session and its connection pool."""
143
+ if self._closed:
144
+ return
145
+ self._closed = True
146
+ await self._session.close()
147
+
148
+ async def __aenter__(self) -> Self:
149
+ if self._closed:
150
+ raise SmithyHTTPError("Cannot enter an HTTP client that has been closed.")
151
+ return self
152
+
153
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
154
+ await self.close()
113
155
 
114
156
  async def _prepare_body(self, body: StreamingBlob) -> AsyncBytesReader | None:
115
157
  """Convert a body for aiohttp, omitting seekable bodies with no data."""
@@ -124,8 +166,8 @@ class AIOHTTPClient(HTTPClient):
124
166
  await body.seek(position)
125
167
  return None if position == end else body
126
168
 
127
- def _serialize_uri_without_query(self, uri: URI) -> yarl.URL:
128
- """Serialize all parts of the URI up to and including the path."""
169
+ def _serialize_uri(self, uri: URI) -> yarl.URL:
170
+ """Serialize the URI, preserving the already-encoded query string as-is."""
129
171
  return yarl.URL.build(
130
172
  scheme=uri.scheme or "",
131
173
  host=uri.host,
@@ -133,10 +175,11 @@ class AIOHTTPClient(HTTPClient):
133
175
  user=uri.username,
134
176
  password=uri.password,
135
177
  path=uri.path or "",
178
+ query_string=uri.query or "",
136
179
  encoded=True,
137
180
  )
138
181
 
139
- async def _marshal_response(
182
+ def _marshal_response(
140
183
  self, aiohttp_resp: "aiohttp.ClientResponse"
141
184
  ) -> HTTPResponseInterface:
142
185
  """Convert a ``aiohttp.ClientResponse`` to a ``smithy_http.aio.HTTPResponse``"""
@@ -154,7 +197,7 @@ class AIOHTTPClient(HTTPClient):
154
197
  return HTTPResponse(
155
198
  status=aiohttp_resp.status,
156
199
  fields=headers,
157
- body=async_list([await aiohttp_resp.read()]),
200
+ body=_AIOHTTPStreamingBody(aiohttp_resp),
158
201
  reason=aiohttp_resp.reason,
159
202
  )
160
203
 
@@ -1,12 +1,13 @@
1
1
  # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  # pyright: reportMissingTypeStubs=false,reportUnknownMemberType=false
4
+ from asyncio import gather
4
5
  from collections.abc import AsyncGenerator, AsyncIterable
5
6
  from copy import deepcopy
6
7
  from dataclasses import dataclass
7
8
  from inspect import iscoroutinefunction
8
9
  from io import BytesIO
9
- from typing import TYPE_CHECKING, Any
10
+ from typing import TYPE_CHECKING, Any, Self
10
11
 
11
12
  from awscrt.exceptions import AwsCrtError
12
13
 
@@ -142,6 +143,10 @@ class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
142
143
 
143
144
  TIMEOUT_EXCEPTIONS = (_CRTTimeoutError,)
144
145
 
146
+ # CRT can stream request and response data concurrently when the connection
147
+ # negotiates HTTP/2.
148
+ SUPPORTS_DUPLEX_STREAMING = True
149
+
145
150
  def __init__(
146
151
  self,
147
152
  eventloop: _AWSCRTEventLoop | None = None,
@@ -160,6 +165,7 @@ class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
160
165
  self._tls_ctx = crt_io.ClientTlsContext(crt_io.TlsContextOptions())
161
166
  self._socket_options = crt_io.SocketOptions()
162
167
  self._connections: ConnectionPoolDict = {}
168
+ self._closed = False
163
169
 
164
170
  async def send(
165
171
  self,
@@ -172,6 +178,11 @@ class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
172
178
  :param request: The request including destination URI, fields, payload.
173
179
  :param request_config: Configuration specific to this request.
174
180
  """
181
+ if self._closed:
182
+ raise SmithyHTTPError(
183
+ "Cannot send a request after the HTTP client has been closed."
184
+ )
185
+
175
186
  try:
176
187
  crt_request = self._marshal_request(request)
177
188
  connection = await self._get_connection(request.destination)
@@ -195,6 +206,26 @@ class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
195
206
  raise _CRTTimeoutError(f"CRT {e.name}: {e.message}") from e
196
207
  raise
197
208
 
209
+ async def close(self) -> None:
210
+ """Close all pooled HTTP connections."""
211
+ if self._closed:
212
+ return
213
+ self._closed = True
214
+ connections = tuple(self._connections.values())
215
+ self._connections.clear()
216
+ await gather(
217
+ *(connection.close() for connection in connections),
218
+ return_exceptions=True,
219
+ )
220
+
221
+ async def __aenter__(self) -> Self:
222
+ if self._closed:
223
+ raise SmithyHTTPError("Cannot enter an HTTP client that has been closed.")
224
+ return self
225
+
226
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
227
+ await self.close()
228
+
198
229
  async def _await_response(
199
230
  self, stream: "AIOHttpClientStreamUnified"
200
231
  ) -> AWSCRTHTTPResponse:
@@ -192,6 +192,8 @@ class HttpBindingClientProtocol(HttpClientProtocol):
192
192
  error_id=error_id,
193
193
  )
194
194
 
195
+ retry_after = self._retry_after(response)
196
+
195
197
  if error_id is None and self._matches_content_type(response):
196
198
  if isinstance(response_body, bytearray):
197
199
  response_body = bytes(response_body)
@@ -225,7 +227,10 @@ class HttpBindingClientProtocol(HttpClientProtocol):
225
227
  response=response,
226
228
  body=response_body,
227
229
  )
228
- return error_shape.deserialize(deserializer)
230
+ modeled_error = error_shape.deserialize(deserializer)
231
+ if retry_after is not None:
232
+ modeled_error.retry_after = retry_after
233
+ return modeled_error
229
234
 
230
235
  message = (
231
236
  f"Unknown error for operation {operation.schema.id} "
@@ -246,8 +251,13 @@ class HttpBindingClientProtocol(HttpClientProtocol):
246
251
  is_throttling_error=is_throttle,
247
252
  is_timeout_error=is_timeout,
248
253
  is_retry_safe=is_throttle or is_timeout or None,
254
+ retry_after=retry_after,
249
255
  )
250
256
 
257
+ def _retry_after(self, response: HTTPResponse) -> float | None:
258
+ """The retry delay in seconds requested by the server, if the response carries one."""
259
+ return None
260
+
251
261
  def _resolve_error_id(
252
262
  self,
253
263
  *,
@@ -170,7 +170,9 @@ class HTTPRequestSerializer(SpecificShapeSerializer):
170
170
  payload = AsyncBytesReader(sync_payload)
171
171
 
172
172
  headers = binding_serializer.header_serializer.headers
173
- if content_type is not None:
173
+ if content_type is not None and not any(
174
+ name.lower() == "content-type" for name, _ in headers
175
+ ):
174
176
  headers.append(("content-type", content_type))
175
177
 
176
178
  if content_length is not None:
@@ -358,7 +360,9 @@ class HTTPResponseSerializer(SpecificShapeSerializer):
358
360
  payload = AsyncBytesReader(sync_payload)
359
361
 
360
362
  headers = binding_serializer.header_serializer.headers
361
- if content_type is not None:
363
+ if content_type is not None and not any(
364
+ name.lower() == "content-type" for name, _ in headers
365
+ ):
362
366
  headers.append(("content-type", content_type))
363
367
 
364
368
  if content_length is not None:
@@ -22,6 +22,11 @@ class MockHTTPClient(HTTPClient):
22
22
 
23
23
  TIMEOUT_EXCEPTIONS = (TimeoutError,)
24
24
 
25
+ # Declared so duplex (bidirectional) stream operations can be unit tested
26
+ # against this client. Queued responses are returned without consuming the
27
+ # request body, which is compatible with duplex streaming.
28
+ SUPPORTS_DUPLEX_STREAMING = True
29
+
25
30
  def __init__(
26
31
  self,
27
32
  *,
File without changes
File without changes
File without changes
File without changes