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]

Reply via email to