Skip to content

Commit 3b81635

Browse files
authored
Support httpx2 (#32)
* Dropped support for Python 3.9 * Added support for httpx2
1 parent 2b05e18 commit 3b81635

8 files changed

Lines changed: 142 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- name: Setup Nox
1818
uses: wntrblm/nox@2024.04.15
1919
with:
20-
python-versions: "3.9, 3.10, 3.11, 3.12, 3.13"
20+
python-versions: "3.10, 3.11, 3.12, 3.13"
2121

2222
- name: Run Nox
2323
run: nox -t test

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Several HTTP errors are often transient, and might succeed if retried:
1919

2020
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.
2121

22-
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.
22+
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.
2323

2424
## Install
2525

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

3434
```sh
3535
pip install retryhttp[httpx] # Supports only HTTPX
36+
pip install retryhttp[httpx2] # Supports only HTTPX2
3637
pip install retryhttp[requests] # Supports only requests
3738
pip install retryhttp[aiohttp] # Supports only aiohttp
3839
```

docs/changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
* Added support for `httpx2`.
6+
* Dropped support for Python 3.9. The minimum supported version is now Python 3.10.
7+
38
## v1.4.0
49

510
* Added support for `aiohttp` ([#28])(https://github.com/austind/retryhttp/pull/28)

docs/guide.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## Overview
22

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

55
!!! note
66
Errors that you can safely retry vary from service to service.
@@ -53,6 +53,22 @@ def get_example():
5353
response.raise_for_status()
5454
```
5555

56+
`httpx2` works the same way as `httpx`; just swap the import and call:
57+
58+
```python
59+
import httpx2
60+
from retryhttp import retry
61+
62+
# Retries safely retryable status codes (429, 500, 502, 503, 504), network errors,
63+
# and timeouts, up to a total of 3 times, with appropriate wait strategies for each
64+
# type of error.
65+
@retry
66+
def get_example():
67+
response = httpx2.get("https://example.com/")
68+
response.raise_for_status()
69+
return response.text
70+
```
71+
5672
!!! note
5773
`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.
5874

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import nox
22

33

4-
@nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"], tags=["test"])
4+
@nox.session(python=["3.10", "3.11", "3.12", "3.13"], tags=["test"])
55
def test(session: nox.Session) -> None:
66
session.install(".[all,dev]")
77
session.run("pytest", "-n", "4")

pyproject.toml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ version = "1.4.0"
55
description = "Retry potentially transient HTTP errors in Python."
66
license = {file = "LICENSE"}
77
readme = "README.md"
8-
requires-python = ">=3.9"
8+
requires-python = ">=3.10"
99
authors = [
1010
{name = "Austin de Coup-Crank", email = "austindcc@gmail.com"},
1111
]
@@ -19,7 +19,6 @@ classifiers = [
1919
"Programming Language :: Python",
2020
"Programming Language :: Python :: 3",
2121
"Programming Language :: Python :: 3 :: Only",
22-
"Programming Language :: Python :: 3.9",
2322
"Programming Language :: Python :: 3.10",
2423
"Programming Language :: Python :: 3.11",
2524
"Programming Language :: Python :: 3.12",
@@ -37,12 +36,16 @@ dependencies = [
3736
httpx = [
3837
"httpx",
3938
]
39+
httpx2 = [
40+
"httpx2",
41+
]
4042
requests = [
4143
"requests",
4244
"types-requests",
4345
]
4446
all = [
4547
"httpx",
48+
"httpx2",
4649
"requests",
4750
"types-requests",
4851
]
@@ -54,6 +57,7 @@ dev = [
5457
"pytest",
5558
"pytest-xdist",
5659
"respx",
60+
"pytest-httpx2",
5761
"ruff",
5862
"nox",
5963
"types-requests",
@@ -86,8 +90,8 @@ packages = ["retryhttp"]
8690
[tool.ruff]
8791
line-length = 100
8892
indent-width = 4
89-
target-version = "py38"
93+
target-version = "py310"
9094

9195
[tool.ruff.lint]
9296
select = ["E", "F", "W", "I"]
93-
ignore = ["E501"]
97+
ignore = ["E501"]

retryhttp/_utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from ._types import HTTPDate
66

77
_HTTPX_INSTALLED = False
8+
_HTTPX2_INSTALLED = False
89
_REQUESTS_INSTALLED = False
910
_AIOHTTP_INSTALLED = False
1011

@@ -15,6 +16,13 @@
1516
except ImportError:
1617
pass
1718

19+
try:
20+
import httpx2
21+
22+
_HTTPX2_INSTALLED = True
23+
except ImportError:
24+
pass
25+
1826
try:
1927
import requests
2028

@@ -52,6 +60,14 @@ def get_default_network_errors() -> Tuple[Type[BaseException], ...]:
5260
httpx.WriteError,
5361
]
5462
)
63+
if _HTTPX2_INSTALLED:
64+
exceptions.extend(
65+
[
66+
httpx2.ConnectError,
67+
httpx2.ReadError,
68+
httpx2.WriteError,
69+
]
70+
)
5571
if _REQUESTS_INSTALLED:
5672
exceptions.extend(
5773
[
@@ -74,6 +90,8 @@ def get_default_timeouts() -> Tuple[Type[BaseException], ...]:
7490
exceptions: list[Type[BaseException]] = []
7591
if _HTTPX_INSTALLED:
7692
exceptions.append(httpx.TimeoutException)
93+
if _HTTPX2_INSTALLED:
94+
exceptions.append(httpx2.TimeoutException)
7795
if _REQUESTS_INSTALLED:
7896
exceptions.append(requests.Timeout)
7997
if _AIOHTTP_INSTALLED:
@@ -91,6 +109,8 @@ def get_default_http_status_exceptions() -> Tuple[Type[BaseException], ...]:
91109
exceptions: list[Type[BaseException]] = []
92110
if _HTTPX_INSTALLED:
93111
exceptions.append(httpx.HTTPStatusError)
112+
if _HTTPX2_INSTALLED:
113+
exceptions.append(httpx2.HTTPStatusError)
94114
if _REQUESTS_INSTALLED:
95115
exceptions.append(requests.HTTPError)
96116
if _AIOHTTP_INSTALLED:

tests/test_retry.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import httpx
2+
import httpx2
23
import pytest
34
import respx
45
from tenacity import RetryError
@@ -23,6 +24,21 @@ def reraise():
2324
return httpx.get(MOCK_URL)
2425

2526

27+
@retryhttp.retry
28+
def default_args_httpx2():
29+
return httpx2.get(MOCK_URL)
30+
31+
32+
@retryhttp.retry(max_attempt_number=2)
33+
def retry_max_2_httpx2():
34+
return httpx2.get(MOCK_URL)
35+
36+
37+
@retryhttp.retry(reraise=True)
38+
def reraise_httpx2():
39+
return httpx2.get(MOCK_URL)
40+
41+
2642
@respx.mock
2743
def test_default_args_success():
2844
route = respx.get(MOCK_URL)
@@ -87,3 +103,75 @@ def test_reraise():
87103
with pytest.raises(httpx.ConnectError):
88104
reraise()
89105
assert route.call_count == 3
106+
107+
108+
# pytest-httpx2 mocks at the httpcore2 layer via the standard respx router, which is
109+
# built on the original httpx. Mocked responses must therefore be original
110+
# httpx.Response objects (respx re-serializes them through httpcore2, and httpx2 then
111+
# re-wraps them), while raised exceptions can be httpx2 types as they propagate directly.
112+
def test_httpx2_default_args_success(httpx2_mock):
113+
route = httpx2_mock.get(MOCK_URL)
114+
route.side_effect = [
115+
httpx2.ConnectError("Mock Error"),
116+
httpx2.ReadTimeout("Mock Error"),
117+
httpx.Response(httpx.codes.OK),
118+
]
119+
120+
response = default_args_httpx2()
121+
122+
assert route.call_count == 3
123+
assert response.status_code == httpx2.codes.OK
124+
125+
126+
def test_httpx2_default_args_connect_error(httpx2_mock):
127+
route = httpx2_mock.get(MOCK_URL)
128+
route.side_effect = [
129+
httpx2.ConnectError("Mock Error"),
130+
httpx2.ConnectError("Mock Error"),
131+
httpx2.ConnectError("Mock Error"),
132+
]
133+
with pytest.raises(RetryError):
134+
default_args_httpx2()
135+
136+
assert route.call_count == 3
137+
138+
139+
def test_httpx2_non_http_error(httpx2_mock):
140+
route = httpx2_mock.get(MOCK_URL)
141+
route.side_effect = IOError
142+
with pytest.raises(IOError):
143+
default_args_httpx2()
144+
assert route.call_count == 1
145+
146+
147+
def test_httpx2_non_default_http_error(httpx2_mock):
148+
route = httpx2_mock.get(MOCK_URL).mock(side_effect=httpx2.CloseError("Mock Error"))
149+
with pytest.raises(httpx2.CloseError):
150+
default_args_httpx2()
151+
assert route.call_count == 1
152+
153+
154+
def test_httpx2_max_attempts(httpx2_mock):
155+
route = httpx2_mock.get(MOCK_URL).mock(
156+
side_effect=[
157+
httpx2.ConnectError("Mock Error"),
158+
httpx2.ConnectTimeout("Mock Error"),
159+
httpx.Response(200),
160+
]
161+
)
162+
with pytest.raises(RetryError):
163+
retry_max_2_httpx2()
164+
assert route.call_count == 2
165+
166+
167+
def test_httpx2_reraise(httpx2_mock):
168+
route = httpx2_mock.get(MOCK_URL).mock(
169+
side_effect=[
170+
httpx2.ConnectError("Mock Error"),
171+
httpx2.ConnectError("Mock Error"),
172+
httpx2.ConnectError("Mock Error"),
173+
]
174+
)
175+
with pytest.raises(httpx2.ConnectError):
176+
reraise_httpx2()
177+
assert route.call_count == 3

0 commit comments

Comments
 (0)