Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-elastic-transport for 
openSUSE:Factory checked in at 2026-07-02 20:05:52
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-elastic-transport (Old)
 and      /work/SRC/openSUSE:Factory/.python-elastic-transport.new.1982 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-elastic-transport"

Thu Jul  2 20:05:52 2026 rev:21 rq:1362531 version:9.4.2

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-elastic-transport/python-elastic-transport.changes
        2026-03-31 15:49:28.044402501 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-elastic-transport.new.1982/python-elastic-transport.changes
      2026-07-02 20:06:07.626477504 +0200
@@ -1,0 +2,8 @@
+Mon Jun 29 20:33:56 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 9.4.2:
+  * Escape percent characters in logging output (#284)
+  * Optional backoff delays between retries
+  * Add support for an httpx synchronous node
+
+-------------------------------------------------------------------

Old:
----
  elastic-transport-python-9.2.1.tar.gz

New:
----
  elastic-transport-python-9.4.2.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-elastic-transport.spec ++++++
--- /var/tmp/diff_new_pack.VgJGBY/_old  2026-07-02 20:06:08.338502036 +0200
+++ /var/tmp/diff_new_pack.VgJGBY/_new  2026-07-02 20:06:08.338502036 +0200
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-elastic-transport
-Version:        9.2.1
+Version:        9.4.2
 Release:        0
 Summary:        Transport classes and utilities shared among Python Elastic 
client libraries
 License:        Apache-2.0

++++++ elastic-transport-python-9.2.1.tar.gz -> 
elastic-transport-python-9.4.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/elastic-transport-python-9.2.1/CHANGELOG.md 
new/elastic-transport-python-9.4.2/CHANGELOG.md
--- old/elastic-transport-python-9.2.1/CHANGELOG.md     2025-12-23 
12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/CHANGELOG.md     2026-06-16 
17:26:02.000000000 +0200
@@ -1,5 +1,18 @@
 # Changelog
 
+## 9.4.2 (2026-06-16)
+
+* Escape percent characters in logging output 
([#284](https://github.com/elastic/elastic-transport-python/pull/284)) (#286)
+
+## 9.4.1 (2026-05-25)
+
+* Optional backoff delays between retries 
([#279](https://github.com/elastic/elastic-transport-python/pull/279))
+
+## 9.4.0 (2026-05-05)
+
+* Add support for an httpx synchronous node 
([#262](https://github.com/elastic/elastic-transport-python/pull/262))
+
+
 ## 9.2.1 (2025-12-23)
 
 * Use `path_prefix` to compute the base URL in the httpx node class 
([#243](https://github.com/elastic/elastic-transport-python/pull/243))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/docs/sphinx/installation.rst 
new/elastic-transport-python-9.4.2/docs/sphinx/installation.rst
--- old/elastic-transport-python-9.2.1/docs/sphinx/installation.rst     
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/docs/sphinx/installation.rst     
2026-06-16 17:26:02.000000000 +0200
@@ -11,4 +11,4 @@
 
 Install the ``aiohttp`` package to use 
:class:`elastic_transport.AiohttpHttpNode`.
 
-Install the ``httpx`` package to use 
:class:`elastic_transport.HttpxAsyncHttpNode`.
+Install the ``httpx`` package to use :class:`elastic_transport.HttpxHttpNode` 
or :class:`elastic_transport.HttpxAsyncHttpNode`.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/elastic-transport-python-9.2.1/docs/sphinx/nodes.rst 
new/elastic-transport-python-9.4.2/docs/sphinx/nodes.rst
--- old/elastic-transport-python-9.2.1/docs/sphinx/nodes.rst    2025-12-23 
12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/docs/sphinx/nodes.rst    2026-06-16 
17:26:02.000000000 +0200
@@ -22,6 +22,9 @@
 .. autoclass:: AiohttpHttpNode
    :members:
 
+.. autoclass:: HttpxHttpNode
+   :members:
+
 .. autoclass:: HttpxAsyncHttpNode
    :members:
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/__init__.py 
new/elastic-transport-python-9.4.2/elastic_transport/__init__.py
--- old/elastic-transport-python-9.2.1/elastic_transport/__init__.py    
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/__init__.py    
2026-06-16 17:26:02.000000000 +0200
@@ -37,6 +37,7 @@
     BaseAsyncNode,
     BaseNode,
     HttpxAsyncHttpNode,
+    HttpxHttpNode,
     RequestsHttpNode,
     Urllib3HttpNode,
 )
@@ -74,6 +75,7 @@
     "HeadApiResponse",
     "HttpHeaders",
     "HttpxAsyncHttpNode",
+    "HttpxHttpNode",
     "JsonSerializer",
     "ListApiResponse",
     "NdjsonSerializer",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/_async_transport.py 
new/elastic-transport-python-9.4.2/elastic_transport/_async_transport.py
--- old/elastic-transport-python-9.2.1/elastic_transport/_async_transport.py    
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/_async_transport.py    
2026-06-16 17:26:02.000000000 +0200
@@ -50,6 +50,7 @@
     NOT_DEAD_NODE_HTTP_STATUSES,
     Transport,
     TransportApiResponse,
+    backoff_time,
     validate_sniffing_options,
 )
 from .client_utils import resolve_default
@@ -79,6 +80,8 @@
         max_retries: int = 3,
         retry_on_status: Collection[int] = (429, 502, 503, 504),
         retry_on_timeout: bool = False,
+        retry_backoff_base: float = 0,
+        retry_backoff_cap: float = 0,
         sniff_on_start: bool = False,
         sniff_before_requests: bool = False,
         sniff_on_node_failure: bool = False,
@@ -114,6 +117,18 @@
             on a different node. defaults to ``(429, 502, 503, 504)``
         :arg retry_on_timeout: should timeout trigger a retry on different
             node? (default ``False``)
+        :arg retry_backoff_base: the "base" argument for the full jitter 
backoff
+            algorithm, in seconds. To enable backoff delays between retry 
attempts,
+            set this argument to a value greater than 0. Note that
+            ``retry_backoff_base`` and ``retry_backoff_cap`` must both be 
greater
+            than zero for backoff delays to be used. The default value for this
+            argument is 0.
+        :arg retry_backoff_cap: the "cap" argument for the full jitter backoff
+            algorithm, in seconds. To enable backoff delays between retry 
attempts,
+            set this argument to a positive number that is greater or equal 
than
+            ``retry_backoff_base``. Note that ``retry_backoff_base`` and
+            ``retry_backoff_cap`` must both be greater than zero for backoff 
delays
+            to be used. The default value for this argument is 0.
         :arg sniff_on_start: If ``True`` will sniff for additional nodes as 
soon
             as possible, guaranteed before the first request.
         :arg sniff_on_node_failure: If ``True`` will sniff for additional 
nodees
@@ -154,6 +169,8 @@
             max_retries=max_retries,
             retry_on_status=retry_on_status,
             retry_on_timeout=retry_on_timeout,
+            retry_backoff_base=retry_backoff_base,
+            retry_backoff_cap=retry_backoff_cap,
             sniff_timeout=sniff_timeout,
             min_delay_between_sniffing=min_delay_between_sniffing,
             meta_header=meta_header,
@@ -188,6 +205,8 @@
         max_retries: Union[int, DefaultType] = DEFAULT,
         retry_on_status: Union[Collection[int], DefaultType] = DEFAULT,
         retry_on_timeout: Union[bool, DefaultType] = DEFAULT,
+        retry_backoff_base: Union[float, DefaultType] = DEFAULT,
+        retry_backoff_cap: Union[float, DefaultType] = DEFAULT,
         request_timeout: Union[Optional[float], DefaultType] = DEFAULT,
         client_meta: Union[Tuple[Tuple[str, str], ...], DefaultType] = DEFAULT,
         otel_span: Union[OpenTelemetrySpan, DefaultType] = DEFAULT,
@@ -226,6 +245,10 @@
         max_retries = resolve_default(max_retries, self.max_retries)
         retry_on_timeout = resolve_default(retry_on_timeout, 
self.retry_on_timeout)
         retry_on_status = resolve_default(retry_on_status, 
self.retry_on_status)
+        retry_backoff_base = resolve_default(
+            retry_backoff_base, self.retry_backoff_base
+        )
+        retry_backoff_cap = resolve_default(retry_backoff_cap, 
self.retry_backoff_cap)
         otel_span = resolve_default(otel_span, OpenTelemetrySpan(None))
 
         if self.meta_header:
@@ -340,6 +363,15 @@
                     e.errors = tuple(errors)
                     raise
                 else:
+                    sleep_time = backoff_time(
+                        attempt, retry_backoff_base, retry_backoff_cap
+                    )
+                    if sleep_time:
+                        _logger.warning(
+                            "Request failure, sleeping for %.1fs before 
retrying",
+                            sleep_time,
+                        )
+                        await asyncio.sleep(sleep_time)
                     _logger.warning(
                         "Retrying request after failure (attempt %d of %d)",
                         attempt,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/_node/__init__.py 
new/elastic-transport-python-9.4.2/elastic_transport/_node/__init__.py
--- old/elastic-transport-python-9.2.1/elastic_transport/_node/__init__.py      
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/_node/__init__.py      
2026-06-16 17:26:02.000000000 +0200
@@ -18,7 +18,7 @@
 from ._base import BaseNode, NodeApiResponse
 from ._base_async import BaseAsyncNode
 from ._http_aiohttp import AiohttpHttpNode
-from ._http_httpx import HttpxAsyncHttpNode
+from ._http_httpx import HttpxAsyncHttpNode, HttpxHttpNode
 from ._http_requests import RequestsHttpNode
 from ._http_urllib3 import Urllib3HttpNode
 
@@ -29,5 +29,6 @@
     "NodeApiResponse",
     "RequestsHttpNode",
     "Urllib3HttpNode",
+    "HttpxHttpNode",
     "HttpxAsyncHttpNode",
 ]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/_node/_base.py 
new/elastic-transport-python-9.4.2/elastic_transport/_node/_base.py
--- old/elastic-transport-python-9.2.1/elastic_transport/_node/_base.py 
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/_node/_base.py 
2026-06-16 17:26:02.000000000 +0200
@@ -240,6 +240,9 @@
                     log_args.extend((http_version, meta.status))
                 if meta.headers:
                     for header, value in sorted(meta.headers.items()):
+                        # escape any % characters in the value, to avoid them 
being
+                        # misinterpreted as a logging template placeholder
+                        value = value.replace("%", "%%")
                         lines.append(f"< {header.title()}: {value}")
                 if response:
                     try:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/_node/_http_httpx.py 
new/elastic-transport-python-9.4.2/elastic_transport/_node/_http_httpx.py
--- old/elastic-transport-python-9.2.1/elastic_transport/_node/_http_httpx.py   
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/_node/_http_httpx.py   
2026-06-16 17:26:02.000000000 +0200
@@ -30,6 +30,7 @@
     BUILTIN_EXCEPTIONS,
     DEFAULT_CA_CERTS,
     RERAISE_EXCEPTIONS,
+    BaseNode,
     NodeApiResponse,
     ssl_context_from_node_config,
 )
@@ -45,6 +46,165 @@
     _HTTPX_META_VERSION = ""
 
 
+class HttpxHttpNode(BaseNode):
+    """
+    HTTP node using httpx.
+    """
+
+    _CLIENT_META_HTTP_CLIENT = ("hx", _HTTPX_META_VERSION)
+
+    def __init__(self, config: NodeConfig):
+        if not _HTTPX_AVAILABLE:  # pragma: nocover
+            raise ValueError("You must have 'httpx' installed to use 
HttpxNode")
+        super().__init__(config)
+
+        if config.ssl_assert_fingerprint:
+            raise ValueError(
+                "httpx does not support certificate pinning. 
https://github.com/encode/httpx/issues/761";
+            )
+
+        ssl_context: Union[ssl.SSLContext, Literal[False]] = False
+        if config.scheme == "https":
+            if config.ssl_context is not None:
+                ssl_context = ssl_context_from_node_config(config)
+            else:
+                ssl_context = ssl_context_from_node_config(config)
+
+                ca_certs = (
+                    DEFAULT_CA_CERTS if config.ca_certs is None else 
config.ca_certs
+                )
+                if config.verify_certs:
+                    if not ca_certs:
+                        raise ValueError(
+                            "Root certificates are missing for certificate "
+                            "validation. Either pass them in using the 
ca_certs parameter or "
+                            "install certifi to use it automatically."
+                        )
+                else:
+                    if config.ssl_show_warn:
+                        warnings.warn(
+                            f"Connecting to {self.base_url!r} using TLS with 
verify_certs=False is insecure",
+                            stacklevel=warn_stacklevel(),
+                            category=SecurityWarning,
+                        )
+
+                if ca_certs is not None:
+                    if os.path.isfile(ca_certs):
+                        ssl_context.load_verify_locations(cafile=ca_certs)
+                    elif os.path.isdir(ca_certs):
+                        ssl_context.load_verify_locations(capath=ca_certs)
+                    else:
+                        raise ValueError("ca_certs parameter is not a path")
+
+                # Use client_cert and client_key variables for SSL certificate 
configuration.
+                if config.client_cert and not 
os.path.isfile(config.client_cert):
+                    raise ValueError("client_cert is not a path to a file")
+                if config.client_key and not os.path.isfile(config.client_key):
+                    raise ValueError("client_key is not a path to a file")
+                if config.client_cert and config.client_key:
+                    ssl_context.load_cert_chain(config.client_cert, 
config.client_key)
+                elif config.client_cert:
+                    ssl_context.load_cert_chain(config.client_cert)
+
+        self.client = httpx.Client(
+            
base_url=f"{config.scheme}://{config.host}:{config.port}{config.path_prefix}",
+            limits=httpx.Limits(max_connections=config.connections_per_node),
+            verify=ssl_context or False,
+            timeout=config.request_timeout,
+        )
+
+    def perform_request(
+        self,
+        method: str,
+        target: str,
+        body: Optional[bytes] = None,
+        headers: Optional[HttpHeaders] = None,
+        request_timeout: Union[DefaultType, Optional[float]] = DEFAULT,
+    ) -> NodeApiResponse:
+        resolved_headers = self._headers.copy()
+        if headers:
+            resolved_headers.update(headers)
+
+        if body:
+            if self._http_compress:
+                resolved_body = gzip.compress(body)
+                resolved_headers["content-encoding"] = "gzip"
+            else:
+                resolved_body = body
+        else:
+            resolved_body = None
+
+        try:
+            start = time.perf_counter()
+            if request_timeout is DEFAULT:
+                resp = self.client.request(
+                    method,
+                    target,
+                    content=resolved_body,
+                    headers=dict(resolved_headers),
+                )
+            else:
+                resp = self.client.request(
+                    method,
+                    target,
+                    content=resolved_body,
+                    headers=dict(resolved_headers),
+                    timeout=request_timeout,
+                )
+            response_body = resp.read()
+            duration = time.perf_counter() - start
+        except RERAISE_EXCEPTIONS + BUILTIN_EXCEPTIONS:
+            raise
+        except Exception as e:
+            err: Exception
+            if isinstance(e, (TimeoutError, httpx.TimeoutException)):
+                err = ConnectionTimeout(
+                    "Connection timed out during request", errors=(e,)
+                )
+            elif isinstance(e, ssl.SSLError):
+                err = TlsError(str(e), errors=(e,))
+            # Detect SSL errors for httpx v0.28.0+
+            # Needed until https://github.com/encode/httpx/issues/3350 is fixed
+            elif isinstance(e, httpx.ConnectError) and e.__cause__:
+                context = e.__cause__.__context__
+                if isinstance(context, ssl.SSLError):
+                    err = TlsError(str(context), errors=(e,))
+                else:
+                    err = ConnectionError(str(e), errors=(e,))
+            else:
+                err = ConnectionError(str(e), errors=(e,))
+            self._log_request(
+                method=method,
+                target=target,
+                headers=resolved_headers,
+                body=body,
+                exception=err,
+            )
+            raise err from e
+
+        meta = ApiResponseMeta(
+            resp.status_code,
+            resp.http_version.lstrip("HTTP/"),
+            HttpHeaders(resp.headers),
+            duration,
+            self.config,
+        )
+
+        self._log_request(
+            method=method,
+            target=target,
+            headers=resolved_headers,
+            body=body,
+            meta=meta,
+            response=response_body,
+        )
+
+        return NodeApiResponse(meta, response_body)
+
+    def close(self) -> None:
+        self.client.close()
+
+
 class HttpxAsyncHttpNode(BaseAsyncNode):
     """
     Async HTTP node using httpx. Supports both Trio and asyncio.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/_node/_http_requests.py 
new/elastic-transport-python-9.4.2/elastic_transport/_node/_http_requests.py
--- 
old/elastic-transport-python-9.2.1/elastic_transport/_node/_http_requests.py    
    2025-12-23 12:43:38.000000000 +0100
+++ 
new/elastic-transport-python-9.4.2/elastic_transport/_node/_http_requests.py    
    2026-06-16 17:26:02.000000000 +0200
@@ -81,7 +81,7 @@
                     pool_kwargs["cert_reqs"] = "CERT_NONE"
                     pool_kwargs["assert_hostname"] = False
 
-            super().init_poolmanager(connections, maxsize, block=block, 
**pool_kwargs)  # type: ignore [no-untyped-call]
+            super().init_poolmanager(connections, maxsize, block=block, 
**pool_kwargs)
             self.poolmanager.pool_classes_by_scheme["https"] = 
HTTPSConnectionPool
 
 except ImportError:  # pragma: nocover
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/_node/_http_urllib3.py 
new/elastic-transport-python-9.4.2/elastic_transport/_node/_http_urllib3.py
--- old/elastic-transport-python-9.2.1/elastic_transport/_node/_http_urllib3.py 
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/_node/_http_urllib3.py 
2026-06-16 17:26:02.000000000 +0200
@@ -24,7 +24,7 @@
 try:
     from importlib import metadata
 except ImportError:
-    import importlib_metadata as metadata  # type: ignore[no-redef]
+    import importlib_metadata as metadata  # type: ignore[no-redef, 
import-not-found]
 
 import urllib3
 from urllib3.exceptions import ConnectTimeoutError, NewConnectionError, 
ReadTimeoutError
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/_transport.py 
new/elastic-transport-python-9.4.2/elastic_transport/_transport.py
--- old/elastic-transport-python-9.2.1/elastic_transport/_transport.py  
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/_transport.py  
2026-06-16 17:26:02.000000000 +0200
@@ -18,6 +18,7 @@
 import dataclasses
 import inspect
 import logging
+import random
 import time
 import warnings
 from platform import python_version
@@ -56,6 +57,7 @@
     AiohttpHttpNode,
     BaseNode,
     HttpxAsyncHttpNode,
+    HttpxHttpNode,
     RequestsHttpNode,
     Urllib3HttpNode,
 )
@@ -70,6 +72,7 @@
     "urllib3": Urllib3HttpNode,
     "requests": RequestsHttpNode,
     "aiohttp": AiohttpHttpNode,
+    "httpx": HttpxHttpNode,
     "httpxasync": HttpxAsyncHttpNode,
 }
 # These are HTTP status errors that shouldn't be considered
@@ -87,6 +90,12 @@
     body: Any
 
 
+def backoff_time(attempts: int, base: float = 1, cap: float = 60) -> float:
+    # Equal Jitter from 
https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
+    temp: float = min(cap, base * 2 ** (attempts - 1)) / 2
+    return temp + random.uniform(0, temp)
+
+
 class Transport:
     """
     Encapsulation of transport-related to logic. Handles instantiation of the
@@ -109,6 +118,8 @@
         max_retries: int = 3,
         retry_on_status: Collection[int] = (429, 502, 503, 504),
         retry_on_timeout: bool = False,
+        retry_backoff_base: float = 0,
+        retry_backoff_cap: float = 0,
         sniff_on_start: bool = False,
         sniff_before_requests: bool = False,
         sniff_on_node_failure: bool = False,
@@ -144,6 +155,18 @@
             on a different node. defaults to ``(429, 502, 503, 504)``
         :arg retry_on_timeout: should timeout trigger a retry on different
             node? (default ``False``)
+        :arg retry_backoff_base: the "base" argument for the full jitter 
backoff
+            algorithm, in seconds. To enable backoff delays between retry 
attempts,
+            set this argument to a value greater than 0. Note that
+            ``retry_backoff_base`` and ``retry_backoff_cap`` must both be 
greater
+            than zero for backoff delays to be used. The default value for this
+            argument is 0.
+        :arg retry_backoff_cap: the "cap" argument for the full jitter backoff
+            algorithm, in seconds. To enable backoff delays between retry 
attempts,
+            set this argument to a positive number that is greater or equal 
than
+            ``retry_backoff_base``. Note that ``retry_backoff_base`` and
+            ``retry_backoff_cap`` must both be greater than zero for backoff 
delays
+            to be used. The default value for this argument is 0.
         :arg sniff_on_start: If ``True`` will sniff for additional nodes as 
soon
             as possible, guaranteed before the first request.
         :arg sniff_on_node_failure: If ``True`` will sniff for additional 
nodees
@@ -225,6 +248,8 @@
         self.max_retries = max_retries
         self.retry_on_status = retry_on_status
         self.retry_on_timeout = retry_on_timeout
+        self.retry_backoff_base = retry_backoff_base
+        self.retry_backoff_cap = retry_backoff_cap
 
         # Build the NodePool from all the options
         node_pool_kwargs: Dict[str, Any] = {}
@@ -263,6 +288,8 @@
         max_retries: Union[int, DefaultType] = DEFAULT,
         retry_on_status: Union[Collection[int], DefaultType] = DEFAULT,
         retry_on_timeout: Union[bool, DefaultType] = DEFAULT,
+        retry_backoff_base: Union[float, DefaultType] = DEFAULT,
+        retry_backoff_cap: Union[float, DefaultType] = DEFAULT,
         request_timeout: Union[Optional[float], DefaultType] = DEFAULT,
         client_meta: Union[Tuple[Tuple[str, str], ...], DefaultType] = DEFAULT,
         otel_span: Union[OpenTelemetrySpan, DefaultType] = DEFAULT,
@@ -300,6 +327,10 @@
         max_retries = resolve_default(max_retries, self.max_retries)
         retry_on_timeout = resolve_default(retry_on_timeout, 
self.retry_on_timeout)
         retry_on_status = resolve_default(retry_on_status, 
self.retry_on_status)
+        retry_backoff_base = resolve_default(
+            retry_backoff_base, self.retry_backoff_base
+        )
+        retry_backoff_cap = resolve_default(retry_backoff_cap, 
self.retry_backoff_cap)
         otel_span = resolve_default(otel_span, OpenTelemetrySpan(None))
 
         if self.meta_header:
@@ -414,6 +445,15 @@
                     e.errors = tuple(errors)
                     raise
                 else:
+                    sleep_time = backoff_time(
+                        attempt, retry_backoff_base, retry_backoff_cap
+                    )
+                    if sleep_time:
+                        _logger.warning(
+                            "Request failure, sleeping for %.1fs before 
retrying",
+                            sleep_time,
+                        )
+                        time.sleep(sleep_time)
                     _logger.warning(
                         "Retrying request after failure (attempt %d of %d)",
                         attempt,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/_version.py 
new/elastic-transport-python-9.4.2/elastic_transport/_version.py
--- old/elastic-transport-python-9.2.1/elastic_transport/_version.py    
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/_version.py    
2026-06-16 17:26:02.000000000 +0200
@@ -15,4 +15,4 @@
 #  specific language governing permissions and limitations
 #  under the License.
 
-__version__ = "9.2.1"
+__version__ = "9.4.2"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/elastic_transport/client_utils.py 
new/elastic-transport-python-9.4.2/elastic_transport/client_utils.py
--- old/elastic-transport-python-9.2.1/elastic_transport/client_utils.py        
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/elastic_transport/client_utils.py        
2026-06-16 17:26:02.000000000 +0200
@@ -150,12 +150,6 @@
     return value
 
 
-# Python 3.7 added '~' to the safe list for urllib.parse.quote()
-_QUOTE_ALWAYS_SAFE = frozenset(
-    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~"
-)
-
-
 def percent_encode(
     string: Union[bytes, str],
     safe: str = "/",
@@ -163,9 +157,8 @@
     errors: Optional[str] = None,
 ) -> str:
     """Percent-encodes a string so it can be used in an HTTP request target"""
-    # Redefines 'urllib.parse.quote()' to always have the '~' character
-    # within the 'ALWAYS_SAFE' list. The character was added in Python 3.7
-    safe = "".join(_QUOTE_ALWAYS_SAFE.union(set(safe)))
+    # This function used to add `~` to unreserverd characters, but this was 
fixed in Python 3.7.
+    # Keeping the function here as it is part of the public API.
     return _quote(string, safe, encoding=encoding, errors=errors)  # type: 
ignore[arg-type]
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/elastic-transport-python-9.2.1/requirements-min.txt 
new/elastic-transport-python-9.4.2/requirements-min.txt
--- old/elastic-transport-python-9.2.1/requirements-min.txt     2025-12-23 
12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/requirements-min.txt     2026-06-16 
17:26:02.000000000 +0200
@@ -1,4 +1,4 @@
-requests==2.26.0
-urllib3==1.26.2
+requests==2.30.0
+urllib3==2.0.3
 aiohttp==3.8.0
 httpx==0.27.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/elastic-transport-python-9.2.1/setup.py 
new/elastic-transport-python-9.4.2/setup.py
--- old/elastic-transport-python-9.2.1/setup.py 2025-12-23 12:43:38.000000000 
+0100
+++ new/elastic-transport-python-9.4.2/setup.py 2026-06-16 17:26:02.000000000 
+0200
@@ -50,7 +50,7 @@
     package_data={"elastic_transport": ["py.typed"]},
     packages=packages,
     install_requires=[
-        "urllib3>=1.26.2, <3",
+        "urllib3>=2, <3",
         "certifi",
         "sniffio",
     ],
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/tests/async_/test_async_transport.py 
new/elastic-transport-python-9.4.2/tests/async_/test_async_transport.py
--- old/elastic-transport-python-9.2.1/tests/async_/test_async_transport.py     
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/tests/async_/test_async_transport.py     
2026-06-16 17:26:02.000000000 +0200
@@ -240,6 +240,34 @@
 
 
 @pytest.mark.anyio
+async def test_request_retry_backoff():
+    t = AsyncTransport(
+        [
+            NodeConfig(
+                "http",
+                "localhost",
+                80,
+                _extras={"exception": ConnectionError("abandon ship")},
+            )
+        ],
+        node_class=AsyncDummyNode,
+        retry_backoff_base=1,
+        retry_backoff_cap=2,
+    )
+
+    with mock.patch("elastic_transport._async_transport.asyncio.sleep") as 
mock_sleep:
+        with pytest.raises(ConnectionError) as e:
+            await t.perform_request("GET", "/")
+
+    assert 4 == len(t.node_pool.get().calls)
+    assert len(e.value.errors) == 3
+    assert all(isinstance(error, ConnectionError) for error in e.value.errors)
+
+    assert mock_sleep.await_count == 3
+    assert all(0 < arg[0][0] <= 2 for arg in mock_sleep.await_args_list)
+
+
[email protected]
 async def test_failed_connection_will_be_marked_as_dead():
     t = AsyncTransport(
         [
@@ -322,7 +350,7 @@
         AsyncTransport([NodeConfig("http", "localhost", 80)], 
node_class="huh?")
     assert str(e.value) == (
         "Unknown option for node_class: 'huh?'. "
-        "Available options are: 'aiohttp', 'httpxasync', 'requests', 'urllib3'"
+        "Available options are: 'aiohttp', 'httpx', 'httpxasync', 'requests', 
'urllib3'"
     )
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/tests/node/test_base.py 
new/elastic-transport-python-9.4.2/tests/node/test_base.py
--- old/elastic-transport-python-9.2.1/tests/node/test_base.py  2025-12-23 
12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/tests/node/test_base.py  2026-06-16 
17:26:02.000000000 +0200
@@ -20,6 +20,7 @@
 from elastic_transport import (
     AiohttpHttpNode,
     HttpxAsyncHttpNode,
+    HttpxHttpNode,
     NodeConfig,
     RequestsHttpNode,
     Urllib3HttpNode,
@@ -28,7 +29,14 @@
 
 
 @pytest.mark.parametrize(
-    "node_cls", [Urllib3HttpNode, RequestsHttpNode, AiohttpHttpNode, 
HttpxAsyncHttpNode]
+    "node_cls",
+    [
+        Urllib3HttpNode,
+        RequestsHttpNode,
+        AiohttpHttpNode,
+        HttpxHttpNode,
+        HttpxAsyncHttpNode,
+    ],
 )
 def test_unknown_parameter(node_cls):
     with pytest.raises(TypeError):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/tests/node/test_http_httpx.py 
new/elastic-transport-python-9.4.2/tests/node/test_http_httpx.py
--- old/elastic-transport-python-9.2.1/tests/node/test_http_httpx.py    
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/tests/node/test_http_httpx.py    
2026-06-16 17:26:02.000000000 +0200
@@ -22,11 +22,141 @@
 import pytest
 import respx
 
-from elastic_transport import HttpxAsyncHttpNode, NodeConfig
+from elastic_transport import HttpxAsyncHttpNode, HttpxHttpNode, NodeConfig
 from elastic_transport._node._base import DEFAULT_USER_AGENT
 
 
-def create_node(node_config: NodeConfig):
+def create_sync_node(node_config: NodeConfig):
+    return HttpxHttpNode(node_config)
+
+
+class TestHttpxNodeCreation:
+    def test_ssl_context(self):
+        ssl_context = ssl.create_default_context()
+        with warnings.catch_warnings(record=True) as w:
+            node = create_sync_node(
+                NodeConfig(
+                    scheme="https",
+                    host="localhost",
+                    port=80,
+                    ssl_context=ssl_context,
+                )
+            )
+        assert node.client._transport._pool._ssl_context is ssl_context
+        assert len(w) == 0
+
+    def test_uses_https_if_verify_certs_is_off(self):
+        with warnings.catch_warnings(record=True) as w:
+            _ = create_sync_node(
+                NodeConfig("https", "localhost", 443, verify_certs=False)
+            )
+        assert (
+            str(w[0].message)
+            == "Connecting to 'https://localhost:443' using TLS with 
verify_certs=False is insecure"
+        )
+
+    def test_no_warn_when_uses_https_if_verify_certs_is_off(self):
+        with warnings.catch_warnings(record=True) as w:
+            _ = create_sync_node(
+                NodeConfig(
+                    "https",
+                    "localhost",
+                    443,
+                    verify_certs=False,
+                    ssl_show_warn=False,
+                )
+            )
+        assert 0 == len(w)
+
+    def test_ca_certs_with_verify_ssl_false_raises_error(self):
+        with pytest.raises(ValueError) as exc:
+            create_sync_node(
+                NodeConfig(
+                    "https",
+                    "localhost",
+                    443,
+                    ca_certs="/ca/certs",
+                    verify_certs=False,
+                )
+            )
+            assert (
+                str(exc.value) == "You cannot use 'ca_certs' when 
'verify_certs=False'"
+            )
+
+    def test_path_prefix(self):
+        node = create_sync_node(
+            NodeConfig(
+                "http",
+                "localhost",
+                9200,
+                path_prefix="/test",
+            )
+        )
+        assert node.base_url == "http://localhost:9200/test";
+        assert node.client.base_url == "http://localhost:9200/test/";
+
+
+class TestHttpxNode:
+    @respx.mock
+    def test_simple_request(self):
+        node = create_sync_node(NodeConfig(scheme="http", host="localhost", 
port=80))
+        respx.get("http://localhost/index";)
+        node.perform_request("GET", "/index", b"hello world", headers={"key": 
"value"})
+        request = respx.calls.last.request
+        assert request.content == b"hello world"
+        assert {
+            "key": "value",
+            "connection": "keep-alive",
+            "user-agent": DEFAULT_USER_AGENT,
+        }.items() <= request.headers.items()
+
+    @respx.mock
+    def test_compression(self):
+        node = create_sync_node(
+            NodeConfig(scheme="http", host="localhost", port=80, 
http_compress=True)
+        )
+        respx.get("http://localhost/index";)
+        node.perform_request("GET", "/index", b"hello world")
+        request = respx.calls.last.request
+        assert gzip.decompress(request.content) == b"hello world"
+        assert {"content-encoding": "gzip"}.items() <= request.headers.items()
+
+    @respx.mock
+    def test_default_timeout(self):
+        node = create_sync_node(
+            NodeConfig(scheme="http", host="localhost", port=80, 
request_timeout=10)
+        )
+        respx.get("http://localhost/index";)
+        node.perform_request("GET", "/index", b"hello world")
+        request = respx.calls.last.request
+        assert request.extensions["timeout"]["connect"] == 10
+
+    @respx.mock
+    def test_overwritten_timeout(self):
+        node = create_sync_node(
+            NodeConfig(scheme="http", host="localhost", port=80, 
request_timeout=10)
+        )
+        respx.get("http://localhost/index";)
+        node.perform_request("GET", "/index", b"hello world", 
request_timeout=15)
+        request = respx.calls.last.request
+        assert request.extensions["timeout"]["connect"] == 15
+
+    @respx.mock
+    def test_merge_headers(self):
+        node = create_sync_node(
+            NodeConfig("http", "localhost", 80, headers={"h1": "v1", "h2": 
"v2"})
+        )
+        respx.get("http://localhost/index";)
+        node.perform_request(
+            "GET", "/index", b"hello world", headers={"h2": "v2p", "h3": "v3"}
+        )
+        request = respx.calls.last.request
+        assert request.headers["h1"] == "v1"
+        assert request.headers["h2"] == "v2p"
+        assert request.headers["h3"] == "v3"
+
+
+def create_async_node(node_config: NodeConfig):
     return HttpxAsyncHttpNode(node_config)
 
 
@@ -34,7 +164,7 @@
     def test_ssl_context(self):
         ssl_context = ssl.create_default_context()
         with warnings.catch_warnings(record=True) as w:
-            node = create_node(
+            node = create_async_node(
                 NodeConfig(
                     scheme="https",
                     host="localhost",
@@ -47,7 +177,9 @@
 
     def test_uses_https_if_verify_certs_is_off(self):
         with warnings.catch_warnings(record=True) as w:
-            _ = create_node(NodeConfig("https", "localhost", 443, 
verify_certs=False))
+            _ = create_async_node(
+                NodeConfig("https", "localhost", 443, verify_certs=False)
+            )
         assert (
             str(w[0].message)
             == "Connecting to 'https://localhost:443' using TLS with 
verify_certs=False is insecure"
@@ -55,7 +187,7 @@
 
     def test_no_warn_when_uses_https_if_verify_certs_is_off(self):
         with warnings.catch_warnings(record=True) as w:
-            _ = create_node(
+            _ = create_async_node(
                 NodeConfig(
                     "https",
                     "localhost",
@@ -68,7 +200,7 @@
 
     def test_ca_certs_with_verify_ssl_false_raises_error(self):
         with pytest.raises(ValueError) as exc:
-            create_node(
+            create_async_node(
                 NodeConfig(
                     "https",
                     "localhost",
@@ -82,7 +214,7 @@
             )
 
     def test_path_prefix(self):
-        node = create_node(
+        node = create_async_node(
             NodeConfig(
                 "http",
                 "localhost",
@@ -98,7 +230,7 @@
 class TestHttpxAsyncNode:
     @respx.mock
     async def test_simple_request(self):
-        node = create_node(NodeConfig(scheme="http", host="localhost", 
port=80))
+        node = create_async_node(NodeConfig(scheme="http", host="localhost", 
port=80))
         respx.get("http://localhost/index";)
         await node.perform_request(
             "GET", "/index", b"hello world", headers={"key": "value"}
@@ -113,7 +245,7 @@
 
     @respx.mock
     async def test_compression(self):
-        node = create_node(
+        node = create_async_node(
             NodeConfig(scheme="http", host="localhost", port=80, 
http_compress=True)
         )
         respx.get("http://localhost/index";)
@@ -124,7 +256,7 @@
 
     @respx.mock
     async def test_default_timeout(self):
-        node = create_node(
+        node = create_async_node(
             NodeConfig(scheme="http", host="localhost", port=80, 
request_timeout=10)
         )
         respx.get("http://localhost/index";)
@@ -134,7 +266,7 @@
 
     @respx.mock
     async def test_overwritten_timeout(self):
-        node = create_node(
+        node = create_async_node(
             NodeConfig(scheme="http", host="localhost", port=80, 
request_timeout=10)
         )
         respx.get("http://localhost/index";)
@@ -144,7 +276,7 @@
 
     @respx.mock
     async def test_merge_headers(self):
-        node = create_node(
+        node = create_async_node(
             NodeConfig("http", "localhost", 80, headers={"h1": "v1", "h2": 
"v2"})
         )
         respx.get("http://localhost/index";)
@@ -157,9 +289,10 @@
         assert request.headers["h3"] == "v3"
 
 
-def test_ssl_assert_fingerprint(cert_fingerprint, httpbin_secure):
[email protected]("node_class", [HttpxHttpNode, HttpxAsyncHttpNode])
+def test_ssl_assert_fingerprint(node_class, cert_fingerprint, httpbin_secure):
     with pytest.raises(ValueError, match="httpx does not support certificate 
pinning"):
-        HttpxAsyncHttpNode(
+        node_class(
             NodeConfig(
                 scheme="https",
                 host=httpbin_secure.host,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/tests/node/test_tls_versions.py 
new/elastic-transport-python-9.4.2/tests/node/test_tls_versions.py
--- old/elastic-transport-python-9.2.1/tests/node/test_tls_versions.py  
2025-12-23 12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/tests/node/test_tls_versions.py  
2026-06-16 17:26:02.000000000 +0200
@@ -25,6 +25,7 @@
     AiohttpHttpNode,
     ConnectionError,
     HttpxAsyncHttpNode,
+    HttpxHttpNode,
     NodeConfig,
     RequestsHttpNode,
     TlsError,
@@ -39,7 +40,13 @@
 
 node_classes = pytest.mark.parametrize(
     "node_class",
-    [AiohttpHttpNode, Urllib3HttpNode, RequestsHttpNode, HttpxAsyncHttpNode],
+    [
+        AiohttpHttpNode,
+        Urllib3HttpNode,
+        RequestsHttpNode,
+        HttpxHttpNode,
+        HttpxAsyncHttpNode,
+    ],
 )
 
 supported_version_params = [
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/elastic-transport-python-9.2.1/tests/test_httpbin.py 
new/elastic-transport-python-9.4.2/tests/test_httpbin.py
--- old/elastic-transport-python-9.2.1/tests/test_httpbin.py    2025-12-23 
12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/tests/test_httpbin.py    2026-06-16 
17:26:02.000000000 +0200
@@ -25,7 +25,7 @@
 from elastic_transport._transport import NODE_CLASS_NAMES
 
 
[email protected]("node_class", ["urllib3", "requests"])
[email protected]("node_class", ["urllib3", "requests", "httpx"])
 def test_simple_request(node_class, httpbin_node_config, httpbin):
     t = Transport([httpbin_node_config], node_class=node_class)
 
@@ -58,7 +58,7 @@
     assert all(v == data["headers"][k] for k, v in request_headers.items())
 
 
[email protected]("node_class", ["urllib3", "requests"])
[email protected]("node_class", ["urllib3", "requests", "httpx"])
 def test_node(node_class, httpbin_node_config, httpbin):
     def new_node(**kwargs):
         return NODE_CLASS_NAMES[node_class](
@@ -70,8 +70,12 @@
     assert resp.status == 200
     parsed = parse_httpbin(data)
     assert parsed == {
-        "headers": {
-            "Accept-Encoding": "identity",
+        "headers": (
+            {"Accept": "*/*", "Accept-Encoding": "gzip, deflate, br"}
+            if node_class == "httpx"
+            else {"Accept-Encoding": "identity"}
+        )
+        | {
             "Connection": "keep-alive",
             "Host": f"{httpbin.host}:{httpbin.port}",
             "User-Agent": DEFAULT_USER_AGENT,
@@ -85,7 +89,8 @@
     assert resp.status == 200
     parsed = parse_httpbin(data)
     assert parsed == {
-        "headers": {
+        "headers": ({"Accept": "*/*"} if node_class == "httpx" else {})
+        | {
             "Accept-Encoding": "gzip",
             "Connection": "keep-alive",
             "Host": f"{httpbin.host}:{httpbin.port}",
@@ -99,7 +104,8 @@
     assert resp.status == 200
     parsed = parse_httpbin(data)
     assert parsed == {
-        "headers": {
+        "headers": ({"Accept": "*/*"} if node_class == "httpx" else {})
+        | {
             "Accept-Encoding": "gzip",
             "Content-Encoding": "gzip",
             "Content-Length": "33",
@@ -120,7 +126,8 @@
     assert resp.status == 200
     parsed = parse_httpbin(data)
     assert parsed == {
-        "headers": {
+        "headers": ({"Accept": "*/*"} if node_class == "httpx" else {})
+        | {
             "Accept-Encoding": "gzip",
             "Content-Encoding": "gzip",
             "Content-Length": "36",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/tests/test_httpserver.py 
new/elastic-transport-python-9.4.2/tests/test_httpserver.py
--- old/elastic-transport-python-9.2.1/tests/test_httpserver.py 2025-12-23 
12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/tests/test_httpserver.py 2026-06-16 
17:26:02.000000000 +0200
@@ -18,27 +18,14 @@
 import warnings
 
 import pytest
-import requests
-import urllib3
 
 from elastic_transport import Transport
 
 
 @pytest.mark.parametrize("node_class", ["urllib3", "requests"])
 def test_simple_request(node_class, https_server_ip_node_config):
-    # when testing minimum urllib3 and requests dependencies, we disable
-    # the deprecation warning for ssl.match_hostname()
-    silence_ssl_deprecation = (
-        node_class == "urllib3" and urllib3.__version__ == "1.26.2"
-    ) or (node_class == "requests" and requests.__version__ == "2.26.0")
-
     with warnings.catch_warnings():
         warnings.simplefilter("error")
-        if silence_ssl_deprecation:
-            warnings.filterwarnings(
-                "ignore", ".*match_hostname.*deprecated", DeprecationWarning
-            )
-
         t = Transport([https_server_ip_node_config], node_class=node_class)
 
         resp, data = t.perform_request("GET", "/foobar")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/elastic-transport-python-9.2.1/tests/test_logging.py 
new/elastic-transport-python-9.4.2/tests/test_logging.py
--- old/elastic-transport-python-9.2.1/tests/test_logging.py    2025-12-23 
12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/tests/test_logging.py    2026-06-16 
17:26:02.000000000 +0200
@@ -25,6 +25,7 @@
     ConnectionError,
     HttpHeaders,
     HttpxAsyncHttpNode,
+    HttpxHttpNode,
     RequestsHttpNode,
     Urllib3HttpNode,
     debug_logging,
@@ -34,7 +35,13 @@
 
 node_class = pytest.mark.parametrize(
     "node_class",
-    [Urllib3HttpNode, RequestsHttpNode, AiohttpHttpNode, HttpxAsyncHttpNode],
+    [
+        Urllib3HttpNode,
+        RequestsHttpNode,
+        AiohttpHttpNode,
+        HttpxAsyncHttpNode,
+        HttpxHttpNode,
+    ],
 )
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/elastic-transport-python-9.2.1/tests/test_transport.py 
new/elastic-transport-python-9.4.2/tests/test_transport.py
--- old/elastic-transport-python-9.2.1/tests/test_transport.py  2025-12-23 
12:43:38.000000000 +0100
+++ new/elastic-transport-python-9.4.2/tests/test_transport.py  2026-06-16 
17:26:02.000000000 +0200
@@ -29,6 +29,7 @@
     AiohttpHttpNode,
     ConnectionError,
     ConnectionTimeout,
+    HttpxHttpNode,
     NodeConfig,
     RequestsHttpNode,
     SniffingError,
@@ -38,6 +39,7 @@
     TransportWarning,
     Urllib3HttpNode,
 )
+from elastic_transport._transport import backoff_time
 from elastic_transport.client_utils import DEFAULT
 from tests.conftest import DummyNode
 
@@ -249,6 +251,34 @@
     ]
 
 
+def test_request_retry_backoff():
+    t = Transport(
+        [
+            NodeConfig(
+                "http",
+                "localhost",
+                80,
+                _extras={"exception": ConnectionError("abandon ship")},
+            )
+        ],
+        node_class=DummyNode,
+        max_retries=3,
+        retry_backoff_base=1,
+        retry_backoff_cap=2,
+    )
+
+    with mock.patch("elastic_transport._transport.time.sleep") as mock_sleep:
+        with pytest.raises(ConnectionError) as e:
+            t.perform_request("GET", "/")
+
+    assert 4 == len(t.node_pool.get().calls)
+    assert len(e.value.errors) == 3
+    assert all(isinstance(error, ConnectionError) for error in e.value.errors)
+
+    assert mock_sleep.call_count == 3
+    assert all(0 < arg[0][0] <= 2 for arg in mock_sleep.call_args_list)
+
+
 def test_failed_connection_will_be_marked_as_dead():
     t = Transport(
         [
@@ -329,7 +359,7 @@
         Transport([NodeConfig("http", "localhost", 80)], node_class="huh?")
     assert str(e.value) == (
         "Unknown option for node_class: 'huh?'. "
-        "Available options are: 'aiohttp', 'httpxasync', 'requests', 'urllib3'"
+        "Available options are: 'aiohttp', 'httpx', 'httpxasync', 'requests', 
'urllib3'"
     )
 
 
@@ -358,16 +388,16 @@
 
 @pytest.mark.parametrize(
     "node_class",
-    ["urllib3", "requests", Urllib3HttpNode, RequestsHttpNode],
+    ["urllib3", "requests", "httpx", Urllib3HttpNode, RequestsHttpNode, 
HttpxHttpNode],
 )
 def test_transport_client_meta_node_class(node_class):
     t = Transport([NodeConfig("http", "localhost", 80)], node_class=node_class)
     assert (
         t._transport_client_meta[3] == 
t.node_pool.node_class._CLIENT_META_HTTP_CLIENT
     )
-    assert t._transport_client_meta[3][0] in ("ur", "rq")
+    assert t._transport_client_meta[3][0] in ("ur", "rq", "hx")
     assert re.match(
-        r"^et=[0-9.]+p?,py=[0-9.]+p?,t=[0-9.]+p?,(?:ur|rq)=[0-9.]+p?$",
+        r"^et=[0-9.]+p?,py=[0-9.]+p?,t=[0-9.]+p?,(?:ur|rq|hx)=[0-9.]+p?$",
         ",".join(f"{k}={v}" for k, v in t._transport_client_meta),
     )
 
@@ -670,3 +700,23 @@
     resp = t.perform_request("GET", "/anything")
     assert resp.meta.status == 200
     assert isinstance(resp.body, dict)
+
+
+def test_backoff_time():
+    for i in range(1, 11):
+        assert backoff_time(i, 0, 0) == 0
+    for i in range(1, 11):
+        assert backoff_time(i, 0, 1) == 0
+    exp = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
+    for i in range(10):
+        ceiling = min(1, 0.1 * exp[i])
+        assert ceiling / 2 <= backoff_time(i + 1, 0.1, 1) <= ceiling
+    for i in range(10):
+        ceiling = min(1, exp[i])
+        assert ceiling / 2 <= backoff_time(i + 1, 1, 1) <= ceiling
+    for i in range(10):
+        ceiling = min(100, exp[i])
+        assert ceiling / 2 <= backoff_time(i + 1, 1, 100) <= ceiling
+    for i in range(10):
+        ceiling = min(600, 60 * exp[i])
+        assert ceiling / 2 <= backoff_time(i + 1, 60, 600) <= ceiling

Reply via email to