diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea9470c..f631181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index f572e17..b6fc5d7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ``` diff --git a/docs/changelog.md b/docs/changelog.md index c554a63..ef70b95 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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) diff --git a/docs/guide.md b/docs/guide.md index ebd8469..e672e15 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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. @@ -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. diff --git a/noxfile.py b/noxfile.py index 332591e..5042d14 100644 --- a/noxfile.py +++ b/noxfile.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 76ac09b..1c307bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"}, ] @@ -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", @@ -37,12 +36,16 @@ dependencies = [ httpx = [ "httpx", ] +httpx2 = [ + "httpx2", +] requests = [ "requests", "types-requests", ] all = [ "httpx", + "httpx2", "requests", "types-requests", ] @@ -54,6 +57,7 @@ dev = [ "pytest", "pytest-xdist", "respx", + "pytest-httpx2", "ruff", "nox", "types-requests", @@ -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"] \ No newline at end of file +ignore = ["E501"] diff --git a/retryhttp/_utils.py b/retryhttp/_utils.py index aa7cd2a..90c23f9 100644 --- a/retryhttp/_utils.py +++ b/retryhttp/_utils.py @@ -5,6 +5,7 @@ from ._types import HTTPDate _HTTPX_INSTALLED = False +_HTTPX2_INSTALLED = False _REQUESTS_INSTALLED = False _AIOHTTP_INSTALLED = False @@ -15,6 +16,13 @@ except ImportError: pass +try: + import httpx2 + + _HTTPX2_INSTALLED = True +except ImportError: + pass + try: import requests @@ -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( [ @@ -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: @@ -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: diff --git a/tests/test_retry.py b/tests/test_retry.py index 3840b0f..0e0b073 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -1,4 +1,5 @@ import httpx +import httpx2 import pytest import respx from tenacity import RetryError @@ -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) @@ -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