Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-httpx-retries for
openSUSE:Factory checked in at 2026-07-06 12:34:48
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-httpx-retries (Old)
and /work/SRC/openSUSE:Factory/.python-httpx-retries.new.1982 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-httpx-retries"
Mon Jul 6 12:34:48 2026 rev:2 rq:1363973 version:0.6.0
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-httpx-retries/python-httpx-retries.changes
2026-06-19 17:39:16.007006316 +0200
+++
/work/SRC/openSUSE:Factory/.python-httpx-retries.new.1982/python-httpx-retries.changes
2026-07-06 12:37:31.626237093 +0200
@@ -1,0 +2,18 @@
+Mon Jul 6 07:39:34 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to version 0.6.0:
+ * Add retry_request and aretry_request helpers that run the
+ retry loop at the client level, so errors raised while
+ reading the response body (ReadTimeout, RemoteProtocolError)
+ are retried too - something RetryTransport cannot do
+ * Add validate_response option on Retry to retry when a
+ callback rejects an otherwise-successful response
+ * Allow overriding the Retry configuration per request via
+ request.extensions["retry"]; the resolved Retry is exposed
+ on response.extensions["retry"] for introspection
+ * Add Retry.copy_with to derive a modified Retry configuration
+ * No breaking changes - the existing API stays compatible
+- Run the upstream test suite in %%check instead of a bare
+ import smoke test
+
+-------------------------------------------------------------------
Old:
----
httpx_retries-0.5.0.tar.gz
New:
----
httpx_retries-0.6.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-httpx-retries.spec ++++++
--- /var/tmp/diff_new_pack.vRsTzw/_old 2026-07-06 12:37:32.250258776 +0200
+++ /var/tmp/diff_new_pack.vRsTzw/_new 2026-07-06 12:37:32.250258776 +0200
@@ -17,16 +17,18 @@
Name: python-httpx-retries
-Version: 0.5.0
+Version: 0.6.0
Release: 0
Summary: A retry layer for HTTPX
License: MIT
URL: https://github.com/will-ockmore/httpx-retries
-Source:
https://files.pythonhosted.org/packages/source/h/httpx-retries/httpx_retries-%{version}.tar.gz
+Source:
https://files.pythonhosted.org/packages/source/h/httpx_retries/httpx_retries-%{version}.tar.gz
BuildRequires: %{python_module hatch-fancy-pypi-readme}
BuildRequires: %{python_module hatchling}
BuildRequires: %{python_module httpx >= 0.20.0}
BuildRequires: %{python_module pip}
+BuildRequires: %{python_module pytest-asyncio}
+BuildRequires: %{python_module pytest}
BuildRequires: %{python_module wheel}
BuildRequires: fdupes
BuildRequires: python-rpm-macros
@@ -51,7 +53,7 @@
%python_expand %fdupes %{buildroot}%{$python_sitelib}
%check
-%python_expand PYTHONPATH=%{buildroot}%{$python_sitelib} $python -B -c "import
httpx_retries"
+%pytest tests
%files %{python_files}
%doc README.md
++++++ httpx_retries-0.5.0.tar.gz -> httpx_retries-0.6.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/PKG-INFO
new/httpx_retries-0.6.0/PKG-INFO
--- old/httpx_retries-0.5.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/httpx_retries-0.6.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: httpx-retries
-Version: 0.5.0
+Version: 0.6.0
Dynamic: License
Dynamic: License-Expression
Summary: A retry layer for HTTPX.
@@ -79,6 +79,11 @@
response = client.get("https://example.com")
```
+> **Errors while reading the response body** (such as `ReadTimeout` part-way
through a download) happen
+> after the transport has returned, so `RetryTransport` can't retry them. This
is a niche case,
+> but if you read very large or slow bodies and need those retried, see the
`retry_request` / `aretry_request` helpers in
+> [Why wasn't my `ReadTimeout`
retried?](https://will-ockmore.github.io/httpx-retries/faq/#why-wasnt-my-readtimeout-retried).
+
## Features
HTTPX Retries builds on the patterns users will expect from `urllib` and
`requests`. The typical approach has been
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/README.md
new/httpx_retries-0.6.0/README.md
--- old/httpx_retries-0.5.0/README.md 2020-02-02 01:00:00.000000000 +0100
+++ new/httpx_retries-0.6.0/README.md 2020-02-02 01:00:00.000000000 +0100
@@ -1,15 +1,22 @@
# HTTPX Retries
-
<p>
-<a href="https://github.com/will-ockmore/httpx-retry/actions">
- <img
src="https://github.com/will-ockmore/httpx-retry/workflows/Test%20Suite/badge.svg"
alt="Test Suite">
+<a href="https://github.com/will-ockmore/httpx-retries/actions">
+ <img
src="https://github.com/will-ockmore/httpx-retries/actions/workflows/test-suite.yml/badge.svg?branch=main"
alt="Test Suite">
</a>
<a href="https://pypi.org/project/httpx-retries/">
<img src="https://badge.fury.io/py/httpx-retries.svg" alt="Package
version">
</a>
+<a href="https://pypi.org/project/httpx-retries/">
+ <img src="https://img.shields.io/pypi/pyversions/httpx-retries.svg"
alt="Python versions">
+</a>
+<a href="https://pepy.tech/project/httpx-retries">
+ <img src="https://static.pepy.tech/badge/httpx-retries/month"
alt="Downloads">
+</a>
+<a href="https://codecov.io/gh/will-ockmore/httpx-retries">
+ <img
src="https://codecov.io/gh/will-ockmore/httpx-retries/branch/main/graph/badge.svg"
alt="Coverage">
+</a>
</p>
-
<!-- badges-end -->
<em>A retry layer for HTTPX.</em>
@@ -61,6 +68,11 @@
response = client.get("https://example.com")
```
+> **Errors while reading the response body** (such as `ReadTimeout` part-way
through a download) happen
+> after the transport has returned, so `RetryTransport` can't retry them. This
is a niche case,
+> but if you read very large or slow bodies and need those retried, see the
`retry_request` / `aretry_request` helpers in
+> [Why wasn't my `ReadTimeout`
retried?](https://will-ockmore.github.io/httpx-retries/faq/#why-wasnt-my-readtimeout-retried).
+
## Features
HTTPX Retries builds on the patterns users will expect from `urllib` and
`requests`. The typical approach has been
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/httpx_retries/__init__.py
new/httpx_retries-0.6.0/httpx_retries/__init__.py
--- old/httpx_retries-0.5.0/httpx_retries/__init__.py 2020-02-02
01:00:00.000000000 +0100
+++ new/httpx_retries-0.6.0/httpx_retries/__init__.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,4 +1,5 @@
+from .helpers import aretry_request, retry_request
from .retry import Retry
from .transport import RetryTransport
-__all__ = ["Retry", "RetryTransport"]
+__all__ = ["Retry", "RetryTransport", "aretry_request", "retry_request"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/httpx_retries/helpers.py
new/httpx_retries-0.6.0/httpx_retries/helpers.py
--- old/httpx_retries-0.5.0/httpx_retries/helpers.py 1970-01-01
01:00:00.000000000 +0100
+++ new/httpx_retries-0.6.0/httpx_retries/helpers.py 2020-02-02
01:00:00.000000000 +0100
@@ -0,0 +1,147 @@
+import inspect
+from typing import Any
+
+import httpx
+
+from .retry import Retry
+from .transport import RetryTransport, _retry_operation, _retry_operation_async
+
+# Arguments accepted by `Client.send` rather than `Client.build_request`;
forwarded to send if provided.
+_SEND_KWARGS = ("auth", "follow_redirects")
+
+
+def _client_retries(client: httpx.Client | httpx.AsyncClient) -> bool:
+ """Return True if the client already retries via a
[RetryTransport][httpx_retries.RetryTransport].
+
+ The helpers run the retry loop themselves, so combining them with a
retrying transport would retry every
+ request twice. Detection reaches into httpx's private attributes and
degrades to ``False`` if they are absent.
+ """
+ mounts = getattr(client, "_mounts", {})
+ transports = [getattr(client, "_transport", None), *mounts.values()]
+ return any(isinstance(transport, RetryTransport) for transport in
transports)
+
+
+def retry_request(
+ client: httpx.Client,
+ method: str,
+ url: httpx.URL | str,
+ *,
+ retry: Retry | None = None,
+ **kwargs: Any,
+) -> httpx.Response:
+ """
+ Send a request with retries, including errors raised while reading the
response body.
+
+ Unlike [RetryTransport][httpx_retries.RetryTransport], which can only
observe what flows through its
+ `handle_request` method (the response *headers*), this helper drives the
retry loop at the client level.
+ Because `httpx.Client.send` reads the body before returning, body-phase
errors such as `httpx.ReadTimeout`
+ and `httpx.RemoteProtocolError("peer closed connection...")` are caught
here and retried.
+
+ ```python
+ import httpx
+ from httpx_retries import retry_request
+
+ with httpx.Client() as client:
+ response = retry_request(client, "GET", "https://example.com")
+ ```
+
+ The retry configuration can be customised, just like
[RetryTransport][httpx_retries.RetryTransport]:
+
+ ```python
+ response = retry_request(client, "GET", "https://example.com",
retry=Retry(total=5, backoff_factor=0.5))
+ ```
+
+ This helper buffers the full response body, so it is not suitable for
streaming. Errors raised while
+ iterating a streaming response (`client.stream(...)`) cannot be retried.
+
+ Body-phase errors are a niche case; see
+ [Why wasn't my `ReadTimeout`
retried?](faq.md#why-wasnt-my-readtimeout-retried) for when these helpers are
+ worth using and when to prefer
[RetryTransport][httpx_retries.RetryTransport] instead.
+
+ Args:
+ client: The client used to build and send the request.
+ method: The HTTP method.
+ url: The URL to request.
+ retry: The retry configuration. A per-request
`request.extensions["retry"]` takes precedence.
+ **kwargs: Additional arguments. `auth` and `follow_redirects` are
forwarded to `client.send`; all others
+ (for example `params`, `headers`, `json`, `content`) are passed to
`client.build_request`.
+
+ Returns:
+ The final response.
+ """
+ if _client_retries(client):
+ raise ValueError(
+ "retry_request runs the retry loop itself and must be used with a
client that does not also retry. "
+ "The given client uses RetryTransport, which would retry every
request twice. Use a plain "
+ "httpx.Client instead; retry_request already retries header-phase
errors and retryable status codes."
+ )
+
+ send_kwargs = {key: kwargs.pop(key) for key in _SEND_KWARGS if key in
kwargs}
+ request = client.build_request(method, url, **kwargs)
+ retry = request.extensions.setdefault("retry", retry or Retry())
+
+ def send(request: httpx.Request) -> httpx.Response:
+ return client.send(request, **send_kwargs)
+
+ if not retry.is_retryable_method(request.method):
+ return send(request)
+
+ if retry.validate_response is not None and
inspect.iscoroutinefunction(retry.validate_response):
+ raise TypeError("validate_response must be a sync function when using
a sync client")
+
+ return _retry_operation(request, send, retry)
+
+
+async def aretry_request(
+ client: httpx.AsyncClient,
+ method: str,
+ url: httpx.URL | str,
+ *,
+ retry: Retry | None = None,
+ **kwargs: Any,
+) -> httpx.Response:
+ """
+ Send a request asynchronously with retries, including errors raised while
reading the response body.
+
+ This is the async counterpart to
[retry_request][httpx_retries.retry_request]. Body-phase errors are a niche
+ case; see [Why wasn't my `ReadTimeout`
retried?](faq.md#why-wasnt-my-readtimeout-retried) for when these
+ helpers are worth using and when to prefer
[RetryTransport][httpx_retries.RetryTransport] instead.
+
+ ```python
+ import httpx
+ from httpx_retries import aretry_request
+
+ async with httpx.AsyncClient() as client:
+ response = await aretry_request(client, "GET", "https://example.com")
+ ```
+
+ Args:
+ client: The client used to build and send the request.
+ method: The HTTP method.
+ url: The URL to request.
+ retry: The retry configuration. A per-request
`request.extensions["retry"]` takes precedence.
+ **kwargs: Additional arguments. `auth` and `follow_redirects` are
forwarded to `client.send`; all others
+ (for example `params`, `headers`, `json`, `content`) are passed to
`client.build_request`.
+
+ Returns:
+ The final response.
+ """
+ if _client_retries(client):
+ raise ValueError(
+ "aretry_request runs the retry loop itself and must be used with a
client that does not also retry. "
+ "The given client uses RetryTransport, which would retry every
request twice. Use a plain "
+ "httpx.AsyncClient instead; aretry_request already retries
header-phase errors and retryable status "
+ "codes."
+ )
+
+ send_kwargs = {key: kwargs.pop(key) for key in _SEND_KWARGS if key in
kwargs}
+ request = client.build_request(method, url, **kwargs)
+ retry = request.extensions.setdefault("retry", retry or Retry())
+
+ async def send(request: httpx.Request) -> httpx.Response:
+ return await client.send(request, **send_kwargs)
+
+ if not retry.is_retryable_method(request.method):
+ return await send(request)
+
+ return await _retry_operation_async(request, send, retry)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/httpx_retries/retry.py
new/httpx_retries-0.6.0/httpx_retries/retry.py
--- old/httpx_retries-0.5.0/httpx_retries/retry.py 2020-02-02
01:00:00.000000000 +0100
+++ new/httpx_retries-0.6.0/httpx_retries/retry.py 2020-02-02
01:00:00.000000000 +0100
@@ -4,7 +4,7 @@
import random
import sys
import time
-from collections.abc import Iterable, Mapping
+from collections.abc import Awaitable, Callable, Iterable, Mapping
from email.utils import parsedate_to_datetime
from enum import Enum
from http import HTTPStatus
@@ -30,6 +30,13 @@
CONNECT = "CONNECT"
+class _UnsetType:
+ __slots__ = ()
+
+
+_UNSET: Final[_UnsetType] = _UnsetType()
+
+
class Retry:
"""
A class to encapsulate retry logic and configuration.
@@ -62,6 +69,10 @@
repeatedly. Defaults to None (no cumulative cap).
elapsed_sleep (float, optional): Cumulative sleep time already spent
on this request. Preserved across
`increment()` calls; users typically do not set this directly.
+ validate_response (callable, optional): An optional callback called
with each response that would
+ otherwise be returned as a "good" (non-retryable-status) response.
If the callback raises, the
+ request is retried. May be sync or async; an async callback cannot
be used with a sync transport.
+ Signature: ``(response: httpx.Response) -> None``.
"""
RETRYABLE_METHODS: Final[frozenset[HTTPMethod]] = frozenset(
@@ -101,6 +112,7 @@
attempts_made: int = 0,
total_timeout: float | None = None,
elapsed_sleep: float = 0.0,
+ validate_response: Callable[[httpx.Response], None | Awaitable[None]]
| None = None,
) -> None:
"""Initialize a new Retry instance."""
if total < 0:
@@ -126,6 +138,7 @@
self.attempts_made = attempts_made
self.total_timeout = total_timeout
self.elapsed_sleep = elapsed_sleep
+ self.validate_response = validate_response
self.allowed_methods: frozenset[str] = frozenset(
method.upper() for method in (allowed_methods or
self.RETRYABLE_METHODS)
@@ -277,22 +290,47 @@
await asyncio.sleep(time_to_sleep)
self.elapsed_sleep += time_to_sleep
+ def copy_with(
+ self,
+ total: int | _UnsetType = _UNSET,
+ allowed_methods: Iterable[HTTPMethod | str] | None | _UnsetType =
_UNSET,
+ status_forcelist: Iterable[HTTPStatus | int] | None | _UnsetType =
_UNSET,
+ retry_on_exceptions: Iterable[type[Exception]] | None | _UnsetType =
_UNSET,
+ backoff_factor: float | _UnsetType = _UNSET,
+ respect_retry_after_header: bool | _UnsetType = _UNSET,
+ max_backoff_wait: float | _UnsetType = _UNSET,
+ backoff_jitter: float | _UnsetType = _UNSET,
+ attempts_made: int | _UnsetType = _UNSET,
+ total_timeout: float | None | _UnsetType = _UNSET,
+ elapsed_sleep: float | _UnsetType = _UNSET,
+ validate_response: Callable[[httpx.Response], None | Awaitable[None]]
| None | _UnsetType = _UNSET,
+ ) -> "Retry":
+ """Return a new Retry with selected fields overridden."""
+ return self.__class__(
+ total=self.total if isinstance(total, _UnsetType) else total,
+ allowed_methods=self.allowed_methods if
isinstance(allowed_methods, _UnsetType) else allowed_methods,
+ status_forcelist=self.status_forcelist if
isinstance(status_forcelist, _UnsetType) else status_forcelist,
+ retry_on_exceptions=self.retryable_exceptions
+ if isinstance(retry_on_exceptions, _UnsetType)
+ else retry_on_exceptions,
+ backoff_factor=self.backoff_factor if isinstance(backoff_factor,
_UnsetType) else backoff_factor,
+ respect_retry_after_header=self.respect_retry_after_header
+ if isinstance(respect_retry_after_header, _UnsetType)
+ else respect_retry_after_header,
+ max_backoff_wait=self.max_backoff_wait if
isinstance(max_backoff_wait, _UnsetType) else max_backoff_wait,
+ backoff_jitter=self.backoff_jitter if isinstance(backoff_jitter,
_UnsetType) else backoff_jitter,
+ attempts_made=self.attempts_made if isinstance(attempts_made,
_UnsetType) else attempts_made,
+ total_timeout=self.total_timeout if isinstance(total_timeout,
_UnsetType) else total_timeout,
+ elapsed_sleep=self.elapsed_sleep if isinstance(elapsed_sleep,
_UnsetType) else elapsed_sleep,
+ validate_response=self.validate_response
+ if isinstance(validate_response, _UnsetType)
+ else validate_response,
+ )
+
def increment(self) -> "Retry":
"""Return a new Retry instance with the attempt count incremented."""
logger.debug("increment retry=%s new_attempts_made=%s", self,
self.attempts_made + 1)
- return self.__class__(
- total=self.total,
- max_backoff_wait=self.max_backoff_wait,
- backoff_factor=self.backoff_factor,
- respect_retry_after_header=self.respect_retry_after_header,
- allowed_methods=self.allowed_methods,
- status_forcelist=self.status_forcelist,
- retry_on_exceptions=self.retryable_exceptions,
- backoff_jitter=self.backoff_jitter,
- attempts_made=self.attempts_made + 1,
- total_timeout=self.total_timeout,
- elapsed_sleep=self.elapsed_sleep,
- )
+ return self.copy_with(attempts_made=self.attempts_made + 1)
def __repr__(self) -> str:
return f"<Retry(total={self.total},
attempts_made={self.attempts_made})>"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/httpx_retries/transport.py
new/httpx_retries-0.6.0/httpx_retries/transport.py
--- old/httpx_retries-0.5.0/httpx_retries/transport.py 2020-02-02
01:00:00.000000000 +0100
+++ new/httpx_retries-0.6.0/httpx_retries/transport.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,3 +1,4 @@
+import inspect
import logging
from collections.abc import Callable, Coroutine
from functools import partial
@@ -10,6 +11,93 @@
logger = logging.getLogger(__name__)
+def _retry_operation(
+ request: httpx.Request,
+ send_method: Callable[..., httpx.Response],
+ retry: Retry,
+) -> httpx.Response:
+ response: httpx.Response | Exception | None = None
+
+ while True:
+ if response is not None:
+ if isinstance(response, httpx.Response):
+ response.close()
+
+ logger.debug("_retry_operation retrying request=%s response=%s
retry=%s", request, response, retry)
+ retry = retry.increment()
+ retry.sleep(response)
+ try:
+ response = send_method(request)
+ except Exception as e:
+ if retry.is_exhausted() or not retry.is_retryable_exception(e):
+ raise
+
+ response = e
+ continue
+
+ if retry.is_exhausted():
+ response.extensions["retry"] = retry
+ return response
+
+ if not retry.is_retryable_status_code(response.status_code):
+ if retry.validate_response is not None:
+ # normally set by httpx _after_ calling this function, but we
want the request in the validator
+ response.request = request
+ try:
+ retry.validate_response(response)
+ except Exception as e:
+ if retry.is_exhausted() or not
retry.is_retryable_exception(e):
+ raise
+ continue
+ response.extensions["retry"] = retry
+ return response
+
+
+async def _retry_operation_async(
+ request: httpx.Request,
+ send_method: Callable[..., Coroutine[Any, Any, httpx.Response]],
+ retry: Retry,
+) -> httpx.Response:
+ response: httpx.Response | Exception | None = None
+
+ while True:
+ if response is not None:
+ if isinstance(response, httpx.Response):
+ await response.aclose()
+
+ logger.debug("_retry_operation_async retrying request=%s
response=%s retry=%s", request, response, retry)
+ retry = retry.increment()
+ await retry.asleep(response)
+ try:
+ response = await send_method(request)
+ except Exception as e:
+ if retry.is_exhausted() or not retry.is_retryable_exception(e):
+ raise
+
+ response = e
+ continue
+
+ if retry.is_exhausted():
+ response.extensions["retry"] = retry
+ return response
+
+ if not retry.is_retryable_status_code(response.status_code):
+ if retry.validate_response is not None:
+ # normally set by httpx _after_ calling this function, but we
want the request in the validator
+ response.request = request
+ try:
+ if inspect.iscoroutinefunction(retry.validate_response):
+ await retry.validate_response(response)
+ else:
+ retry.validate_response(response)
+ except Exception as e:
+ if retry.is_exhausted() or not
retry.is_retryable_exception(e):
+ raise
+ continue
+ response.extensions["retry"] = retry
+ return response
+
+
class RetryTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
"""
A transport that automatically retries requests.
@@ -87,9 +175,14 @@
logger.debug("handle_request started request=%s", request)
- if self.retry.is_retryable_method(request.method):
+ retry: Retry = request.extensions.setdefault("retry", self.retry)
+
+ if retry.is_retryable_method(request.method):
+ if retry.validate_response is not None and
inspect.iscoroutinefunction(retry.validate_response):
+ raise TypeError("validate_response must be a sync function
when using a sync transport")
+
send_method = partial(self._sync_transport.handle_request)
- response = self._retry_operation(request, send_method)
+ response = _retry_operation(request, send_method, retry)
else:
response = self._sync_transport.handle_request(request)
@@ -111,70 +204,14 @@
logger.debug("handle_async_request started request=%s", request)
- if self.retry.is_retryable_method(request.method):
+ retry: Retry = request.extensions.setdefault("retry", self.retry)
+
+ if retry.is_retryable_method(request.method):
send_method = partial(self._async_transport.handle_async_request)
- response = await self._retry_operation_async(request, send_method)
+ response = await _retry_operation_async(request, send_method,
retry)
else:
response = await
self._async_transport.handle_async_request(request)
logger.debug("handle_async_request finished request=%s response=%s",
request, response)
return response
-
- def _retry_operation(
- self,
- request: httpx.Request,
- send_method: Callable[..., httpx.Response],
- ) -> httpx.Response:
- retry = self.retry
- response: httpx.Response | Exception | None = None
-
- while True:
- if response is not None:
- if isinstance(response, httpx.Response):
- response.close()
-
- logger.debug("_retry_operation retrying request=%s response=%s
retry=%s", request, response, retry)
- retry = retry.increment()
- retry.sleep(response)
- try:
- response = send_method(request)
- except Exception as e:
- if retry.is_exhausted() or not retry.is_retryable_exception(e):
- raise
-
- response = e
- continue
-
- if retry.is_exhausted() or not
retry.is_retryable_status_code(response.status_code):
- return response
-
- async def _retry_operation_async(
- self,
- request: httpx.Request,
- send_method: Callable[..., Coroutine[Any, Any, httpx.Response]],
- ) -> httpx.Response:
- retry = self.retry
- response: httpx.Response | Exception | None = None
-
- while True:
- if response is not None:
- if isinstance(response, httpx.Response):
- await response.aclose()
-
- logger.debug(
- "_retry_operation_async retrying request=%s response=%s
retry=%s", request, response, retry
- )
- retry = retry.increment()
- await retry.asleep(response)
- try:
- response = await send_method(request)
- except Exception as e:
- if retry.is_exhausted() or not retry.is_retryable_exception(e):
- raise
-
- response = e
- continue
-
- if retry.is_exhausted() or not
retry.is_retryable_status_code(response.status_code):
- return response
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/pyproject.toml
new/httpx_retries-0.6.0/pyproject.toml
--- old/httpx_retries-0.5.0/pyproject.toml 2020-02-02 01:00:00.000000000
+0100
+++ new/httpx_retries-0.6.0/pyproject.toml 2020-02-02 01:00:00.000000000
+0100
@@ -4,7 +4,7 @@
[project]
name = "httpx-retries"
-version = "0.5.0"
+version = "0.6.0"
description = "A retry layer for HTTPX."
requires-python = ">=3.10"
authors = [{ name = "Will Ockmore", email = "[email protected]" }]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/tests/test_helpers.py
new/httpx_retries-0.6.0/tests/test_helpers.py
--- old/httpx_retries-0.5.0/tests/test_helpers.py 1970-01-01
01:00:00.000000000 +0100
+++ new/httpx_retries-0.6.0/tests/test_helpers.py 2020-02-02
01:00:00.000000000 +0100
@@ -0,0 +1,338 @@
+from collections.abc import AsyncIterator, Generator, Iterator
+from unittest.mock import AsyncMock, MagicMock
+
+import httpx
+import pytest
+
+from httpx_retries import Retry, RetryTransport, aretry_request, retry_request
+
+
+class _FailingByteStream(httpx.SyncByteStream):
+ """A response body that raises when read."""
+
+ def __init__(self, exc: Exception) -> None:
+ self._exc = exc
+
+ def __iter__(self) -> Iterator[bytes]:
+ raise self._exc
+ yield b"" # pragma: no cover
+
+ def close(self) -> None:
+ pass
+
+
+class _FailingAsyncByteStream(httpx.AsyncByteStream):
+ """A response body that raises when read."""
+
+ def __init__(self, exc: Exception) -> None:
+ self._exc = exc
+
+ async def __aiter__(self) -> AsyncIterator[bytes]:
+ raise self._exc
+ yield b"" # pragma: no cover
+
+ async def aclose(self) -> None:
+ pass
+
+
+class BodyFailTransport(httpx.BaseTransport):
+ """Returns a response whose body read fails `fail_times` times, then
succeeds."""
+
+ def __init__(self, exc: Exception, fail_times: int) -> None:
+ self.exc = exc
+ self.fail_times = fail_times
+ self.attempts = 0
+
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
+ self.attempts += 1
+ if self.attempts <= self.fail_times:
+ return httpx.Response(200, stream=_FailingByteStream(self.exc))
+ return httpx.Response(200, content=b"ok")
+
+
+class AsyncBodyFailTransport(httpx.AsyncBaseTransport):
+ """Returns a response whose body read fails `fail_times` times, then
succeeds."""
+
+ def __init__(self, exc: Exception, fail_times: int) -> None:
+ self.exc = exc
+ self.fail_times = fail_times
+ self.attempts = 0
+
+ async def handle_async_request(self, request: httpx.Request) ->
httpx.Response:
+ self.attempts += 1
+ if self.attempts <= self.fail_times:
+ return httpx.Response(200,
stream=_FailingAsyncByteStream(self.exc))
+ return httpx.Response(200, content=b"ok")
+
+
+class _HeaderAuth(httpx.Auth):
+ """Auth scheme that stamps a fixed header onto each request."""
+
+ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request,
httpx.Response, None]:
+ request.headers["X-Auth"] = "secret"
+ yield request
+
+
+class RecordingTransport(httpx.BaseTransport):
+ """Records the requests it receives and returns 200."""
+
+ def __init__(self) -> None:
+ self.requests: list[httpx.Request] = []
+
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
+ self.requests.append(request)
+ return httpx.Response(200, content=b"ok")
+
+
+class AsyncRecordingTransport(httpx.AsyncBaseTransport):
+ """Records the requests it receives and returns 200."""
+
+ def __init__(self) -> None:
+ self.requests: list[httpx.Request] = []
+
+ async def handle_async_request(self, request: httpx.Request) ->
httpx.Response:
+ self.requests.append(request)
+ return httpx.Response(200, content=b"ok")
+
+
+class RedirectTransport(httpx.BaseTransport):
+ """Redirects `/start` to `/final` once, then serves `/final`."""
+
+ def __init__(self) -> None:
+ self.paths: list[str] = []
+
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
+ self.paths.append(request.url.path)
+ if request.url.path == "/start":
+ return httpx.Response(302, headers={"Location":
"https://example.com/final"})
+ return httpx.Response(200, content=b"final")
+
+
+class AsyncRedirectTransport(httpx.AsyncBaseTransport):
+ """Redirects `/start` to `/final` once, then serves `/final`."""
+
+ def __init__(self) -> None:
+ self.paths: list[str] = []
+
+ async def handle_async_request(self, request: httpx.Request) ->
httpx.Response:
+ self.paths.append(request.url.path)
+ if request.url.path == "/start":
+ return httpx.Response(302, headers={"Location":
"https://example.com/final"})
+ return httpx.Response(200, content=b"final")
+
+
+def test_retry_request_success(mock_sleep: MagicMock) -> None:
+ transport = BodyFailTransport(httpx.ReadTimeout("boom"), fail_times=0)
+
+ with httpx.Client(transport=transport) as client:
+ response = retry_request(client, "GET", "https://example.com")
+
+ assert response.status_code == 200
+ assert response.text == "ok"
+ assert mock_sleep.call_count == 0
+ assert response.extensions["retry"].attempts_made == 0
+
+
+# Both are in the default retryable set and are the two headline body-phase
failures.
+BODY_READ_ERRORS = [
+ httpx.ReadTimeout("read timed out"),
+ httpx.RemoteProtocolError("peer closed connection without sending complete
message body"),
+]
+
+
[email protected]("exc", BODY_READ_ERRORS)
+def test_retry_request_retries_body_read_error(mock_sleep: MagicMock, exc:
Exception) -> None:
+ transport = BodyFailTransport(exc, fail_times=2)
+
+ with httpx.Client(transport=transport) as client:
+ response = retry_request(client, "GET", "https://example.com")
+
+ assert response.status_code == 200
+ assert response.text == "ok"
+ assert transport.attempts == 3
+ assert mock_sleep.call_count == 2
+ assert response.extensions["retry"].attempts_made == 2
+
+
+def test_retry_request_raises_when_exhausted(mock_sleep: MagicMock) -> None:
+ transport = BodyFailTransport(httpx.ReadTimeout("boom"), fail_times=100)
+ retry = Retry(total=3)
+
+ with httpx.Client(transport=transport) as client:
+ with pytest.raises(httpx.ReadTimeout, match="boom"):
+ retry_request(client, "GET", "https://example.com", retry=retry)
+
+ assert mock_sleep.call_count == 3
+
+
+def test_retry_request_non_retryable_method(mock_sleep: MagicMock) -> None:
+ transport = BodyFailTransport(httpx.ReadTimeout("boom"), fail_times=1)
+
+ with httpx.Client(transport=transport) as client:
+ with pytest.raises(httpx.ReadTimeout, match="boom"):
+ retry_request(client, "POST", "https://example.com")
+
+ assert transport.attempts == 1
+ assert mock_sleep.call_count == 0
+
+
+def test_retry_request_extension_overrides_retry_argument(mock_sleep:
MagicMock) -> None:
+ transport = BodyFailTransport(httpx.ReadTimeout("boom"), fail_times=100)
+
+ with httpx.Client(transport=transport) as client:
+ with pytest.raises(httpx.ReadTimeout, match="boom"):
+ retry_request(
+ client,
+ "GET",
+ "https://example.com",
+ retry=Retry(total=10),
+ extensions={"retry": Retry(total=2)},
+ )
+
+ assert mock_sleep.call_count == 2
+
+
+def test_retry_request_async_validate_response_raises_for_sync_client() ->
None:
+ async def validate(response: httpx.Response) -> None: # pragma: no cover
+ pass
+
+ transport = BodyFailTransport(httpx.ReadTimeout("boom"), fail_times=0)
+ retry = Retry(validate_response=validate)
+
+ with httpx.Client(transport=transport) as client:
+ with pytest.raises(TypeError, match="validate_response must be a sync
function"):
+ retry_request(client, "GET", "https://example.com", retry=retry)
+
+
[email protected]
+async def test_aretry_request_success(mock_asleep: AsyncMock) -> None:
+ transport = AsyncBodyFailTransport(httpx.ReadTimeout("boom"), fail_times=0)
+
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await aretry_request(client, "GET", "https://example.com")
+
+ assert response.status_code == 200
+ assert response.text == "ok"
+ assert mock_asleep.call_count == 0
+
+
[email protected]("exc", BODY_READ_ERRORS)
[email protected]
+async def test_aretry_request_retries_body_read_error(mock_asleep: AsyncMock,
exc: Exception) -> None:
+ transport = AsyncBodyFailTransport(exc, fail_times=2)
+
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await aretry_request(client, "GET", "https://example.com")
+
+ assert response.status_code == 200
+ assert response.text == "ok"
+ assert transport.attempts == 3
+ assert mock_asleep.call_count == 2
+ assert response.extensions["retry"].attempts_made == 2
+
+
[email protected]
+async def test_aretry_request_non_retryable_method(mock_asleep: AsyncMock) ->
None:
+ transport = AsyncBodyFailTransport(httpx.ReadTimeout("boom"), fail_times=1)
+
+ async with httpx.AsyncClient(transport=transport) as client:
+ with pytest.raises(httpx.ReadTimeout, match="boom"):
+ await aretry_request(client, "POST", "https://example.com")
+
+ assert transport.attempts == 1
+ assert mock_asleep.call_count == 0
+
+
+def test_retry_request_forwards_auth(mock_sleep: MagicMock) -> None:
+ transport = RecordingTransport()
+
+ with httpx.Client(transport=transport) as client:
+ response = retry_request(client, "GET", "https://example.com",
auth=_HeaderAuth())
+
+ assert response.status_code == 200
+ assert transport.requests[0].headers["X-Auth"] == "secret"
+
+
+def test_retry_request_forwards_follow_redirects(mock_sleep: MagicMock) ->
None:
+ transport = RedirectTransport()
+
+ with httpx.Client(transport=transport) as client:
+ # Without follow_redirects, the client default (off) leaves the 302 in
place.
+ response = retry_request(client, "GET", "https://example.com/start")
+ assert response.status_code == 302
+
+ response = retry_request(client, "GET", "https://example.com/start",
follow_redirects=True)
+
+ assert response.status_code == 200
+ assert response.text == "final"
+ assert transport.paths == ["/start", "/start", "/final"]
+
+
[email protected]
+async def test_aretry_request_forwards_auth(mock_asleep: AsyncMock) -> None:
+ transport = AsyncRecordingTransport()
+
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await aretry_request(client, "GET", "https://example.com",
auth=_HeaderAuth())
+
+ assert response.status_code == 200
+ assert transport.requests[0].headers["X-Auth"] == "secret"
+
+
[email protected]
+async def test_aretry_request_forwards_follow_redirects(mock_asleep:
AsyncMock) -> None:
+ transport = AsyncRedirectTransport()
+
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await aretry_request(client, "GET",
"https://example.com/start", follow_redirects=True)
+
+ assert response.status_code == 200
+ assert response.text == "final"
+ assert transport.paths == ["/start", "/final"]
+
+
+def test_retry_request_rejects_retrying_client() -> None:
+ with
httpx.Client(transport=RetryTransport(transport=RecordingTransport())) as
client:
+ with pytest.raises(ValueError, match="would retry every request
twice"):
+ retry_request(client, "GET", "https://example.com")
+
+
+def test_retry_request_rejects_mounted_retrying_transport() -> None:
+ mounts = {"https://": RetryTransport(transport=RecordingTransport())}
+ with httpx.Client(mounts=mounts, trust_env=False) as client:
+ with pytest.raises(ValueError, match="would retry every request
twice"):
+ retry_request(client, "GET", "https://example.com")
+
+
[email protected]
+async def test_aretry_request_rejects_retrying_client() -> None:
+ async with
httpx.AsyncClient(transport=RetryTransport(transport=AsyncRecordingTransport()))
as client:
+ with pytest.raises(ValueError, match="would retry every request
twice"):
+ await aretry_request(client, "GET", "https://example.com")
+
+
[email protected]
+async def test_aretry_request_rejects_mounted_retrying_transport() -> None:
+ mounts = {"https://": RetryTransport(transport=AsyncRecordingTransport())}
+ async with httpx.AsyncClient(mounts=mounts, trust_env=False) as client:
+ with pytest.raises(ValueError, match="would retry every request
twice"):
+ await aretry_request(client, "GET", "https://example.com")
+
+
+def test_retry_request_validate_response_retries(mock_sleep: MagicMock) ->
None:
+ transport = RecordingTransport()
+ statuses: list[int] = []
+
+ def validate(response: httpx.Response) -> None:
+ statuses.append(response.status_code)
+ if len(statuses) < 3:
+ raise httpx.TimeoutException("not ready yet")
+
+ retry = Retry(total=5, validate_response=validate)
+
+ with httpx.Client(transport=transport) as client:
+ response = retry_request(client, "GET", "https://example.com",
retry=retry)
+
+ assert response.status_code == 200
+ assert statuses == [200, 200, 200]
+ assert mock_sleep.call_count == 2
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/tests/test_retry.py
new/httpx_retries-0.6.0/tests/test_retry.py
--- old/httpx_retries-0.5.0/tests/test_retry.py 2020-02-02 01:00:00.000000000
+0100
+++ new/httpx_retries-0.6.0/tests/test_retry.py 2020-02-02 01:00:00.000000000
+0100
@@ -1,6 +1,8 @@
import datetime
+import inspect
import logging
from http import HTTPStatus
+from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
@@ -550,3 +552,68 @@
retry = Retry(total=10, total_timeout=5)
retry.elapsed_sleep = 4.9
assert retry.is_exhausted() is False
+
+
+def test_copy_with_overrides_fields() -> None:
+ retry = Retry(total=10, backoff_factor=0.5, total_timeout=30.0)
+ copy = retry.copy_with(total=3, backoff_factor=1.0)
+ assert copy.total == 3
+ assert copy.backoff_factor == 1.0
+ assert copy.total_timeout == 30.0 # unchanged
+
+
+def test_copy_with_no_args_equals_original() -> None:
+ retry = Retry(total=5, backoff_factor=0.2, max_backoff_wait=60.0)
+ copy = retry.copy_with()
+ assert copy.total == retry.total
+ assert copy.backoff_factor == retry.backoff_factor
+ assert copy.max_backoff_wait == retry.max_backoff_wait
+ assert copy.allowed_methods == retry.allowed_methods
+ assert copy.status_forcelist == retry.status_forcelist
+ assert copy.retryable_exceptions == retry.retryable_exceptions
+
+
+def test_copy_with_can_set_total_timeout_to_none() -> None:
+ retry = Retry(total=5, total_timeout=10.0)
+ copy = retry.copy_with(total_timeout=None)
+ assert copy.total_timeout is None
+
+
+def test_copy_with_preserves_subclass() -> None:
+ class CustomRetry(Retry):
+ pass
+
+ retry = CustomRetry(total=5)
+ copy = retry.copy_with(total=3)
+ assert type(copy) is CustomRetry
+
+
+def test_copy_with_and_init_have_same_parameters() -> None:
+ init_params = set(inspect.signature(Retry.__init__).parameters) - {"self"}
+ copy_with_params = set(inspect.signature(Retry.copy_with).parameters) -
{"self"}
+ assert init_params == copy_with_params
+
+
+def test_copy_with_roundtrips_all_fields() -> None:
+ init_params = set(inspect.signature(Retry.__init__).parameters) - {"self"}
+ kwargs: dict[str, Any] = {
+ "total": 3,
+ "allowed_methods": {"GET", "POST"},
+ "status_forcelist": {500, 503},
+ "retry_on_exceptions": [httpx.TimeoutException],
+ "backoff_factor": 0.5,
+ "respect_retry_after_header": False,
+ "max_backoff_wait": 60.0,
+ "backoff_jitter": 0.5,
+ "attempts_made": 2,
+ "total_timeout": 30.0,
+ "elapsed_sleep": 1.5,
+ "validate_response": lambda _: None,
+ }
+ assert set(kwargs) == init_params
+
+ original = Retry(**kwargs)
+ copy = original.copy_with(**kwargs)
+
+ for attr, value in vars(original).items():
+ assert getattr(copy, attr) == value, f"{attr} mismatch"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/httpx_retries-0.5.0/tests/test_transport.py
new/httpx_retries-0.6.0/tests/test_transport.py
--- old/httpx_retries-0.5.0/tests/test_transport.py 2020-02-02
01:00:00.000000000 +0100
+++ new/httpx_retries-0.6.0/tests/test_transport.py 2020-02-02
01:00:00.000000000 +0100
@@ -9,6 +9,7 @@
from httpx import Request, Response
from httpx_retries import Retry, RetryTransport
+from httpx_retries.transport import _retry_operation, _retry_operation_async
def status_codes(
@@ -252,20 +253,19 @@
@pytest.mark.parametrize("status_code", Retry.RETRYABLE_STATUS_CODES)
def test_retry_operation_always_closes_response(status_code: int) -> None:
- transport = RetryTransport()
-
responses = []
def send_method(request: httpx.Request) -> httpx.Response:
response = Mock(spec=httpx.Response)
response.status_code = status_code
response.headers = httpx.Headers()
+ response.extensions = {}
response.close = Mock()
responses.append(response)
return response
- transport._retry_operation(request=httpx.Request("GET",
"https://example.com"), send_method=send_method)
+ _retry_operation(request=httpx.Request("GET", "https://example.com"),
send_method=send_method, retry=Retry())
assert all(r.close.called for r in responses[:-1])
@@ -522,6 +522,60 @@
assert response.status_code == 200
+def test_retry_extension_overrides_transport(mock_responses: MockResponse) ->
None:
+ mock_sleep, status_code_sequences = mock_responses
+ status_code_sequences["https://example.com/fail"] = status_codes([(429,
None)])
+ transport = RetryTransport(retry=Retry(total=10))
+
+ request = httpx.Request("GET", "https://example.com/fail",
extensions={"retry": Retry(total=2)})
+ with httpx.Client(transport=transport) as client:
+ response = client.send(request)
+
+ assert response.status_code == 429
+ assert mock_sleep.call_count == 2
+ assert response.extensions["retry"].attempts_made == 2
+
+
+def test_retry_extension_set_from_transport_when_absent(mock_responses:
MockResponse) -> None:
+ mock_sleep, _ = mock_responses
+ transport = RetryTransport(retry=Retry(total=3))
+
+ request = httpx.Request("GET", "https://example.com")
+ with httpx.Client(transport=transport) as client:
+ response = client.send(request)
+
+ assert request.extensions["retry"] is transport.retry
+ assert response.extensions["retry"] is transport.retry
+
+
[email protected]
+async def test_async_retry_extension_overrides_transport(mock_async_responses:
AsyncMockResponse) -> None:
+ mock_asleep, status_code_sequences = mock_async_responses
+ status_code_sequences["https://example.com/fail"] = astatus_codes([(429,
None)])
+ transport = RetryTransport(retry=Retry(total=10))
+
+ request = httpx.Request("GET", "https://example.com/fail",
extensions={"retry": Retry(total=2)})
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await client.send(request)
+
+ assert response.status_code == 429
+ assert mock_asleep.call_count == 2
+ assert response.extensions["retry"].attempts_made == 2
+
+
[email protected]
+async def
test_async_retry_extension_set_from_transport_when_absent(mock_async_responses:
AsyncMockResponse) -> None:
+ mock_asleep, _ = mock_async_responses
+ transport = RetryTransport(retry=Retry(total=3))
+
+ request = httpx.Request("GET", "https://example.com")
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await client.send(request)
+
+ assert request.extensions["retry"] is transport.retry
+ assert response.extensions["retry"] is transport.retry
+
+
def test_retry_after_capped_by_total_timeout(mock_responses: MockResponse) ->
None:
mock_sleep, status_code_sequences = mock_responses
status_code_sequences["https://example.com/fail"] = status_codes([(429,
"120")])
@@ -558,20 +612,21 @@
@pytest.mark.parametrize("status_code", Retry.RETRYABLE_STATUS_CODES)
@pytest.mark.asyncio
async def test_retry_operation_async_always_closes_response(status_code: int)
-> None:
- transport = RetryTransport()
-
responses = []
async def send_method(request: httpx.Request) -> httpx.Response:
response = AsyncMock(spec=httpx.Response)
response.status_code = status_code
response.headers = httpx.Headers()
+ response.extensions = {}
response.aclose = AsyncMock()
responses.append(response)
return response
- await transport._retry_operation_async(request=httpx.Request("GET",
"https://example.com"), send_method=send_method)
+ await _retry_operation_async(
+ request=httpx.Request("GET", "https://example.com"),
send_method=send_method, retry=Retry()
+ )
assert all(r.aclose.called for r in responses[:-1])
@@ -706,3 +761,166 @@
# must not mutate it, or retry budgets leak across requests.
assert transport.retry.attempts_made == 0
assert transport.retry.elapsed_sleep == 0.0
+
+
+def test_validate_response_retries_on_failure(mock_responses: MockResponse) ->
None:
+ mock_sleep, status_code_sequences = mock_responses
+ call_count = 0
+
+ def validate(response: httpx.Response) -> None:
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise httpx.TimeoutException("not ready yet")
+
+ retry = Retry(total=5, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ with httpx.Client(transport=transport) as client:
+ response = client.get("https://example.com")
+
+ assert response.status_code == 200
+ assert call_count == 3
+ assert mock_sleep.call_count == 2
+
+
+def test_validate_response_non_retryable_exception_raises(mock_responses:
MockResponse) -> None:
+ mock_sleep, _ = mock_responses
+
+ def validate(response: httpx.Response) -> None:
+ raise ValueError("bad response")
+
+ retry = Retry(total=5, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ with pytest.raises(ValueError, match="bad response"):
+ with httpx.Client(transport=transport) as client:
+ client.get("https://example.com")
+
+ assert mock_sleep.call_count == 0
+
+
+def test_validate_response_exhausted_returns_response(mock_responses:
MockResponse) -> None:
+ mock_sleep, _ = mock_responses
+
+ def validate(response: httpx.Response) -> None:
+ raise httpx.TimeoutException("always bad")
+
+ retry = Retry(total=3, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ with httpx.Client(transport=transport) as client:
+ response = client.get("https://example.com")
+
+ assert response.status_code == 200
+ assert mock_sleep.call_count == 3
+
+
+def
test_validate_response_async_callback_raises_for_sync_transport(mock_responses:
MockResponse) -> None:
+ mock_sleep, _ = mock_responses
+
+ async def validate(response: httpx.Response) -> None: # pragma: no cover
+ pass
+
+ retry = Retry(total=3, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ with pytest.raises(TypeError, match="validate_response must be a sync
function"):
+ with httpx.Client(transport=transport) as client:
+ client.get("https://example.com")
+
+
[email protected]
+async def
test_async_validate_response_retries_on_failure(mock_async_responses:
AsyncMockResponse) -> None:
+ mock_asleep, _ = mock_async_responses
+ call_count = 0
+
+ async def validate(response: httpx.Response) -> None:
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise httpx.TimeoutException("not ready yet")
+
+ retry = Retry(total=5, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await client.get("https://example.com")
+
+ assert response.status_code == 200
+ assert call_count == 3
+ assert mock_asleep.call_count == 2
+
+
[email protected]
+async def
test_async_validate_response_non_retryable_exception_raises(mock_async_responses:
AsyncMockResponse) -> None:
+ mock_asleep, _ = mock_async_responses
+
+ async def validate(response: httpx.Response) -> None:
+ raise ValueError("bad response")
+
+ retry = Retry(total=5, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ with pytest.raises(ValueError, match="bad response"):
+ async with httpx.AsyncClient(transport=transport) as client:
+ await client.get("https://example.com")
+
+ assert mock_asleep.call_count == 0
+
+
[email protected]
+async def test_async_validate_response_sync_callback(mock_async_responses:
AsyncMockResponse) -> None:
+ mock_asleep, _ = mock_async_responses
+ call_count = 0
+
+ def validate(response: httpx.Response) -> None:
+ nonlocal call_count
+ call_count += 1
+ if call_count < 2:
+ raise httpx.TimeoutException("not ready yet")
+
+ retry = Retry(total=5, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await client.get("https://example.com")
+
+ assert response.status_code == 200
+ assert call_count == 2
+ assert mock_asleep.call_count == 1
+
+
[email protected]
+async def
test_async_validate_response_exhausted_returns_response(mock_async_responses:
AsyncMockResponse) -> None:
+ mock_asleep, _ = mock_async_responses
+
+ async def validate(response: httpx.Response) -> None:
+ raise httpx.TimeoutException("always bad")
+
+ retry = Retry(total=3, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ async with httpx.AsyncClient(transport=transport) as client:
+ response = await client.get("https://example.com")
+
+ assert response.status_code == 200
+ assert mock_asleep.call_count == 3
+
+
+def test_validate_response_not_called_for_retryable_status(mock_responses:
MockResponse) -> None:
+ mock_sleep, status_code_sequences = mock_responses
+ status_code_sequences["https://example.com/fail"] = status_codes([(503,
None), (200, None)])
+ validated = []
+
+ def validate(response: httpx.Response) -> None:
+ validated.append(response.status_code)
+
+ retry = Retry(total=5, validate_response=validate)
+ transport = RetryTransport(retry=retry)
+
+ with httpx.Client(transport=transport) as client:
+ response = client.get("https://example.com/fail")
+
+ assert response.status_code == 200
+ assert validated == [200]