11"""Python 3 API wrapper for Garmin Connect."""
22
33import contextlib
4+ import functools
45import logging
56import numbers
67import os
8+ import random
79import re
810import time
911from collections .abc import Callable
2022
2123logger = logging .getLogger (__name__ )
2224
25+ # Regex used to extract an HTTP status code from the client's error messages.
26+ # The underlying client raises GarminConnectConnectionError with a message of
27+ # the form "API Error {status} - {detail}", so we parse it to decide whether
28+ # a failure is retryable (5xx) or not (4xx, auth, etc.).
29+ _STATUS_CODE_RE = re .compile (r"(?:API Error|Error|HTTP)\s*(\d{3})" )
30+
2331# Constants for validation
2432MAX_ACTIVITY_LIMIT = 1000
2533MAX_HYDRATION_ML = 10000 # 10 liters
@@ -98,6 +106,165 @@ def _validate_json_exists(response: requests.Response) -> dict[str, Any] | None:
98106 return response .json ()
99107
100108
109+ def _extract_status_code (exc : BaseException ) -> int | None :
110+ """Best-effort extraction of an HTTP status code from an exception.
111+
112+ Checks ``status_code`` and ``response.status_code`` attributes, then
113+ parses ``"API Error NNN"`` / ``"HTTP NNN"`` patterns out of the message
114+ (the client raises ``GarminConnectConnectionError`` with messages like
115+ ``"API Error 503 - ..."``).
116+ """
117+ status = getattr (exc , "status_code" , None )
118+ if isinstance (status , int ):
119+ return status
120+ resp = getattr (exc , "response" , None )
121+ status = getattr (resp , "status_code" , None )
122+ if isinstance (status , int ):
123+ return status
124+ match = _STATUS_CODE_RE .search (str (exc ))
125+ return int (match .group (1 )) if match else None
126+
127+
128+ def _has_network_cause (exc : BaseException ) -> bool :
129+ """Walk ``__cause__`` / ``__context__`` looking for a raw network error."""
130+ seen : set [int ] = set ()
131+ cur : BaseException | None = exc
132+ while cur is not None and id (cur ) not in seen :
133+ seen .add (id (cur ))
134+ if isinstance (cur , requests .ConnectionError | requests .Timeout ):
135+ return True
136+ cur = cur .__cause__ or cur .__context__
137+ return False
138+
139+
140+ def _is_retryable (exc : BaseException ) -> bool :
141+ """Return True for transient errors worth retrying.
142+
143+ Retries 5xx server errors and genuine network failures. Never retries
144+ 401 (auth), 429 (rate-limit) or 4xx (client) errors — those are
145+ deterministic and caller-actionable.
146+ """
147+ if isinstance (
148+ exc , GarminConnectAuthenticationError | GarminConnectTooManyRequestsError
149+ ):
150+ return False
151+ if isinstance (exc , GarminConnectConnectionError ):
152+ status = _extract_status_code (exc )
153+ if status is None :
154+ return _has_network_cause (exc )
155+ return 500 <= status < 600
156+ return isinstance (exc , requests .ConnectionError | requests .Timeout )
157+
158+
159+ def _backoff_delay (attempt : int , obj : Any ) -> float :
160+ """Exponential backoff with 50-100% jitter, bounded by ``retry_max_wait``."""
161+ min_wait = getattr (obj , "retry_min_wait" , 1.0 )
162+ max_wait = getattr (obj , "retry_max_wait" , 10.0 )
163+ base = min (max_wait , min_wait * (2 ** attempt ))
164+ return base * (0.5 + random .random () * 0.5 ) # noqa: S311 jitter, not crypto
165+
166+
167+ def _handle_api_errors (
168+ label : str ,
169+ ) -> Callable [[Callable [..., Any ]], Callable [..., Any ]]:
170+ """Decorator: uniform error translation + optional retry for Garmin API calls.
171+
172+ Translates transport-level exceptions to domain-specific
173+ ``GarminConnectAuthenticationError`` (401), ``GarminConnectTooManyRequestsError``
174+ (429) or ``GarminConnectConnectionError`` (all other HTTP / network failures),
175+ preserving the original ``.response`` where available so callers can still
176+ introspect the underlying response.
177+
178+ Retries are opt-in via ``self.retry_attempts`` (default ``0`` = disabled).
179+ When enabled, only 5xx server errors and raw connection / timeout failures
180+ are retried, with exponential backoff plus jitter between ``retry_min_wait``
181+ and ``retry_max_wait`` seconds. 401 / 429 / 4xx always fail fast.
182+ """
183+
184+ def decorator (func : Callable [..., Any ]) -> Callable [..., Any ]:
185+ @functools .wraps (func )
186+ def wrapper (self : Any , * args : Any , ** kwargs : Any ) -> Any :
187+ path = args [0 ] if args else kwargs .get ("path" , "<unknown>" )
188+ attempts = max (0 , getattr (self , "retry_attempts" , 0 ))
189+ for attempt in range (attempts + 1 ):
190+ try :
191+ return func (self , * args , ** kwargs )
192+ except ( # noqa: PERF203 retry requires per-attempt try/except
193+ GarminConnectAuthenticationError ,
194+ GarminConnectTooManyRequestsError ,
195+ ):
196+ # Already domain-specific and never retryable.
197+ raise
198+ except (HTTPError , GarminConnectConnectionError ) as e :
199+ status = _extract_status_code (e )
200+ if status == 401 :
201+ raise GarminConnectAuthenticationError (
202+ f"Authentication failed: { e } "
203+ ) from e
204+ if status == 429 :
205+ raise GarminConnectTooManyRequestsError (
206+ f"Rate limit exceeded: { e } "
207+ ) from e
208+ if status and 400 <= status < 500 :
209+ new_exc = GarminConnectConnectionError (
210+ f"{ label } client error ({ status } ): { e } "
211+ )
212+ resp = getattr (e , "response" , None )
213+ if resp is not None :
214+ new_exc .response = resp # type: ignore[attr-defined]
215+ raise new_exc from e
216+ # 5xx or no parseable status — retryable path.
217+ if attempt < attempts :
218+ delay = _backoff_delay (attempt , self )
219+ logger .warning (
220+ "%s attempt %d/%d failed (path=%s status=%s) — "
221+ "retrying in %.1fs: %s" ,
222+ label ,
223+ attempt + 1 ,
224+ attempts + 1 ,
225+ path ,
226+ status ,
227+ delay ,
228+ e ,
229+ )
230+ time .sleep (delay )
231+ continue
232+ logger .exception (
233+ "%s failed for path '%s' (status=%s)" , label , path , status
234+ )
235+ new_exc = GarminConnectConnectionError (f"{ label } HTTP error: { e } " )
236+ resp = getattr (e , "response" , None )
237+ if resp is not None :
238+ new_exc .response = resp # type: ignore[attr-defined]
239+ raise new_exc from e
240+ except (requests .ConnectionError , requests .Timeout ) as e :
241+ if attempt < attempts :
242+ delay = _backoff_delay (attempt , self )
243+ logger .warning (
244+ "%s network error attempt %d/%d (path=%s) — "
245+ "retrying in %.1fs: %s" ,
246+ label ,
247+ attempt + 1 ,
248+ attempts + 1 ,
249+ path ,
250+ delay ,
251+ e ,
252+ )
253+ time .sleep (delay )
254+ continue
255+ logger .exception ("Network error during %s path=%s" , label , path )
256+ raise GarminConnectConnectionError (f"Connection error: { e } " ) from e
257+ except Exception as e :
258+ logger .exception ("Connection error during %s path=%s" , label , path )
259+ raise GarminConnectConnectionError (f"Connection error: { e } " ) from e
260+ # Unreachable: loop returns, retries, or raises.
261+ raise RuntimeError (f"{ label } retry loop exited without a result" )
262+
263+ return wrapper
264+
265+ return decorator
266+
267+
101268class Garmin :
102269 """Class for fetching data from Garmin Connect."""
103270
@@ -108,8 +275,20 @@ def __init__(
108275 is_cn : bool = False ,
109276 prompt_mfa : Callable [[], str ] | None = None ,
110277 return_on_mfa : bool = False ,
278+ retry_attempts : int = 0 ,
279+ retry_min_wait : float = 1.0 ,
280+ retry_max_wait : float = 10.0 ,
111281 ) -> None :
112- """Create a new class instance."""
282+ """Create a new class instance.
283+
284+ :param retry_attempts: Retries for transient 5xx / network errors on
285+ ``connectapi``, ``connectwebproxy`` and ``download``. ``0``
286+ (default) disables retries — 401, 429 and 4xx always fail fast
287+ either way.
288+ :param retry_min_wait: Initial backoff in seconds; grows exponentially
289+ with jitter between attempts.
290+ :param retry_max_wait: Upper bound on the backoff in seconds.
291+ """
113292 # Validate input types
114293 if email is not None and not isinstance (email , str ):
115294 raise ValueError ("email must be a string or None" )
@@ -119,12 +298,19 @@ def __init__(
119298 raise ValueError ("is_cn must be a boolean" )
120299 if not isinstance (return_on_mfa , bool ):
121300 raise ValueError ("return_on_mfa must be a boolean" )
301+ if isinstance (retry_attempts , bool ) or not isinstance (retry_attempts , int ):
302+ raise ValueError ("retry_attempts must be an int" )
303+ if retry_attempts < 0 :
304+ raise ValueError ("retry_attempts must be non-negative" )
122305
123306 self .username = email
124307 self .password = password
125308 self .is_cn = is_cn
126309 self .prompt_mfa = prompt_mfa
127310 self .return_on_mfa = return_on_mfa
311+ self .retry_attempts = retry_attempts
312+ self .retry_min_wait = float (retry_min_wait )
313+ self .retry_max_wait = float (retry_max_wait )
128314
129315 self .garmin_connect_user_settings_url = (
130316 "/userprofile-service/userprofile/user-settings"
@@ -321,92 +507,20 @@ def __init__(
321507 self .full_name = None
322508 self .unit_system = None
323509
510+ @_handle_api_errors ("API call" )
324511 def connectapi (self , path : str , ** kwargs : Any ) -> Any :
325- """Wrapper for native connectapi with error handling."""
326- try :
327- return self .client .connectapi (path , ** kwargs )
328-
329- except (HTTPError , GarminConnectConnectionError ) as e :
330- # For GarminConnectConnectionError, extract status from the wrapped HTTPError
331- if isinstance (e , GarminConnectConnectionError ):
332- status = getattr (getattr (e , "response" , None ), "status_code" , None )
333- else :
334- status = getattr (getattr (e , "response" , None ), "status_code" , None )
335-
336- logger .exception (
337- "API call failed for path '%s': %s (status=%s)" , path , e , status
338- )
339- if status == 401 :
340- raise GarminConnectAuthenticationError (
341- f"Authentication failed: { e } "
342- ) from e
343- if status == 429 :
344- raise GarminConnectTooManyRequestsError (
345- f"Rate limit exceeded: { e } "
346- ) from e
347- if status and 400 <= status < 500 :
348- # Client errors (400-499) - API endpoint issues, bad parameters, etc.
349- raise GarminConnectConnectionError (
350- f"API client error ({ status } ): { e } "
351- ) from e
352- raise GarminConnectConnectionError (f"HTTP error: { e } " ) from e
353- except Exception as e :
354- logger .exception ("Connection error during connectapi path=%s" , path )
355- raise GarminConnectConnectionError (f"Connection error: { e } " ) from e
512+ """Native connectapi call with error translation and optional retries."""
513+ return self .client .connectapi (path , ** kwargs )
356514
515+ @_handle_api_errors ("Web proxy call" )
357516 def connectwebproxy (self , path : str , ** kwargs : Any ) -> Any :
358- """Wrapper for web proxy requests to connect.garmin.com with error handling."""
359- try :
360- return self .client .request ("GET" , "connect" , path , ** kwargs ).json ()
361- except GarminConnectConnectionError as e :
362- status = getattr (getattr (e , "response" , None ), "status_code" , None )
363- logger .exception (
364- "API call failed for web proxy path '%s' (status=%s)" ,
365- path ,
366- status ,
367- )
368- if status == 401 :
369- raise GarminConnectAuthenticationError (
370- f"Web proxy auth error: { e } "
371- ) from e
372- if status == 429 :
373- raise GarminConnectTooManyRequestsError (
374- f"Web proxy rate limit: { e } "
375- ) from e
376- if status and 400 <= status < 500 :
377- raise GarminConnectConnectionError (
378- f"Web proxy client error ({ status } ): { e } "
379- ) from e
380- raise GarminConnectConnectionError (f"Web proxy error: { e } " ) from e
381- except Exception as e :
382- logger .exception ("Connection error during web proxy path=%s" , path )
383- raise GarminConnectConnectionError (f"Connection error: { e } " ) from e
517+ """Web proxy request with error translation and optional retries."""
518+ return self .client .request ("GET" , "connect" , path , ** kwargs ).json ()
384519
520+ @_handle_api_errors ("Download" )
385521 def download (self , path : str , ** kwargs : Any ) -> Any :
386- """Wrapper for native download with error handling."""
387- try :
388- return self .client .download (path , ** kwargs )
389- except (HTTPError , GarminConnectConnectionError ) as e :
390- # For GarminConnectConnectionError, extract status from the wrapped HTTPError
391- if isinstance (e , GarminConnectConnectionError ):
392- status = getattr (getattr (e , "response" , None ), "status_code" , None )
393- else :
394- status = getattr (getattr (e , "response" , None ), "status_code" , None )
395-
396- logger .exception ("Download failed for path '%s' (status=%s)" , path , status )
397- if status == 401 :
398- raise GarminConnectAuthenticationError (f"Download error: { e } " ) from e
399- if status == 429 :
400- raise GarminConnectTooManyRequestsError (f"Download error: { e } " ) from e
401- if status and 400 <= status < 500 :
402- # Client errors (400-499) - API endpoint issues, bad parameters, etc.
403- raise GarminConnectConnectionError (
404- f"Download client error ({ status } ): { e } "
405- ) from e
406- raise GarminConnectConnectionError (f"Download error: { e } " ) from e
407- except Exception as e :
408- logger .exception ("Download failed for path '%s'" , path )
409- raise GarminConnectConnectionError (f"Download error: { e } " ) from e
522+ """Native download call with error translation and optional retries."""
523+ return self .client .download (path , ** kwargs )
410524
411525 def login (self , / , tokenstore : str | None = None ) -> tuple [str | None , str | None ]:
412526 """Log in natively.
0 commit comments