Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for httpx as backend #1085

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1eb6888
Replace aiohttp with httpx
jakkdl Feb 1, 2024
d10761c
WIP of full replacement of aiohttp with httpx
jakkdl Feb 9, 2024
3608872
mostly finished WIP of adding httpx support
jakkdl Feb 19, 2024
757819f
fix various test failures
jakkdl Feb 19, 2024
4327b58
fix more parametrization issues
jakkdl Feb 19, 2024
e123adc
fix typo current_http_stack -> current_http_backend
jakkdl Feb 19, 2024
d029aa4
Add pytest flag for specifying backend when running tests.
jakkdl Feb 20, 2024
8f6bd69
add initial retryable exceptions. _validate_connector_args will now g…
jakkdl Feb 20, 2024
bdc680c
yes
jakkdl Feb 20, 2024
48b7310
append coverage when running tests twice
jakkdl Mar 1, 2024
b19bc09
Merge branch 'master' into httpx
jakkdl Mar 1, 2024
3e38c06
fix normalization of key paths in urls, revert test
jakkdl Apr 3, 2024
3e29d0d
shuffle around code wrt retryable exception to be less confusing
jakkdl Apr 3, 2024
af293b0
Merge remote-tracking branch 'origin/master' into httpx
jakkdl Apr 3, 2024
4482464
Merge remote-tracking branch 'origin/master' into httpx
jakkdl Oct 19, 2024
9dff5be
fix failed merge
jakkdl Oct 19, 2024
746d6b1
ruamel/yaml release pulled, so minor commit to retrigger CI
jakkdl Oct 19, 2024
82f2bc8
pre-merge dir rename
jakkdl Feb 27, 2025
550b660
Merge remote-tracking branch 'origin/master' into httpx
jakkdl Feb 27, 2025
2fd7f29
first fixes after reviews
jakkdl Feb 28, 2025
d1c0d12
fix no-httpx ci run
jakkdl Feb 28, 2025
9462503
add HttpxStreamingBody to reduce test changes
jakkdl Feb 28, 2025
c5d4592
blah
jakkdl Feb 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ jobs:
COLOR: 'yes'
run: |
make mototest
- name: Run unittests without httpx
if: matrix.python-version == '3.11'
env:
COLOR: 'yes'
run: |
pip uninstall --yes httpx
HTTP_BACKEND='aiohttp' FLAGS='--cov-append' make mototest
- name: Upload coverage to Codecov
if: matrix.python-version == '3.11'
uses: codecov/[email protected]
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ For example, using *virtualenvwrapper* commands could look like::

After that, please install libraries required for development::

$ pip install pip-tools
$ pip-compile requirements-dev.in
$ pip-sync requirements-dev.txt

Expand Down
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Some simple testing tasks (sorry, UNIX only).

FLAGS=
FLAGS?=
# ?= is assignment conditional on it not being set
HTTP_BACKEND?='all'

pre-commit flake: checkrst
pre-commit run --all
Expand All @@ -22,7 +24,7 @@ cov cover coverage: pre-commit
mototest:
docker pull alpine
docker pull lambci/lambda:python3.8
BOTO_CONFIG=/dev/null python -Wd -X tracemalloc=5 -X faulthandler -m pytest -vv -m moto -n auto --cov-report term --cov-report html --cov-report xml --cov=aiobotocore --cov=tests --log-cli-level=DEBUG aiobotocore tests
BOTO_CONFIG=/dev/null python -Wd -X tracemalloc=5 -X faulthandler -m pytest -vv -m moto -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
@echo "open file://`pwd`/htmlcov/index.html"

clean:
Expand Down
3 changes: 2 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,11 @@ secret accessible via environment variables:
::

$ pip install pip-tools
$ pip-compile requirements-dev.txt
$ pip-compile requirements-dev.in
$ pip-sync requirements-dev.txt
$ export AWS_ACCESS_KEY_ID=xxx
$ export AWS_SECRET_ACCESS_KEY=xxx
$ export AWS_DEFAULT_REGION=xxx # e.g. us-west-2

Execute tests suite:

Expand Down
14 changes: 14 additions & 0 deletions aiobotocore/_endpoint_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import botocore.retryhandler
import wrapt

try:
import httpx
except ImportError:
httpx = None

# Monkey patching: We need to insert the aiohttp exception equivalents
# The only other way to do this would be to have another config file :(
_aiohttp_retryable_exceptions = [
Expand All @@ -14,6 +19,15 @@
asyncio.TimeoutError,
]

if httpx is not None:
# TODO: Wild guesses after looking at https://pydoc.dev/httpx/latest/classIndex.html
# somebody with more network and/or httpx knowledge should revise this list.
_aiohttp_retryable_exceptions.extend(
(httpx.NetworkError, httpx.ConnectTimeout)
)


# TODO [httpx]: determine retryable exceptions
botocore.retryhandler.EXCEPTION_MAP['GENERAL_CONNECTION_ERROR'].extend(
_aiohttp_retryable_exceptions
)
Expand Down
7 changes: 6 additions & 1 deletion aiobotocore/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import botocore.serialize
from botocore.args import ClientArgsCreator

from aiobotocore.httpsession import AIOHTTPSession

from .config import AioConfig
from .endpoint import AioEndpointCreator
from .regions import AioEndpointRulesetResolver
Expand Down Expand Up @@ -67,8 +69,10 @@ def get_client_args(
# aiobotocore addition
if isinstance(client_config, AioConfig):
connector_args = client_config.connector_args
http_session_cls = client_config.http_session_cls
else:
connector_args = None
http_session_cls = AIOHTTPSession

new_config = AioConfig(connector_args, **config_kwargs)
endpoint_creator = AioEndpointCreator(event_emitter)
Expand All @@ -79,9 +83,10 @@ def get_client_args(
endpoint_url=endpoint_config['endpoint_url'],
verify=verify,
response_parser_factory=self._response_parser_factory,
timeout=(new_config.connect_timeout, new_config.read_timeout),
max_pool_connections=new_config.max_pool_connections,
http_session_cls=http_session_cls,
proxies=new_config.proxies,
timeout=(new_config.connect_timeout, new_config.read_timeout),
socket_options=socket_options,
client_cert=new_config.client_cert,
proxies_config=new_config.proxies_config,
Expand Down
28 changes: 28 additions & 0 deletions aiobotocore/awsrequest.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,31 @@
@property
def text(self):
return self._text_prop()


class HttpxAWSResponse(AWSResponse):
# Unlike AWSResponse, these return awaitables

async def _content_prop(self):
"""Content of the response as bytes."""

if self._content is None:
# NOTE: this will cache the data in self.raw
self._content = await self.raw.aread() or b''

return self._content

@property
def content(self):
return self._content_prop()

async def _text_prop(self):
encoding = botocore.utils.get_encoding_from_headers(self.headers)
if encoding:
return (await self.content).decode(encoding)

Check warning on line 52 in aiobotocore/awsrequest.py

View check run for this annotation

Codecov / codecov/patch

aiobotocore/awsrequest.py#L50-L52

Added lines #L50 - L52 were not covered by tests
else:
return (await self.content).decode('utf-8')

Check warning on line 54 in aiobotocore/awsrequest.py

View check run for this annotation

Codecov / codecov/patch

aiobotocore/awsrequest.py#L54

Added line #L54 was not covered by tests

@property
def text(self):
return self._text_prop()

Check warning on line 58 in aiobotocore/awsrequest.py

View check run for this annotation

Codecov / codecov/patch

aiobotocore/awsrequest.py#L58

Added line #L58 was not covered by tests
23 changes: 20 additions & 3 deletions aiobotocore/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
import botocore.client
from botocore.exceptions import ParamValidationError

from aiobotocore.httpsession import AIOHTTPSession, HttpxSession


class AioConfig(botocore.client.Config):
def __init__(self, connector_args=None, **kwargs):
def __init__(
self, connector_args=None, http_session_cls=AIOHTTPSession, **kwargs
):
super().__init__(**kwargs)

self._validate_connector_args(connector_args)
self._validate_connector_args(connector_args, http_session_cls)
self.connector_args = copy.copy(connector_args)
self.http_session_cls = http_session_cls
if not self.connector_args:
self.connector_args = dict()

Expand All @@ -27,13 +32,17 @@ def merge(self, other_config):
return AioConfig(self.connector_args, **config_options)

@staticmethod
def _validate_connector_args(connector_args):
def _validate_connector_args(connector_args, http_session_cls):
if connector_args is None:
return

for k, v in connector_args.items():
# verify_ssl is handled by verify parameter to create_client
if k == 'use_dns_cache':
if http_session_cls is HttpxSession:
raise ParamValidationError(
report='Httpx does not support dns caching. https://github.com/encode/httpx/discussions/2211'
)
if not isinstance(v, bool):
raise ParamValidationError(
report=f'{k} value must be a boolean'
Expand All @@ -44,6 +53,10 @@ def _validate_connector_args(connector_args):
report=f'{k} value must be a float/int or None'
)
elif k == 'force_close':
if http_session_cls is HttpxSession:
raise ParamValidationError(
report=f'Httpx backend does not currently support {k}.'
)
if not isinstance(v, bool):
raise ParamValidationError(
report=f'{k} value must be a boolean'
Expand All @@ -59,6 +72,10 @@ def _validate_connector_args(connector_args):
elif k == "resolver":
from aiohttp.abc import AbstractResolver

if http_session_cls is HttpxSession:
raise ParamValidationError(
report=f'Httpx backend does not support {k}.'
)
if not isinstance(v, AbstractResolver):
raise ParamValidationError(
report=f'{k} must be an instance of a AbstractResolver'
Expand Down
32 changes: 25 additions & 7 deletions aiobotocore/endpoint.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from __future__ import annotations

import asyncio
from typing import Any

from botocore.endpoint import (
DEFAULT_TIMEOUT,
Expand All @@ -13,14 +16,22 @@
logger,
)
from botocore.hooks import first_non_none_response
from urllib3.response import HTTPHeaderDict
from requests.models import Response
from urllib3._collections import HTTPHeaderDict

from aiobotocore.httpchecksum import handle_checksum_body
from aiobotocore.httpsession import AIOHTTPSession
from aiobotocore.response import StreamingBody

try:
import httpx
except ImportError:
httpx = None


async def convert_to_response_dict(http_response, operation_model):
async def convert_to_response_dict(
http_response: Response, operation_model
) -> dict[str, Any]:
"""Convert an HTTP response object to a request dict.

This converts the requests library's HTTP response object to
Expand All @@ -36,15 +47,19 @@ async def convert_to_response_dict(http_response, operation_model):
* body (string or file-like object)

"""
response_dict = {
if httpx and isinstance(http_response.raw, httpx.Response):
raw_headers = http_response.raw.headers.raw
else: # aiohttp.ClientResponse
raw_headers = http_response.raw.raw_headers
response_dict: dict[str, Any] = {
# botocore converts keys to str, so make sure that they are in
# the expected case. See detailed discussion here:
# https://github.com/aio-libs/aiobotocore/pull/116
# aiohttp's CIMultiDict camel cases the headers :(
'headers': HTTPHeaderDict(
{
k.decode('utf-8').lower(): v.decode('utf-8')
for k, v in http_response.raw.raw_headers
for k, v in raw_headers
}
),
'status_code': http_response.status_code,
Expand All @@ -57,8 +72,11 @@ async def convert_to_response_dict(http_response, operation_model):
elif operation_model.has_event_stream_output:
response_dict['body'] = http_response.raw
elif operation_model.has_streaming_output:
length = response_dict['headers'].get('content-length')
response_dict['body'] = StreamingBody(http_response.raw, length)
if httpx and isinstance(http_response.raw, httpx.Response):
response_dict['body'] = http_response.raw
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should keep the StreamingBody as this is our own version so we can adapt as necessary

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went into detail on this in #1085 (comment)

But if we want to get rid of e.g. aread vs read differences maybe it could make sense to create a HttpxStreamingBody that tries to strike a middle ground

else:
length = response_dict['headers'].get('content-length')
response_dict['body'] = StreamingBody(http_response.raw, length)
else:
response_dict['body'] = await http_response.content
return response_dict
Expand Down Expand Up @@ -273,7 +291,7 @@ async def _needs_retry(
return False
else:
# Request needs to be retried, and we need to sleep
# for the specified number of times.
# for the specified number of seconds.
logger.debug(
"Response received to retry, sleeping for %s seconds",
handler_response,
Expand Down
Loading
Loading