66import contextlib
77import hashlib
88import logging
9+ import random
910import re
1011import sys
1112import time
2425import aiohttp
2526import orjson
2627from aiofiles import os as aos
27- from aiohttp import CookieJar , client_exceptions
28+ from aiohttp import ClientResponse , CookieJar , client_exceptions
2829from platformdirs import user_cache_dir , user_config_dir
2930from yarl import URL
3031
@@ -115,6 +116,13 @@ class PublicApiChimePatchRequest(TypedDict, total=False):
115116# retry timeout for thumbnails/heatmaps
116117RETRY_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+
118126TYPES_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
120128If 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):
126134NFC_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+
129193def 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