From 321a31d9f52cefe2ba4685e47462438be8643349 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 22 Jun 2026 14:52:00 +0000 Subject: [PATCH 1/7] fix: retry transient connection errors on all HTTP requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only fetch_events was decorated with @retry_on_connection_failure, so a transient TimeoutError or ClientConnectorError raised from any other method — including the command path (execute_action_group) and all setup/state/refresh calls — propagated raw on the first occurrence. Centralize the retry on the _get/_post/_put/_delete helpers so every request gets uniform transient-connection retry, and drop the now redundant decorator from fetch_events. Fixes #2147 --- pyoverkiz/client.py | 5 ++++- tests/test_client.py | 52 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 836761a9..35560b67 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -476,7 +476,6 @@ async def register_event_listener(self) -> str: @retry_on_concurrent_requests @retry_on_auth_error @retry_on_listener_error - @retry_on_connection_failure async def fetch_events(self) -> list[Event]: """Fetch new events from a registered event listener. Fetched events are removed. @@ -1021,6 +1020,7 @@ async def deactivate_developer_mode(self, gateway_id: str) -> None: """ await self._delete(f"setup/gateways/{gateway_id}/developerMode") + @retry_on_connection_failure async def _get(self, path: str) -> Any: """Make a GET request to the OverKiz API.""" await self._refresh_token_if_expired() @@ -1032,6 +1032,7 @@ async def _get(self, path: str) -> Any: ) as response: return await self._parse_response(response) + @retry_on_connection_failure async def _post( self, path: str, @@ -1050,6 +1051,7 @@ async def _post( ) as response: return await self._parse_response(response) + @retry_on_connection_failure async def _put(self, path: str, payload: dict[str, Any] | None = None) -> Any: """Make a PUT request to the OverKiz API.""" await self._refresh_token_if_expired() @@ -1062,6 +1064,7 @@ async def _put(self, path: str, payload: dict[str, Any] | None = None) -> Any: ) as response: return await self._parse_response(response) + @retry_on_connection_failure async def _delete(self, path: str) -> None: """Make a DELETE request to the OverKiz API.""" await self._refresh_token_if_expired() diff --git a/tests/test_client.py b/tests/test_client.py index 4956b713..ed35b75b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -68,6 +68,58 @@ async def test_get_devices_basic(self, client: OverkizClient): devices = await client.get_devices() assert len(devices) == 23 + @pytest.mark.asyncio + async def test_backoff_retries_command_on_connection_failure( + self, client: OverkizClient + ) -> None: + """Ensure the command path retries transient connection failures. + + Regression test for #2147: a transient ``TimeoutError`` raised on the + execute command path must be retried instead of propagating raw on the + first occurrence. + """ + resp = MockResponse(json.dumps({"execId": "exec-1"})) + + with ( + patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, + patch.object( + aiohttp.ClientSession, + "post", + side_effect=[TimeoutError("timed out"), resp], + ) as post_mock, + ): + exec_id = await client.execute_action_group( + actions=[Action(device_url="io://1234-5678-9012/12345678")], + ) + + assert exec_id == "exec-1" + assert post_mock.call_count == 2 + assert sleep_mock.await_count == 1 + + @pytest.mark.asyncio + async def test_backoff_retries_get_on_connection_failure( + self, client: OverkizClient + ) -> None: + """Ensure GET requests retry transient ``ClientConnectorError`` failures.""" + resp = MockResponse(json.dumps({"protocolVersion": "1"})) + connection_error = aiohttp.ClientConnectorError( + connection_key=MagicMock(), os_error=OSError("unreachable") + ) + + with ( + patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, + patch.object( + aiohttp.ClientSession, + "get", + side_effect=[connection_error, resp], + ) as get_mock, + ): + result = await client.get_api_version() + + assert result == "1" + assert get_mock.call_count == 2 + assert sleep_mock.await_count == 1 + @pytest.mark.parametrize( ("fixture_name", "event_length"), [ From c8eac89c439fd7a4f26958445822e7362dae4add Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 22 Jun 2026 14:57:55 +0000 Subject: [PATCH 2/7] fix: lower connection-failure retry to 3 tries / 30s budget Tighten retry_on_connection_failure from 5 tries / 120s to 3 tries / 30s so a flaky connection gives up faster (~3s worst-case sleep) instead of blocking a command or poll for up to ~15s. Add a test covering the give-up-after-max-tries path. --- pyoverkiz/client.py | 4 ++-- tests/test_client.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 35560b67..1c5fcfda 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -102,8 +102,8 @@ async def refresh_listener(invocation: Details) -> None: retry_on_connection_failure = backoff.on_exception( backoff.expo, (TimeoutError, ClientConnectorError), - max_tries=5, - max_time=120, + max_tries=3, + max_time=30, jitter=backoff.full_jitter, logger=_LOGGER, ) diff --git a/tests/test_client.py b/tests/test_client.py index ed35b75b..7d8e89e8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -120,6 +120,29 @@ async def test_backoff_retries_get_on_connection_failure( assert get_mock.call_count == 2 assert sleep_mock.await_count == 1 + @pytest.mark.asyncio + async def test_backoff_gives_up_after_max_tries_on_connection_failure( + self, client: OverkizClient + ) -> None: + """Ensure connection failures are re-raised after max_tries attempts. + + ``retry_on_connection_failure`` is capped at 3 attempts (1 initial + 2 + retries); a persistent failure must surface rather than retry forever. + """ + with ( + patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, + patch.object( + aiohttp.ClientSession, + "get", + side_effect=TimeoutError("timed out"), + ) as get_mock, + pytest.raises(TimeoutError), + ): + await client.get_api_version() + + assert get_mock.call_count == 3 + assert sleep_mock.await_count == 2 + @pytest.mark.parametrize( ("fixture_name", "event_length"), [ From ff4b5046ba9a37c54138e47bdcc9475137923d50 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 22 Jun 2026 15:01:33 +0000 Subject: [PATCH 3/7] test: drop redundant GET retry test The give-up-after-max-tries test already exercises _get through the decorator, so the GET retry-once test added no coverage beyond the command-path (_post) regression test. --- tests/test_client.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 7d8e89e8..ff1548aa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -96,30 +96,6 @@ async def test_backoff_retries_command_on_connection_failure( assert post_mock.call_count == 2 assert sleep_mock.await_count == 1 - @pytest.mark.asyncio - async def test_backoff_retries_get_on_connection_failure( - self, client: OverkizClient - ) -> None: - """Ensure GET requests retry transient ``ClientConnectorError`` failures.""" - resp = MockResponse(json.dumps({"protocolVersion": "1"})) - connection_error = aiohttp.ClientConnectorError( - connection_key=MagicMock(), os_error=OSError("unreachable") - ) - - with ( - patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, - patch.object( - aiohttp.ClientSession, - "get", - side_effect=[connection_error, resp], - ) as get_mock, - ): - result = await client.get_api_version() - - assert result == "1" - assert get_mock.call_count == 2 - assert sleep_mock.await_count == 1 - @pytest.mark.asyncio async def test_backoff_gives_up_after_max_tries_on_connection_failure( self, client: OverkizClient From 061194f94e452d2f34df2bf1c15efc13142bfd09 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 22 Jun 2026 15:03:39 +0000 Subject: [PATCH 4/7] test: simplify connection-failure test docstrings --- tests/test_client.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index ff1548aa..d6556b58 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -72,12 +72,7 @@ async def test_get_devices_basic(self, client: OverkizClient): async def test_backoff_retries_command_on_connection_failure( self, client: OverkizClient ) -> None: - """Ensure the command path retries transient connection failures. - - Regression test for #2147: a transient ``TimeoutError`` raised on the - execute command path must be retried instead of propagating raw on the - first occurrence. - """ + """Ensure the command path retries a transient connection failure.""" resp = MockResponse(json.dumps({"execId": "exec-1"})) with ( @@ -100,11 +95,7 @@ async def test_backoff_retries_command_on_connection_failure( async def test_backoff_gives_up_after_max_tries_on_connection_failure( self, client: OverkizClient ) -> None: - """Ensure connection failures are re-raised after max_tries attempts. - - ``retry_on_connection_failure`` is capped at 3 attempts (1 initial + 2 - retries); a persistent failure must surface rather than retry forever. - """ + """Ensure a persistent connection failure is re-raised after 3 attempts.""" with ( patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, patch.object( From 6167e66eb450ed48c92173105772364360042c42 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 28 Jun 2026 09:47:39 +0000 Subject: [PATCH 5/7] feat: harden connection resiliency and document retry strategy - Treat ServerDisconnectedError as a transient transport failure (retry fast, no relogin) instead of an auth error. A genuine session expiry still escalates to relogin via the outer auth decorator. - Add a default per-request ClientTimeout (total=15s, sock_connect=10s) to the client-owned session so a hung socket fails fast within the connection-retry budget instead of blocking on aiohttp's 300s default. - Add docs/resiliency.md explaining the retry strategy, timeouts, and which exceptions consumers should handle themselves. --- docs/resiliency.md | 120 ++++++++++++++++++++++++++++++++++++++++ docs/troubleshooting.md | 6 +- mkdocs.yml | 1 + pyoverkiz/client.py | 17 ++++-- tests/test_client.py | 73 ++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 docs/resiliency.md diff --git a/docs/resiliency.md b/docs/resiliency.md new file mode 100644 index 00000000..9a8277fe --- /dev/null +++ b/docs/resiliency.md @@ -0,0 +1,120 @@ +# Resiliency + +pyOverkiz retries transient failures automatically and raises typed exceptions for +everything else. This page explains what the library handles for you, so you can +decide what (if anything) to handle yourself. + +## What the library retries + +Every request is wrapped with [`backoff`](https://github.com/litl/backoff) decorators +using exponential delays with full jitter. Each retry policy is capped by both a +maximum number of attempts (`max_tries`) and a maximum wall-clock budget (`max_time`), +so retries always give up rather than looping indefinitely. + +| Failure | Retried | Budget | On retry | +| --- | --- | --- | --- | +| Connection errors — `TimeoutError`, `ClientConnectorError`, `ServerDisconnectedError` | yes | 3 tries / ~30s | reopen the connection | +| `NotAuthenticatedError` (session expired) | yes | 2 tries / ~60s | call `login()` | +| `TooManyConcurrentRequestsError` | yes | 5 tries / ~120s | — | +| `TooManyExecutionsError` | yes | 5 tries / ~300s | — | +| `ExecutionQueueFullError` | yes | 5 tries / ~120s | — | +| Event-listener errors — `InvalidEventListenerIdError`, `NoRegisteredEventListenerError` | yes (on `fetch_events`) | 2 tries / ~30s | re-register the listener | + +Everything else — `BadCredentialsError`, `TooManyRequestsError`, `MaintenanceError`, +`UnsupportedOperationError`, and so on — is **not** retried and is raised directly. + +### Connection errors fail fast + +Transient transport failures are retried quickly and then given up on (3 attempts +within roughly 30 seconds). This is deliberate: it is better to surface a failure to +the caller than to keep a request hanging for minutes. For a polling loop such as +`fetch_events()`, letting a call fail and retrying on the next poll tick is usually the +right behaviour. + +A dropped keep-alive socket (`ServerDisconnectedError`) is treated as a connection +blip and simply retried — it does **not** trigger a re-login. A genuine session expiry +is reported by the server as a `Not authenticated` response, which raises +`NotAuthenticatedError` and escalates to a re-login through the separate auth policy. + +!!! note "`max_time` does not interrupt a hung request" + The `max_time` budget only bounds the scheduling of retries *between* attempts; it + cannot cancel a single in-flight request. A request that hangs is bounded by the + session timeout (see below), which surfaces as a `TimeoutError` and is then retried. + +## Request timeout + +When pyOverkiz creates its own session, it applies a default per-request timeout +(`total=15s`, `sock_connect=10s`). This is kept below the connection-retry budget so a +hung socket fails fast and is retried, instead of blocking on aiohttp's 300-second +default. + +If you pass your own `ClientSession`, **you own its timeout** — pyOverkiz does not +override it. Configure one explicitly: + +```python +from aiohttp import ClientSession, ClientTimeout + +from pyoverkiz.auth.credentials import UsernamePasswordCredentials +from pyoverkiz.client import OverkizClient +from pyoverkiz.enums import Server + +session = ClientSession(timeout=ClientTimeout(total=15, sock_connect=10)) + +client = OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("you@example.com", "password"), + session=session, +) +``` + +## What you should handle + +Because the library already retries transient failures, most consumers only need to +handle the terminal cases: + +- **`BadCredentialsError`** — the credentials are wrong; retrying will not help. Prompt + for new credentials. +- **`TooManyRequestsError`** — you are being rate limited. Reduce your polling + frequency. This is intentionally not retried so you do not make the situation worse. +- **`MaintenanceError` / `ServiceUnavailableError`** — the backend is temporarily + unavailable. Back off and try again later. +- **`NotAuthenticatedError`** — only reaches you if the automatic re-login also failed. + Call `login()` and retry. + +```python +import asyncio + +from pyoverkiz.auth.credentials import UsernamePasswordCredentials +from pyoverkiz.client import OverkizClient +from pyoverkiz.enums import Server +from pyoverkiz.exceptions import ( + MaintenanceError, + NotAuthenticatedError, + TooManyRequestsError, +) + + +async def fetch_devices() -> None: + async with OverkizClient( + server=Server.SOMFY_EUROPE, + credentials=UsernamePasswordCredentials("you@example.com", "password"), + ) as client: + await client.login() + try: + print(await client.get_devices()) + except NotAuthenticatedError: + await client.login() + print(await client.get_devices()) + except TooManyRequestsError: + # Rate limited — slow down your polling. + await asyncio.sleep(60) + except MaintenanceError: + # Backend under maintenance — try again later. + pass + + +asyncio.run(fetch_devices()) +``` + +All exceptions derive from `BaseOverkizError`, so you can catch that as a catch-all for +anything the library raises. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4034a7ca..2965db03 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -82,9 +82,11 @@ asyncio.run(fetch_devices_with_retry()) - Refresh setup with `get_setup()` and re-fetch devices. - Confirm you are using the correct gateway and server. -## Timeouts +## Timeouts and retries -For long-running operations, prefer shorter request timeouts with retries rather than a single long timeout. +The library retries transient connection and rate-limit failures automatically and +applies a default request timeout. See [Resiliency](resiliency.md) for the full retry +strategy and for configuring timeouts on a custom session. ## Logging diff --git a/mkdocs.yml b/mkdocs.yml index 02ea10b8..97405bd7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,7 @@ nav: - Device control: device-control.md - Action queue: action-queue.md - Event handling: event-handling.md + - Resiliency: resiliency.md - Troubleshooting: troubleshooting.md - Migrating from v1: migration-v2.md - Reference: diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 1c5fcfda..70c875e7 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -17,6 +17,7 @@ ClientConnectorError, ClientResponse, ClientSession, + ClientTimeout, ServerDisconnectedError, ) from backoff.types import Details @@ -72,6 +73,9 @@ _LOGGER = logging.getLogger(__name__) +# Default per-request timeout for the client-owned session (see docs: Resiliency). +DEFAULT_TIMEOUT = ClientTimeout(total=15, sock_connect=10) + def _get_client_from_invocation(invocation: Details) -> OverkizClient: """Return the `OverkizClient` instance from a backoff invocation.""" @@ -88,20 +92,21 @@ async def refresh_listener(invocation: Details) -> None: await _get_client_from_invocation(invocation).register_event_listener() -# Reusable backoff decorators with max_tries and max_time to cap total retry duration. +# Reusable backoff decorators (see docs: Resiliency for the full strategy). retry_on_auth_error = backoff.on_exception( backoff.expo, - (NotAuthenticatedError, ServerDisconnectedError), + NotAuthenticatedError, max_tries=2, - max_time=60, # safety net for hung requests + max_time=60, jitter=backoff.full_jitter, on_backoff=relogin, logger=_LOGGER, ) +# Transient transport failures: retry fast (3 tries / ~30s), no relogin. retry_on_connection_failure = backoff.on_exception( backoff.expo, - (TimeoutError, ClientConnectorError), + (TimeoutError, ClientConnectorError, ServerDisconnectedError), max_tries=3, max_time=30, jitter=backoff.full_jitter, @@ -226,7 +231,9 @@ def __init__( self.gateways: list[Gateway] = [] self._event_listener_id: str | None = None - self.session = session or ClientSession(headers={"User-Agent": USER_AGENT}) + self.session = session or ClientSession( + headers={"User-Agent": USER_AGENT}, timeout=DEFAULT_TIMEOUT + ) self._ssl = verify_ssl if self.server_config.api_type == APIType.LOCAL and verify_ssl: diff --git a/tests/test_client.py b/tests/test_client.py index d6556b58..0a818346 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -58,6 +58,19 @@ async def test_get_api_type_local(self, local_client: OverkizClient): """Verify that a local-configured client reports APIType.LOCAL.""" assert local_client.server_config.api_type == APIType.LOCAL + @pytest.mark.asyncio + async def test_default_session_has_request_timeout( + self, client: OverkizClient + ) -> None: + """A client-owned session applies a default per-request timeout.""" + from pyoverkiz.client import DEFAULT_TIMEOUT + + assert client.session.timeout.total == DEFAULT_TIMEOUT.total + # Must stay below the connection-retry budget so a hung socket fails fast. + assert client.session.timeout.total is not None + assert client.session.timeout.total <= 30 + await client.session.close() + @pytest.mark.asyncio async def test_get_devices_basic(self, client: OverkizClient): """Ensure the client can fetch and parse the basic devices fixture.""" @@ -110,6 +123,66 @@ async def test_backoff_gives_up_after_max_tries_on_connection_failure( assert get_mock.call_count == 3 assert sleep_mock.await_count == 2 + @pytest.mark.asyncio + async def test_backoff_retries_on_server_disconnected_without_relogin( + self, client: OverkizClient + ) -> None: + """A dropped keep-alive socket is retried as a connection blip, not a relogin.""" + client.login = AsyncMock() + resp = MockResponse(json.dumps({"protocolVersion": "1"})) + + with ( + patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, + patch.object( + aiohttp.ClientSession, + "get", + side_effect=[aiohttp.ServerDisconnectedError(), resp], + ) as get_mock, + ): + result = await client.get_api_version() + + assert result == "1" + assert get_mock.call_count == 2 + assert sleep_mock.await_count == 1 + # A transport blip must not trigger a (heavy) relogin. + assert client.login.await_count == 0 + + @pytest.mark.asyncio + async def test_server_disconnected_escalates_to_relogin_when_auth_expired( + self, client: OverkizClient + ) -> None: + """If retrying a disconnect surfaces a real auth expiry, relogin still kicks in.""" + client.login = AsyncMock() + + with ( + patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock, + patch.object( + aiohttp.ClientSession, + "get", + side_effect=[ + # Transport blip, retried by the connection decorator. + aiohttp.ServerDisconnectedError(), + # Retry reaches the server, which reports the session expired. + MockResponse( + json.dumps( + { + "errorCode": "RESOURCE_ACCESS_DENIED", + "error": "Not authenticated", + } + ), + status=401, + ), + # After relogin, the auth decorator retries and succeeds. + MockResponse(json.dumps({"protocolVersion": "1"})), + ], + ), + ): + result = await client.get_api_version() + + assert result == "1" + assert client.login.await_count == 1 + assert sleep_mock.await_count >= 1 + @pytest.mark.parametrize( ("fixture_name", "event_length"), [ From 9432b23c28a83b82bb90c73840cc0a4c926cbb13 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 28 Jun 2026 09:53:40 +0000 Subject: [PATCH 6/7] docs: explain backoff jitter; drop redundant code comments - Remove the '(see docs)' comments from client.py now that the strategy is fully documented. - Document that retry delays use exponential ceilings with full jitter, so they are randomized rather than a fixed ramp. --- docs/resiliency.md | 10 ++++++++++ pyoverkiz/client.py | 3 --- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/resiliency.md b/docs/resiliency.md index 9a8277fe..d348e013 100644 --- a/docs/resiliency.md +++ b/docs/resiliency.md @@ -23,6 +23,16 @@ so retries always give up rather than looping indefinitely. Everything else — `BadCredentialsError`, `TooManyRequestsError`, `MaintenanceError`, `UnsupportedOperationError`, and so on — is **not** retried and is raised directly. +### Backoff timing + +Delays grow exponentially — the ceilings are 1s, 2s, 4s, 8s, … — but **full jitter** +is applied on top, so each actual wait is a random value between 0 and that ceiling +(e.g. the first retry waits a random `0–1s`, the second a random `0–2s`). The +randomization is deliberate: it spreads retries out so many clients do not all retry in +lockstep ([AWS "Exponential Backoff and Jitter"](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)). +Because of the jitter, the per-attempt delays are not a fixed sequence; the `max_time` +budget is the upper bound that matters. + ### Connection errors fail fast Transient transport failures are retried quickly and then given up on (3 attempts diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 70c875e7..93b5f973 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -73,7 +73,6 @@ _LOGGER = logging.getLogger(__name__) -# Default per-request timeout for the client-owned session (see docs: Resiliency). DEFAULT_TIMEOUT = ClientTimeout(total=15, sock_connect=10) @@ -92,7 +91,6 @@ async def refresh_listener(invocation: Details) -> None: await _get_client_from_invocation(invocation).register_event_listener() -# Reusable backoff decorators (see docs: Resiliency for the full strategy). retry_on_auth_error = backoff.on_exception( backoff.expo, NotAuthenticatedError, @@ -103,7 +101,6 @@ async def refresh_listener(invocation: Details) -> None: logger=_LOGGER, ) -# Transient transport failures: retry fast (3 tries / ~30s), no relogin. retry_on_connection_failure = backoff.on_exception( backoff.expo, (TimeoutError, ClientConnectorError, ServerDisconnectedError), From 38f28538a2217cad328b71dd0fd4902d9ab6b487 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 28 Jun 2026 09:56:17 +0000 Subject: [PATCH 7/7] docs: drop unrelated AWS link from backoff explanation --- docs/resiliency.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/resiliency.md b/docs/resiliency.md index d348e013..86936454 100644 --- a/docs/resiliency.md +++ b/docs/resiliency.md @@ -29,9 +29,8 @@ Delays grow exponentially — the ceilings are 1s, 2s, 4s, 8s, … — but **ful is applied on top, so each actual wait is a random value between 0 and that ceiling (e.g. the first retry waits a random `0–1s`, the second a random `0–2s`). The randomization is deliberate: it spreads retries out so many clients do not all retry in -lockstep ([AWS "Exponential Backoff and Jitter"](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)). -Because of the jitter, the per-attempt delays are not a fixed sequence; the `max_time` -budget is the upper bound that matters. +lockstep. Because of the jitter, the per-attempt delays are not a fixed sequence; the +`max_time` budget is the upper bound that matters. ### Connection errors fail fast