Skip to content

Commit 25e3292

Browse files
authored
Add support for httpx as backend (#1085)
1 parent e3c37ee commit 25e3292

21 files changed

+753
-62
lines changed

.github/workflows/ci-cd.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,24 @@ jobs:
4141
os:
4242
- ubuntu-24.04
4343
- ubuntu-24.04-arm
44+
no-httpx:
45+
- false
4446
experimental:
4547
- false
4648
include:
49+
- python-version: 3.13 # add a no-httpx run
50+
os: ubuntu-24.04
51+
no-httpx: true
52+
experimental: false
53+
# add experimental 3.14 runs. Move this to being a 3.14 python-version
54+
# entry when stable.
4755
- python-version: 3.14
4856
os: ubuntu-24.04
57+
no-httpx: false
4958
experimental: true
5059
- python-version: 3.14
5160
os: ubuntu-24.04-arm
61+
no-httpx: false
5262
experimental: true
5363
fail-fast: false
5464
uses: ./.github/workflows/reusable-test.yml
@@ -57,6 +67,7 @@ jobs:
5767
os: ${{ matrix.os }}
5868
continue-on-error: ${{ matrix.experimental }}
5969
enable-cache: ${{ github.ref_type == 'tag' && 'false' || 'auto' }}
70+
no-httpx: ${{ matrix.no-httpx }}
6071
secrets:
6172
codecov-token: ${{ secrets.CODECOV_TOKEN }}
6273

.github/workflows/reusable-test.yml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ on:
1717
enable-cache:
1818
type: string
1919
required: true
20+
no-httpx:
21+
type: boolean
22+
required: true
2023
secrets:
2124
codecov-token:
2225
required: false
@@ -26,7 +29,9 @@ env:
2629

2730
jobs:
2831
test:
29-
name: Test Python ${{ inputs.python-version }} on ${{ inputs.os }}
32+
name: Test Python ${{ inputs.python-version }} on ${{ inputs.os }}${{
33+
inputs.no-httpx && ' no-httpx' || '' }}
34+
3035
runs-on: ${{ inputs.os }}
3136
continue-on-error: ${{ inputs.continue-on-error }}
3237
env:
@@ -49,8 +54,16 @@ jobs:
4954
run: |
5055
uv run make pre-commit
5156
- name: Run unittests
57+
if: ${{ ! inputs.no-httpx }}
58+
env:
59+
COLOR: 'yes'
60+
run: |
61+
uv run --extra=httpx make mototest
62+
- name: Run unittests without httpx installed
63+
if: ${{ inputs.no-httpx }}
5264
env:
5365
COLOR: 'yes'
66+
HTTP_BACKEND: 'aiohttp'
5467
run: |
5568
uv run make mototest
5669
- name: Upload coverage to Codecov
@@ -60,7 +73,7 @@ jobs:
6073
token: ${{ secrets.codecov-token }} # not required for public repos
6174
files: ./coverage.xml
6275
# yamllint disable-line rule:line-length
63-
flags: unittests,os-${{ inputs.os }},python-${{ inputs.python-version }} # optional
76+
flags: unittests,os-${{ inputs.os }},python-${{ inputs.python-version }}${{ inputs.no-httpx && ',no-httpx' || '' }} # optional
6477
name: codecov-umbrella # optional
6578
fail_ci_if_error: true # optional (default = false)
6679
verbose: true # optional (default = false)

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Changes
1010
* patch ``_apply_request_trailer_checksum``, ``_get_provider_params`` and ``AIOWaiter.wait`` to add context feature information
1111
* patch ``AioSession._create_client`` to propagate context information. Created ``context.py`` to provide equivalent async context manager and async decorator functions
1212
* fixed test ``test_put_object_sha256`` as ``moto`` supports ``ChecksumSHA256``
13+
* Add experimental httpx support. The backend can be activated when creating a new session: ``session.create_client(..., config=AioConfig(http_session_cls=aiobotocore.httpxsession.HttpxSession))``. It's not fully tested and some features from aiohttp have not been ported, but feedback on what you're missing and bug reports are very welcome.
1314

1415
2.22.0 (2025-04-29)
1516
^^^^^^^^^^^^^^^^^^^

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Some simple testing tasks (sorry, UNIX only).
22

3-
# ?= conditional assign, so users can pass options on the CLI instead of manually editing this file
3+
# ?= is conditional assign, so users can pass options on the CLI instead of manually editing this file
4+
HTTP_BACKEND?='all'
45
FLAGS?=
56

67
pre-commit:
@@ -17,7 +18,7 @@ cov cover coverage: pre-commit
1718
@echo "open file://`pwd`/htmlcov/index.html"
1819

1920
mototest:
20-
python -Wd -X tracemalloc=5 -X faulthandler -m pytest -vv -m "not localonly" -n auto --cov-report term --cov-report html --cov-report xml --cov=aiobotocore --cov=tests --log-cli-level=DEBUG $(FLAGS) aiobotocore tests
21+
python -Wd -X tracemalloc=5 -X faulthandler -m pytest -vv -m "not localonly" -n auto --cov-report term --cov-report html --cov-report xml --cov=aiobotocore --cov=tests --log-cli-level=DEBUG --http-backend=$(HTTP_BACKEND) $(FLAGS) aiobotocore tests
2122

2223
clean:
2324
rm -rf `find . -name __pycache__`

aiobotocore/_endpoint_helpers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
import botocore.retryhandler
55
import wrapt
66

7+
try:
8+
import httpx
9+
except ImportError:
10+
httpx = None
11+
712
# Monkey patching: We need to insert the aiohttp exception equivalents
813
# The only other way to do this would be to have another config file :(
914
_aiohttp_retryable_exceptions = [
@@ -14,10 +19,26 @@
1419
asyncio.TimeoutError,
1520
]
1621

22+
1723
botocore.retryhandler.EXCEPTION_MAP['GENERAL_CONNECTION_ERROR'].extend(
1824
_aiohttp_retryable_exceptions
1925
)
2026

27+
if httpx is not None:
28+
# See https://www.python-httpx.org/exceptions/#the-exception-hierarchy
29+
# All child exceptions of TransportError, except ProxyError,
30+
# UnsupportedProtocol and CloseError.
31+
_httpx_retryable_exceptions = [
32+
httpx.TimeoutException,
33+
httpx.ProtocolError,
34+
httpx.ConnectError,
35+
httpx.ReadError,
36+
httpx.WriteError,
37+
]
38+
botocore.retryhandler.EXCEPTION_MAP['GENERAL_CONNECTION_ERROR'].extend(
39+
_httpx_retryable_exceptions
40+
)
41+
2142

2243
def _text(s, encoding='utf-8', errors='strict'):
2344
if isinstance(s, bytes):

aiobotocore/awsrequest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,14 @@ async def _text_prop(self):
2828
@property
2929
def text(self):
3030
return self._text_prop()
31+
32+
33+
class HttpxAWSResponse(AioAWSResponse):
34+
async def _content_prop(self):
35+
"""Content of the response as bytes."""
36+
37+
if self._content is None:
38+
# NOTE: this will cache the data in self.raw
39+
self._content = await self.raw.aread() or b''
40+
41+
return self._content

aiobotocore/config.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
from botocore.exceptions import ParamValidationError
55

66
from aiobotocore.endpoint import DEFAULT_HTTP_SESSION_CLS
7+
from aiobotocore.httpxsession import HttpxSession
8+
9+
# AWS has a 20 second idle timeout:
10+
# https://web.archive.org/web/20150926192339/https://forums.aws.amazon.com/message.jspa?messageID=215367
11+
# and aiohttp default timeout is 30s so we set it to something
12+
# reasonable here
13+
DEFAULT_KEEPALIVE_TIMEOUT = 12
14+
15+
TIMEOUT_ARGS = frozenset(
16+
('keepalive_timeout', 'write_timeout', 'pool_timeout')
17+
)
718

819

920
class AioConfig(botocore.client.Config):
@@ -15,18 +26,16 @@ def __init__(
1526
):
1627
super().__init__(**kwargs)
1728

18-
self._validate_connector_args(connector_args)
29+
self._validate_connector_args(connector_args, http_session_cls)
1930
self.connector_args = copy.copy(connector_args)
2031
self.http_session_cls = http_session_cls
2132
if not self.connector_args:
2233
self.connector_args = dict()
2334

2435
if 'keepalive_timeout' not in self.connector_args:
25-
# AWS has a 20 second idle timeout:
26-
# https://web.archive.org/web/20150926192339/https://forums.aws.amazon.com/message.jspa?messageID=215367
27-
# and aiohttp default timeout is 30s so we set it to something
28-
# reasonable here
29-
self.connector_args['keepalive_timeout'] = 12
36+
self.connector_args['keepalive_timeout'] = (
37+
DEFAULT_KEEPALIVE_TIMEOUT
38+
)
3039

3140
def merge(self, other_config):
3241
# Adapted from parent class
@@ -35,13 +44,17 @@ def merge(self, other_config):
3544
return AioConfig(self.connector_args, **config_options)
3645

3746
@staticmethod
38-
def _validate_connector_args(connector_args):
47+
def _validate_connector_args(connector_args, http_session_cls):
3948
if connector_args is None:
4049
return
4150

4251
for k, v in connector_args.items():
4352
# verify_ssl is handled by verify parameter to create_client
4453
if k == 'use_dns_cache':
54+
if http_session_cls is HttpxSession:
55+
raise ParamValidationError(
56+
report='Httpx does not support dns caching. https://github.com/encode/httpx/discussions/2211'
57+
)
4558
if not isinstance(v, bool):
4659
raise ParamValidationError(
4760
report=f'{k} value must be a boolean'
@@ -51,12 +64,16 @@ def _validate_connector_args(connector_args):
5164
raise ParamValidationError(
5265
report=f'{k} value must be an int or None'
5366
)
54-
elif k == 'keepalive_timeout':
67+
elif k in TIMEOUT_ARGS:
5568
if v is not None and not isinstance(v, (float, int)):
5669
raise ParamValidationError(
5770
report=f'{k} value must be a float/int or None'
5871
)
5972
elif k == 'force_close':
73+
if http_session_cls is HttpxSession:
74+
raise ParamValidationError(
75+
report=f'Httpx backend does not currently support {k}.'
76+
)
6077
if not isinstance(v, bool):
6178
raise ParamValidationError(
6279
report=f'{k} value must be a boolean'
@@ -72,6 +89,10 @@ def _validate_connector_args(connector_args):
7289
elif k == "resolver":
7390
from aiohttp.abc import AbstractResolver
7491

92+
if http_session_cls is HttpxSession:
93+
raise ParamValidationError(
94+
report=f'Httpx backend does not support {k}.'
95+
)
7596
if not isinstance(v, AbstractResolver):
7697
raise ParamValidationError(
7798
report=f'{k} must be an instance of a AbstractResolver'

aiobotocore/endpoint.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
from aiobotocore.httpchecksum import handle_checksum_body
1818
from aiobotocore.httpsession import AIOHTTPSession
1919
from aiobotocore.parsers import AioResponseParserFactory
20-
from aiobotocore.response import StreamingBody
20+
from aiobotocore.response import HttpxStreamingBody, StreamingBody
21+
22+
try:
23+
import httpx
24+
except ImportError:
25+
httpx = None
2126

2227
DEFAULT_HTTP_SESSION_CLS = AIOHTTPSession
2328

@@ -50,8 +55,11 @@ async def convert_to_response_dict(http_response, operation_model):
5055
elif operation_model.has_event_stream_output:
5156
response_dict['body'] = http_response.raw
5257
elif operation_model.has_streaming_output:
53-
length = response_dict['headers'].get('content-length')
54-
response_dict['body'] = StreamingBody(http_response.raw, length)
58+
if httpx and isinstance(http_response.raw, httpx.Response):
59+
response_dict['body'] = HttpxStreamingBody(http_response.raw)
60+
else:
61+
length = response_dict['headers'].get('content-length')
62+
response_dict['body'] = StreamingBody(http_response.raw, length)
5563
else:
5664
response_dict['body'] = await http_response.content
5765
return response_dict

aiobotocore/httpchecksum.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
)
1414

1515
from aiobotocore._helpers import resolve_awaitable
16-
from aiobotocore.response import StreamingBody
16+
from aiobotocore.response import HttpxStreamingBody, StreamingBody
17+
18+
try:
19+
import httpx
20+
except ImportError:
21+
httpx = None
1722

1823

1924
class AioAwsChunkedWrapper(AwsChunkedWrapper):
@@ -109,6 +114,50 @@ def _validate_checksum(self):
109114
raise FlexibleChecksumError(error_msg=error_msg)
110115

111116

117+
# TODO: fix inheritance? read & _validate_checksum are the exact same as above
118+
# only diff is super class and how to call __init__
119+
class HttpxStreamingChecksumBody(HttpxStreamingBody):
120+
def __init__(self, raw_stream, content_length, checksum, expected):
121+
# HttpxStreamingbody doesn't use content_length
122+
super().__init__(raw_stream)
123+
self._checksum = checksum
124+
self._expected = expected
125+
126+
# TODO: this class is largely (or possibly entirely) untested. The tests need to be
127+
# more thoroughly rewritten wherever they directly create Streamingbody,
128+
# StreamingChecksumBody, etc.
129+
130+
async def read(self, amt=None):
131+
chunk = await super().read(amt=amt)
132+
self._checksum.update(chunk)
133+
if amt is None or (not chunk and amt > 0):
134+
self._validate_checksum()
135+
return chunk
136+
137+
async def readinto(self, b: bytearray):
138+
chunk = await self.__wrapped__.content.read(len(b))
139+
amount_read = len(chunk)
140+
b[:amount_read] = chunk
141+
142+
if amount_read == len(b):
143+
view = b
144+
else:
145+
view = memoryview(b)[:amount_read]
146+
147+
self._checksum.update(view)
148+
if amount_read == 0 and len(b) > 0:
149+
self._validate_checksum()
150+
return amount_read
151+
152+
def _validate_checksum(self):
153+
if self._checksum.digest() != base64.b64decode(self._expected):
154+
error_msg = (
155+
f"Expected checksum {self._expected} did not match calculated "
156+
f"checksum: {self._checksum.b64digest()}"
157+
)
158+
raise FlexibleChecksumError(error_msg=error_msg)
159+
160+
112161
async def handle_checksum_body(
113162
http_response, response, context, operation_model
114163
):
@@ -155,7 +204,11 @@ async def handle_checksum_body(
155204
def _handle_streaming_response(http_response, response, algorithm):
156205
checksum_cls = _CHECKSUM_CLS.get(algorithm)
157206
header_name = f"x-amz-checksum-{algorithm}"
158-
return StreamingChecksumBody(
207+
if httpx is not None and isinstance(http_response.raw, httpx.Response):
208+
streaming_cls = HttpxStreamingChecksumBody
209+
else:
210+
streaming_cls = StreamingChecksumBody
211+
return streaming_cls(
159212
http_response.raw,
160213
response["headers"].get("content-length"),
161214
checksum_cls(),

aiobotocore/httpsession.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from multidict import CIMultiDict
4141

4242
import aiobotocore.awsrequest
43+
import aiobotocore.config
4344
from aiobotocore._endpoint_helpers import _IOBaseWrapper, _text
4445

4546

@@ -83,10 +84,9 @@ def __init__(
8384
self._timeout = timeout
8485
self._connector_args = connector_args
8586
if self._connector_args is None:
86-
# AWS has a 20 second idle timeout:
87-
# https://web.archive.org/web/20150926192339/https://forums.aws.amazon.com/message.jspa?messageID=215367
88-
# aiohttp default timeout is 30s so set something reasonable here
89-
self._connector_args = dict(keepalive_timeout=12)
87+
self._connector_args = dict(
88+
keepalive_timeout=aiobotocore.config.DEFAULT_KEEPALIVE_TIMEOUT
89+
)
9090

9191
self._max_pool_connections = max_pool_connections
9292
self._socket_options = socket_options

0 commit comments

Comments
 (0)