Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Setup Nox
uses: wntrblm/nox@2024.04.15
with:
python-versions: "3.9, 3.10, 3.11, 3.12, 3.13"
python-versions: "3.10, 3.11, 3.12, 3.13"

- name: Run Nox
run: nox -t test
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Several HTTP errors are often transient, and might succeed if retried:

This project aims to simplify retrying these, by extending [`tenacity`](https://tenacity.readthedocs.io/) with custom retry and wait strategies, as well as a custom decorator. Defaults are sensible for most use cases, but are fully customizable.

Supports both [`requests`](https://docs.python-requests.org/en/latest/index.html), [`httpx`](https://python-httpx.org/) and [`aiohttp`](https://docs.aiohttp.org/) natively, but could be customized to use with any library that raises exceptions for the conditions listed above.
Supports [`requests`](https://docs.python-requests.org/en/latest/index.html), [`httpx`](https://python-httpx.org/), [`httpx2`](https://httpx2.pydantic.dev/) and [`aiohttp`](https://docs.aiohttp.org/) natively, but could be customized to use with any library that raises exceptions for the conditions listed above.

## Install

Expand All @@ -33,6 +33,7 @@ You can also install support for only HTTPX or requests, if you would rather not

```sh
pip install retryhttp[httpx] # Supports only HTTPX
pip install retryhttp[httpx2] # Supports only HTTPX2
pip install retryhttp[requests] # Supports only requests
pip install retryhttp[aiohttp] # Supports only aiohttp
```
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

* Added support for `httpx2`.
* Dropped support for Python 3.9. The minimum supported version is now Python 3.10.

## v1.4.0

* Added support for `aiohttp` ([#28])(https://github.com/austind/retryhttp/pull/28)
Expand Down
18 changes: 17 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Overview

`retryhttp` makes it easy to retry potentially transient HTTP errors when using `httpx`, `requests` or `aiohttp`.
`retryhttp` makes it easy to retry potentially transient HTTP errors when using `httpx`, `httpx2`, `requests` or `aiohttp`.

!!! note
Errors that you can safely retry vary from service to service.
Expand Down Expand Up @@ -53,6 +53,22 @@ def get_example():
response.raise_for_status()
```

`httpx2` works the same way as `httpx`; just swap the import and call:

```python
import httpx2
from retryhttp import retry

# Retries safely retryable status codes (429, 500, 502, 503, 504), network errors,
# and timeouts, up to a total of 3 times, with appropriate wait strategies for each
# type of error.
@retry
def get_example():
response = httpx2.get("https://example.com/")
response.raise_for_status()
return response.text
```

!!! note
`retryhttp` works by catching exceptions, so your code must raise those exceptions. Most of the time, all you need is to call `response.raise_for_status()`. Be sure not to catch those exceptions in your own `try..except` block.

Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import nox


@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"], tags=["test"])
@nox.session(python=["3.10", "3.11", "3.12", "3.13"], tags=["test"])
def test(session: nox.Session) -> None:
session.install(".[all,dev]")
session.run("pytest", "-n", "4")
12 changes: 8 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ version = "1.4.0"
description = "Retry potentially transient HTTP errors in Python."
license = {file = "LICENSE"}
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
authors = [
{name = "Austin de Coup-Crank", email = "austindcc@gmail.com"},
]
Expand All @@ -19,7 +19,6 @@ classifiers = [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
Expand All @@ -37,12 +36,16 @@ dependencies = [
httpx = [
"httpx",
]
httpx2 = [
"httpx2",
]
requests = [
"requests",
"types-requests",
]
all = [
"httpx",
"httpx2",
"requests",
"types-requests",
]
Expand All @@ -54,6 +57,7 @@ dev = [
"pytest",
"pytest-xdist",
"respx",
"pytest-httpx2",
"ruff",
"nox",
"types-requests",
Expand Down Expand Up @@ -86,8 +90,8 @@ packages = ["retryhttp"]
[tool.ruff]
line-length = 100
indent-width = 4
target-version = "py38"
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "W", "I"]
ignore = ["E501"]
ignore = ["E501"]
20 changes: 20 additions & 0 deletions retryhttp/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from ._types import HTTPDate

_HTTPX_INSTALLED = False
_HTTPX2_INSTALLED = False
_REQUESTS_INSTALLED = False
_AIOHTTP_INSTALLED = False

Expand All @@ -15,6 +16,13 @@
except ImportError:
pass

try:
import httpx2

_HTTPX2_INSTALLED = True
except ImportError:
pass

try:
import requests

Expand Down Expand Up @@ -52,6 +60,14 @@ def get_default_network_errors() -> Tuple[Type[BaseException], ...]:
httpx.WriteError,
]
)
if _HTTPX2_INSTALLED:
exceptions.extend(
[
httpx2.ConnectError,
httpx2.ReadError,
httpx2.WriteError,
]
)
if _REQUESTS_INSTALLED:
exceptions.extend(
[
Expand All @@ -74,6 +90,8 @@ def get_default_timeouts() -> Tuple[Type[BaseException], ...]:
exceptions: list[Type[BaseException]] = []
if _HTTPX_INSTALLED:
exceptions.append(httpx.TimeoutException)
if _HTTPX2_INSTALLED:
exceptions.append(httpx2.TimeoutException)
if _REQUESTS_INSTALLED:
exceptions.append(requests.Timeout)
if _AIOHTTP_INSTALLED:
Expand All @@ -91,6 +109,8 @@ def get_default_http_status_exceptions() -> Tuple[Type[BaseException], ...]:
exceptions: list[Type[BaseException]] = []
if _HTTPX_INSTALLED:
exceptions.append(httpx.HTTPStatusError)
if _HTTPX2_INSTALLED:
exceptions.append(httpx2.HTTPStatusError)
if _REQUESTS_INSTALLED:
exceptions.append(requests.HTTPError)
if _AIOHTTP_INSTALLED:
Expand Down
88 changes: 88 additions & 0 deletions tests/test_retry.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import httpx
import httpx2
import pytest
import respx
from tenacity import RetryError
Expand All @@ -23,6 +24,21 @@ def reraise():
return httpx.get(MOCK_URL)


@retryhttp.retry
def default_args_httpx2():
return httpx2.get(MOCK_URL)


@retryhttp.retry(max_attempt_number=2)
def retry_max_2_httpx2():
return httpx2.get(MOCK_URL)


@retryhttp.retry(reraise=True)
def reraise_httpx2():
return httpx2.get(MOCK_URL)


@respx.mock
def test_default_args_success():
route = respx.get(MOCK_URL)
Expand Down Expand Up @@ -87,3 +103,75 @@ def test_reraise():
with pytest.raises(httpx.ConnectError):
reraise()
assert route.call_count == 3


# pytest-httpx2 mocks at the httpcore2 layer via the standard respx router, which is
# built on the original httpx. Mocked responses must therefore be original
# httpx.Response objects (respx re-serializes them through httpcore2, and httpx2 then
# re-wraps them), while raised exceptions can be httpx2 types as they propagate directly.
def test_httpx2_default_args_success(httpx2_mock):
route = httpx2_mock.get(MOCK_URL)
route.side_effect = [
httpx2.ConnectError("Mock Error"),
httpx2.ReadTimeout("Mock Error"),
httpx.Response(httpx.codes.OK),
]

response = default_args_httpx2()

assert route.call_count == 3
assert response.status_code == httpx2.codes.OK


def test_httpx2_default_args_connect_error(httpx2_mock):
route = httpx2_mock.get(MOCK_URL)
route.side_effect = [
httpx2.ConnectError("Mock Error"),
httpx2.ConnectError("Mock Error"),
httpx2.ConnectError("Mock Error"),
]
with pytest.raises(RetryError):
default_args_httpx2()

assert route.call_count == 3


def test_httpx2_non_http_error(httpx2_mock):
route = httpx2_mock.get(MOCK_URL)
route.side_effect = IOError
with pytest.raises(IOError):
default_args_httpx2()
assert route.call_count == 1


def test_httpx2_non_default_http_error(httpx2_mock):
route = httpx2_mock.get(MOCK_URL).mock(side_effect=httpx2.CloseError("Mock Error"))
with pytest.raises(httpx2.CloseError):
default_args_httpx2()
assert route.call_count == 1


def test_httpx2_max_attempts(httpx2_mock):
route = httpx2_mock.get(MOCK_URL).mock(
side_effect=[
httpx2.ConnectError("Mock Error"),
httpx2.ConnectTimeout("Mock Error"),
httpx.Response(200),
]
)
with pytest.raises(RetryError):
retry_max_2_httpx2()
assert route.call_count == 2


def test_httpx2_reraise(httpx2_mock):
route = httpx2_mock.get(MOCK_URL).mock(
side_effect=[
httpx2.ConnectError("Mock Error"),
httpx2.ConnectError("Mock Error"),
httpx2.ConnectError("Mock Error"),
]
)
with pytest.raises(httpx2.ConnectError):
reraise_httpx2()
assert route.call_count == 3
Loading