Skip to content

Commit 14e3617

Browse files
authored
feat: add retry with exponential backoff (#723)
1 parent 20b93bf commit 14e3617

File tree

3 files changed

+664
-43
lines changed

3 files changed

+664
-43
lines changed

src/uiprotect/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from .api import ProtectApiClient
5+
from .api import ProtectApiClient, calculate_retry_delay, parse_retry_after
66
from .exceptions import Invalid, NotAuthorized, NvrError
77
from .utils import (
88
get_nested_attr,
@@ -18,10 +18,12 @@
1818
"NotAuthorized",
1919
"NvrError",
2020
"ProtectApiClient",
21+
"calculate_retry_delay",
2122
"get_nested_attr",
2223
"get_nested_attr_as_bool",
2324
"get_top_level_attr_as_bool",
2425
"make_enabled_getter",
2526
"make_required_getter",
2627
"make_value_getter",
28+
"parse_retry_after",
2729
]

src/uiprotect/api.py

Lines changed: 158 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import contextlib
77
import hashlib
88
import logging
9+
import random
910
import re
1011
import sys
1112
import time
@@ -24,7 +25,7 @@
2425
import aiohttp
2526
import orjson
2627
from aiofiles import os as aos
27-
from aiohttp import CookieJar, client_exceptions
28+
from aiohttp import ClientResponse, CookieJar, client_exceptions
2829
from platformdirs import user_cache_dir, user_config_dir
2930
from yarl import URL
3031

@@ -115,6 +116,13 @@ class PublicApiChimePatchRequest(TypedDict, total=False):
115116
# retry timeout for thumbnails/heatmaps
116117
RETRY_TIMEOUT = 10
117118

119+
# Retry configuration constants
120+
RETRY_DEFAULT_ATTEMPTS = 3
121+
RETRY_BASE_DELAY = 1.0
122+
RETRY_MAX_DELAY = 30.0
123+
RETRY_EXPONENTIAL_BASE = 2.0
124+
RETRY_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
125+
118126
TYPES_BUG_MESSAGE = """There is currently a bug in UniFi Protect that makes `start` / `end` not work if `types` is not provided. This means uiprotect has to iterate over all of the events matching the filters provided to return values.
119127
120128
If your Protect instance has a lot of events, this request will take much longer then expected. It is recommended adding additional filters to speed the request up."""
@@ -126,6 +134,62 @@ class PublicApiChimePatchRequest(TypedDict, total=False):
126134
NFC_FINGERPRINT_SUPPORT_VERSION = Version("5.1.57")
127135

128136

137+
def calculate_retry_delay(attempt: int, retry_after: float | None = None) -> float:
138+
"""
139+
Calculate delay before next retry attempt with exponential backoff and jitter.
140+
141+
Args:
142+
attempt: Current retry attempt number (0-based).
143+
retry_after: Optional Retry-After header value in seconds.
144+
145+
Returns:
146+
Delay in seconds before next retry.
147+
148+
"""
149+
if retry_after is not None and retry_after > 0:
150+
delay = min(retry_after, RETRY_MAX_DELAY)
151+
# Only add positive jitter when server specified a delay
152+
jitter_range = delay * 0.25
153+
delay += random.uniform(0, jitter_range) # noqa: S311
154+
else:
155+
delay = RETRY_BASE_DELAY * (RETRY_EXPONENTIAL_BASE**attempt)
156+
delay = min(delay, RETRY_MAX_DELAY)
157+
# Full jitter (±25% of delay) for calculated delays
158+
jitter_range = delay * 0.25
159+
delay += random.uniform(-jitter_range, jitter_range) # noqa: S311
160+
161+
# Ensure delay stays within bounds [0.1, RETRY_MAX_DELAY]
162+
return max(0.1, min(delay, RETRY_MAX_DELAY))
163+
164+
165+
def parse_retry_after(response: ClientResponse) -> float | None:
166+
"""
167+
Parse Retry-After header from response.
168+
169+
Args:
170+
response: HTTP response object.
171+
172+
Returns:
173+
Retry delay in seconds, or None if header not present/parseable.
174+
175+
"""
176+
retry_after = response.headers.get("Retry-After")
177+
if retry_after is None:
178+
return None
179+
180+
try:
181+
# Retry-After can be seconds or HTTP-date, we only handle seconds
182+
return float(retry_after)
183+
except ValueError:
184+
_LOGGER.debug("Could not parse Retry-After header: %s", retry_after)
185+
return None
186+
187+
188+
# =============================================================================
189+
# Helper Functions
190+
# =============================================================================
191+
192+
129193
def get_user_hash(host: str, username: str) -> str:
130194
session = hashlib.sha256()
131195
session.update(host.encode("utf8"))
@@ -189,6 +253,7 @@ class BaseApiClient:
189253
_public_api_session: aiohttp.ClientSession | None = None
190254
_loaded_session: bool = False
191255
_cookiename = "TOKEN"
256+
_max_retries: int = RETRY_DEFAULT_ATTEMPTS
192257

193258
headers: dict[str, str] | None = None
194259
_private_websocket: Websocket | None = None
@@ -220,6 +285,7 @@ def __init__(
220285
config_dir: Path | None = None,
221286
store_sessions: bool = True,
222287
ws_receive_timeout: int | None = None,
288+
max_retries: int = RETRY_DEFAULT_ATTEMPTS,
223289
) -> None:
224290
self._auth_lock = asyncio.Lock()
225291
self._host = host
@@ -233,6 +299,7 @@ def __init__(
233299
self._ws_receive_timeout = ws_receive_timeout
234300
self._loaded_session = False
235301
self._update_task: asyncio.Task[Bootstrap | None] | None = None
302+
self._max_retries = max_retries
236303

237304
self.config_dir = config_dir or (Path(user_config_dir()) / "ufp")
238305
self.cache_dir = cache_dir or (Path(user_cache_dir()) / "ufp_cache")
@@ -430,6 +497,44 @@ def set_header(self, key: str, value: str | None) -> None:
430497
else:
431498
self.headers[key] = value
432499

500+
async def _do_request(
501+
self,
502+
session: aiohttp.ClientSession,
503+
method: str,
504+
request_url: URL,
505+
headers: dict[str, str],
506+
**kwargs: Any,
507+
) -> aiohttp.ClientResponse:
508+
"""Execute a single HTTP request with disconnect retry."""
509+
last_err: aiohttp.ServerDisconnectedError | None = None
510+
for _attempt in range(2):
511+
try:
512+
req_context = session.request(
513+
method,
514+
request_url,
515+
headers=headers,
516+
**kwargs,
517+
)
518+
response = await req_context.__aenter__()
519+
try:
520+
await self._update_last_token_cookie(response)
521+
except Exception:
522+
response.release()
523+
raise
524+
return response
525+
except aiohttp.ServerDisconnectedError as err:
526+
# If the server disconnected, try again
527+
# since HTTP/1.1 allows the server to disconnect at any time
528+
last_err = err
529+
except client_exceptions.ClientError as err:
530+
raise NvrError(
531+
f"Error requesting data from {self._host}: {err}",
532+
) from err
533+
534+
raise NvrError(
535+
f"Error requesting data from {self._host}: {last_err}",
536+
) from last_err
537+
433538
async def request(
434539
self,
435540
method: str,
@@ -439,7 +544,12 @@ async def request(
439544
public_api: bool = False,
440545
**kwargs: Any,
441546
) -> aiohttp.ClientResponse:
442-
"""Make a request to UniFi Protect"""
547+
"""
548+
Make a request to UniFi Protect with automatic retry on transient errors.
549+
550+
Automatically retries requests that receive 408, 429, 500, 502, 503,
551+
or 504 status codes using exponential backoff.
552+
"""
443553
if require_auth and not public_api:
444554
await self.ensure_authenticated()
445555

@@ -460,49 +570,53 @@ async def request(
460570
else:
461571
session = await self.get_session()
462572

463-
for attempt in range(2):
464-
try:
465-
req_context = session.request(
466-
method,
467-
request_url,
468-
headers=headers,
469-
**kwargs,
470-
)
471-
response = await req_context.__aenter__()
573+
# First attempt (always happens, even with max_retries=0)
574+
response = await self._do_request(
575+
session, method, request_url, headers, **kwargs
576+
)
472577

473-
await self._update_last_token_cookie(response)
474-
if auto_close:
475-
try:
476-
_LOGGER.debug(
477-
"%s %s %s",
478-
response.status,
479-
response.content_type,
480-
response,
481-
)
482-
response.release()
483-
except Exception:
484-
# make sure response is released
485-
response.release()
486-
# re-raise exception
487-
raise
578+
# Retry loop for transient errors
579+
for retry_attempt in range(self._max_retries):
580+
if response.status not in RETRY_STATUS_CODES:
581+
break
488582

489-
return response
490-
except aiohttp.ServerDisconnectedError as err:
491-
# If the server disconnected, try again
492-
# since HTTP/1.1 allows the server to disconnect
493-
# at any time
494-
if attempt == 0:
495-
continue
496-
raise NvrError(
497-
f"Error requesting data from {self._host}: {err}",
498-
) from err
499-
except client_exceptions.ClientError as err:
500-
raise NvrError(
501-
f"Error requesting data from {self._host}: {err}",
502-
) from err
583+
retry_after = parse_retry_after(response)
584+
response.release()
585+
delay = calculate_retry_delay(retry_attempt, retry_after)
586+
# Escalate log level: DEBUG (1st) → INFO (2nd) → WARNING (3rd+)
587+
log_level = (logging.DEBUG, logging.INFO, logging.WARNING)[
588+
min(retry_attempt, 2)
589+
]
590+
_LOGGER.log(
591+
log_level,
592+
"Request to %s returned %s, retrying in %.2f seconds (attempt %d/%d)",
593+
request_url,
594+
response.status,
595+
delay,
596+
retry_attempt + 1,
597+
self._max_retries,
598+
)
599+
await asyncio.sleep(delay)
600+
response = await self._do_request(
601+
session, method, request_url, headers, **kwargs
602+
)
603+
604+
if auto_close:
605+
try:
606+
_LOGGER.debug(
607+
"%s %s %s",
608+
response.status,
609+
response.content_type,
610+
response,
611+
)
612+
response.release()
613+
except Exception:
614+
# make sure response is released
615+
response.release()
616+
# re-raise exception
617+
raise
503618

504-
# should never happen
505-
raise NvrError(f"Error requesting data from {self._host}")
619+
return response
506620

507621
async def api_request_raw(
508622
self,
@@ -1010,6 +1124,7 @@ def __init__(
10101124
ignore_unadopted: bool = True,
10111125
debug: bool = False,
10121126
ws_receive_timeout: int | None = None,
1127+
max_retries: int = RETRY_DEFAULT_ATTEMPTS,
10131128
) -> None:
10141129
super().__init__(
10151130
host=host,
@@ -1025,6 +1140,7 @@ def __init__(
10251140
cache_dir=cache_dir,
10261141
config_dir=config_dir,
10271142
store_sessions=store_sessions,
1143+
max_retries=max_retries,
10281144
)
10291145

10301146
self._minimum_score = minimum_score

0 commit comments

Comments
 (0)