Skip to content

Commit cf2f119

Browse files
committed
Added support for httpx2
1 parent d9580c1 commit cf2f119

6 files changed

Lines changed: 133 additions & 2 deletions

File tree

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

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

78
## v1.4.0

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

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,16 @@ dependencies = [
3636
httpx = [
3737
"httpx",
3838
]
39+
httpx2 = [
40+
"httpx2",
41+
]
3942
requests = [
4043
"requests",
4144
"types-requests",
4245
]
4346
all = [
4447
"httpx",
48+
"httpx2",
4549
"requests",
4650
"types-requests",
4751
]
@@ -53,6 +57,7 @@ dev = [
5357
"pytest",
5458
"pytest-xdist",
5559
"respx",
60+
"pytest-httpx2",
5661
"ruff",
5762
"nox",
5863
"types-requests",

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)