diff --git a/packages/smithy-http/.changes/next-release/smithy-http-bugfix-4d75836f71714fef8792632f0de556de.json b/packages/smithy-http/.changes/next-release/smithy-http-bugfix-4d75836f71714fef8792632f0de556de.json new file mode 100644 index 000000000..950c4ab6e --- /dev/null +++ b/packages/smithy-http/.changes/next-release/smithy-http-bugfix-4d75836f71714fef8792632f0de556de.json @@ -0,0 +1,4 @@ +{ + "type": "bugfix", + "description": "Fixed `AIOHTTPClient` sending omitted request payloads with chunked transfer encoding." +} diff --git a/packages/smithy-http/src/smithy_http/aio/aiohttp.py b/packages/smithy-http/src/smithy_http/aio/aiohttp.py index 5a330931d..c5f08a1b4 100644 --- a/packages/smithy-http/src/smithy_http/aio/aiohttp.py +++ b/packages/smithy-http/src/smithy_http/aio/aiohttp.py @@ -90,8 +90,13 @@ async def send( chain.from_iterable(fld.as_tuples() for fld in request.fields) ) - body: StreamingBlob = request.body - if not isinstance(body, AsyncBytesReader): + body: StreamingBlob | None = request.body + if ( + "content-length" not in request.fields + and "transfer-encoding" not in request.fields + ): + body = await self._prepare_body(body) + elif not isinstance(body, AsyncBytesReader): body = AsyncBytesReader(body) # The typing on `params` is incorrect, it'll happily accept a mapping whose @@ -106,6 +111,19 @@ async def send( ) as resp: return await self._marshal_response(resp) + async def _prepare_body(self, body: StreamingBlob) -> AsyncBytesReader | None: + """Convert a body for aiohttp, omitting seekable bodies with no data.""" + if not isinstance(body, AsyncBytesReader): + body = AsyncBytesReader(body) + + if not body.seekable(): + return body + + position = await body.seek(0, 1) + end = await body.seek(0, 2) + await body.seek(position) + return None if position == end else body + def _serialize_uri_without_query(self, uri: URI) -> yarl.URL: """Serialize all parts of the URI up to and including the path.""" return yarl.URL.build( diff --git a/packages/smithy-http/tests/unit/aio/test_aiohttp.py b/packages/smithy-http/tests/unit/aio/test_aiohttp.py new file mode 100644 index 000000000..4f4548faf --- /dev/null +++ b/packages/smithy-http/tests/unit/aio/test_aiohttp.py @@ -0,0 +1,80 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# pyright: reportPrivateUsage=false +from collections.abc import AsyncIterator +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock + +from smithy_core import URI +from smithy_core.aio.types import AsyncBytesReader +from smithy_http import Field, Fields +from smithy_http.aio import HTTPRequest +from smithy_http.aio.aiohttp import AIOHTTPClient + + +def _create_client() -> tuple[AIOHTTPClient, MagicMock]: + response = MagicMock(status=200, headers={}, reason="OK") + response.read = AsyncMock(return_value=b"") + + session = MagicMock() + session.request.return_value.__aenter__ = AsyncMock(return_value=response) + session.request.return_value.__aexit__ = AsyncMock(return_value=None) + return AIOHTTPClient(_session=cast(Any, session)), session + + +async def test_send_omits_empty_async_reader_body() -> None: + client, session = _create_client() + request = HTTPRequest( + method="GET", + destination=URI(scheme="https", host="example.com", path="/"), + body=AsyncBytesReader(b""), + fields=Fields(), + ) + + await client.send(request) + + assert session.request.call_args.kwargs["data"] is None + + +async def test_send_preserves_explicitly_framed_empty_body() -> None: + client, session = _create_client() + body = AsyncBytesReader(b"") + request = HTTPRequest( + method="GET", + destination=URI(scheme="https", host="example.com", path="/"), + body=body, + fields=Fields([Field(name="content-length", values=["0"])]), + ) + + await client.send(request) + + assert session.request.call_args.kwargs["data"] is body + + +async def test_prepare_body_preserves_nonempty_reader_position() -> None: + client, _ = _create_client() + body = AsyncBytesReader(b"request body") + assert await body.read(4) == b"requ" + + prepared = await client._prepare_body(body) + + assert prepared is body + assert await body.read() == b"est body" + + +async def test_prepare_body_does_not_consume_nonseekable_body() -> None: + started = False + + async def body() -> AsyncIterator[bytes]: + nonlocal started + started = True + yield b"request body" + + client, _ = _create_client() + request_body = body() + + prepared = await client._prepare_body(request_body) + + assert started is False + assert prepared is not None + assert await prepared.read() == b"request body"