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 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ jobs:
COLOR: 'yes'
run: |
make mototest
- name: Run unittests without httpx
# run it on same python as codecov
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
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Some simple testing tasks (sorry, UNIX only).

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

pre-commit:
Expand All @@ -19,7 +20,7 @@ cov cover coverage: pre-commit
mototest:
docker pull alpine
docker pull lambci/lambda:python3.8
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 $(FLAGS) aiobotocore tests
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
19 changes: 18 additions & 1 deletion 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,12 +19,24 @@
asyncio.TimeoutError,
]


botocore.retryhandler.EXCEPTION_MAP['GENERAL_CONNECTION_ERROR'].extend(
_aiohttp_retryable_exceptions
)

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.
_httpx_retryable_exceptions = [
httpx.NetworkError,
httpx.ConnectTimeout,
]
botocore.retryhandler.EXCEPTION_MAP['GENERAL_CONNECTION_ERROR'].extend(
_httpx_retryable_exceptions
)


def _text(s, encoding='utf-8', errors='strict'):
def _text(s, encoding='utf-8', errors='strict') -> str:
if isinstance(s, bytes):
return s.decode(encoding, errors)
return s # pragma: no cover
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
17 changes: 15 additions & 2 deletions aiobotocore/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from botocore.exceptions import ParamValidationError

from aiobotocore.endpoint import DEFAULT_HTTP_SESSION_CLS
from aiobotocore.httpsession import HttpxSession


class AioConfig(botocore.client.Config):
Expand All @@ -15,7 +16,7 @@ def __init__(
):
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:
Expand All @@ -35,13 +36,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 @@ -52,6 +57,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 @@ -67,6 +76,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,16 +16,24 @@
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

DEFAULT_HTTP_SESSION_CLS = AIOHTTPSession


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 @@ -38,15 +49,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 @@ -59,8 +74,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
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 @@ -282,7 +300,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