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.
- smithy_http-0.5.0/.changes/0.4.4.json +16 -0
- smithy_http-0.5.0/.changes/0.5.0.json +20 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/CHANGELOG.md +17 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/PKG-INFO +2 -2
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/__init__.py +1 -1
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/aiohttp.py +58 -15
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/crt.py +32 -1
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/protocols.py +11 -1
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/serializers.py +6 -2
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/testing/mockhttp.py +5 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.0.1.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.1.0.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.2.0.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.2.1.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.3.0.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.3.1.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.4.0.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.4.1.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.4.2.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.changes/0.4.3.json +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/.gitignore +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/NOTICE +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/README.md +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/pyproject.toml +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/__init__.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/auth/__init__.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/auth/apikey.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/identity/__init__.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/identity/apikey.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/interfaces/__init__.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/aio/restjson.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/bindings.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/deserializers.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/endpoints.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/exceptions.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/interceptors/__init__.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/interceptors/user_agent.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/interfaces/__init__.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/plugins/__init__.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/py.typed +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/restjson.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/testing/__init__.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/testing/utils.py +0 -0
- {smithy_http-0.4.3 → smithy_http-0.5.0}/src/smithy_http/user_agent.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: smithy-http
|
|
3
|
-
Version: 0.
|
|
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/
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
128
|
-
"""Serialize
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|