Skip to content

Commit 8db3ab7

Browse files
authored
Improve retry mechanisms with logging and type annotations (#1922)
This will make sure that backoff requests are added to the Overkiz logger. Default logging is info for backoff, thus it will show up for debug in Home Assistant as well.
1 parent 37b5b8f commit 8db3ab7

File tree

2 files changed

+99
-8
lines changed

2 files changed

+99
-8
lines changed

pyoverkiz/client.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
import asyncio
66
import datetime
7+
import logging
78
import os
89
import ssl
910
import urllib.parse
10-
from collections.abc import Mapping
1111
from json import JSONDecodeError
1212
from types import TracebackType
1313
from typing import Any, cast
@@ -22,6 +22,7 @@
2222
FormData,
2323
ServerDisconnectedError,
2424
)
25+
from backoff.types import Details
2526
from botocore.client import BaseClient
2627
from botocore.config import Config
2728
from warrant_lite import WarrantLite
@@ -91,15 +92,22 @@
9192
from pyoverkiz.obfuscate import obfuscate_sensitive_data
9293
from pyoverkiz.types import JSON
9394

95+
_LOGGER = logging.getLogger(__name__)
9496

95-
async def relogin(invocation: Mapping[str, Any]) -> None:
96-
"""Small helper used by retry decorators to re-authenticate the client."""
97-
await invocation["args"][0].login()
9897

98+
def _get_client_from_invocation(invocation: Details) -> OverkizClient:
99+
"""Return the `OverkizClient` instance from a backoff invocation."""
100+
return cast(OverkizClient, invocation["args"][0])
99101

100-
async def refresh_listener(invocation: Mapping[str, Any]) -> None:
101-
"""Helper to refresh an event listener when retrying listener-related operations."""
102-
await invocation["args"][0].register_event_listener()
102+
103+
async def relogin(invocation: Details) -> None:
104+
"""Re-authenticate using the main `OverkizClient` instance."""
105+
await _get_client_from_invocation(invocation).login()
106+
107+
108+
async def refresh_listener(invocation: Details) -> None:
109+
"""Refresh the listener using the main `OverkizClient` instance."""
110+
await _get_client_from_invocation(invocation).register_event_listener()
103111

104112

105113
# Reusable backoff decorators to reduce code duplication
@@ -108,37 +116,43 @@ async def refresh_listener(invocation: Mapping[str, Any]) -> None:
108116
(NotAuthenticatedException, ServerDisconnectedError),
109117
max_tries=2,
110118
on_backoff=relogin,
119+
logger=_LOGGER,
111120
)
112121

113122
retry_on_connection_failure = backoff.on_exception(
114123
backoff.expo,
115124
(TimeoutError, ClientConnectorError),
116125
max_tries=5,
126+
logger=_LOGGER,
117127
)
118128

119129
retry_on_concurrent_requests = backoff.on_exception(
120130
backoff.expo,
121131
TooManyConcurrentRequestsException,
122132
max_tries=5,
133+
logger=_LOGGER,
123134
)
124135

125136
retry_on_too_many_executions = backoff.on_exception(
126137
backoff.expo,
127138
TooManyExecutionsException,
128139
max_tries=10,
140+
logger=_LOGGER,
129141
)
130142

131143
retry_on_listener_error = backoff.on_exception(
132144
backoff.expo,
133145
(InvalidEventListenerIdException, NoRegisteredEventListenerException),
134146
max_tries=2,
135147
on_backoff=refresh_listener,
148+
logger=_LOGGER,
136149
)
137150

138151
retry_on_execution_queue_full = backoff.on_exception(
139152
backoff.expo,
140153
ExecutionQueueFullException,
141154
max_tries=5,
155+
logger=_LOGGER,
142156
)
143157

144158
# pylint: disable=too-many-instance-attributes, too-many-branches

tests/test_client.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import json
1010
import os
11-
from unittest.mock import patch
11+
from unittest.mock import AsyncMock, patch
1212

1313
import aiohttp
1414
import pytest
@@ -102,6 +102,83 @@ async def test_fetch_events_simple_cast(self, client: OverkizClient):
102102
assert isinstance(int_state_event.value, int)
103103
assert int_state_event.type == DataType.INTEGER
104104

105+
@pytest.mark.asyncio
106+
async def test_backoff_relogin_on_auth_error(self, client: OverkizClient):
107+
"""Ensure auth backoff retries and triggers `login()` on failure."""
108+
client.login = AsyncMock()
109+
110+
with (
111+
patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock,
112+
patch.object(
113+
OverkizClient,
114+
"_OverkizClient__get",
115+
new=AsyncMock(
116+
side_effect=[
117+
exceptions.NotAuthenticatedException("expired"),
118+
{"protocolVersion": "1"},
119+
]
120+
),
121+
) as get_mock,
122+
):
123+
result = await client.get_api_version()
124+
125+
assert result == "1"
126+
assert get_mock.await_count == 2
127+
assert client.login.await_count == 1
128+
assert sleep_mock.await_count == 1
129+
130+
@pytest.mark.asyncio
131+
async def test_backoff_refresh_listener_on_listener_error(
132+
self, client: OverkizClient
133+
) -> None:
134+
"""Ensure listener backoff retries and triggers `register_event_listener()`."""
135+
client.event_listener_id = "listener-1"
136+
client.register_event_listener = AsyncMock(return_value="listener-2")
137+
138+
with (
139+
patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock,
140+
patch.object(
141+
OverkizClient,
142+
"_OverkizClient__post",
143+
new=AsyncMock(
144+
side_effect=[
145+
exceptions.InvalidEventListenerIdException("bad listener"),
146+
[],
147+
]
148+
),
149+
) as post_mock,
150+
):
151+
events = await client.fetch_events()
152+
153+
assert events == []
154+
assert post_mock.await_count == 2
155+
assert client.register_event_listener.await_count == 1
156+
assert sleep_mock.await_count == 1
157+
158+
@pytest.mark.asyncio
159+
async def test_backoff_retries_on_concurrent_requests(
160+
self, client: OverkizClient
161+
) -> None:
162+
"""Ensure concurrent request backoff retries and succeeds afterwards."""
163+
with (
164+
patch("backoff._async.asyncio.sleep", new=AsyncMock()) as sleep_mock,
165+
patch.object(
166+
OverkizClient,
167+
"_OverkizClient__post",
168+
new=AsyncMock(
169+
side_effect=[
170+
exceptions.TooManyConcurrentRequestsException("busy"),
171+
{"id": "listener-3"},
172+
]
173+
),
174+
) as post_mock,
175+
):
176+
listener_id = await client.register_event_listener()
177+
178+
assert listener_id == "listener-3"
179+
assert post_mock.await_count == 2
180+
assert sleep_mock.await_count == 1
181+
105182
@pytest.mark.parametrize(
106183
"fixture_name",
107184
[

0 commit comments

Comments
 (0)