Skip to content

Commit 798b4e8

Browse files
cyberjunkyclaude
andcommitted
feat: opt-in retry with backoff + consolidate API error translation
Replaces the reverted PR #353 with a slimmer, dependency-free implementation. Adds three optional constructor parameters — retry_attempts (default 0 = disabled), retry_min_wait and retry_max_wait — that enable exponential backoff with jitter for transient 5xx / network errors on connectapi, connectwebproxy and download. 401, 429 and 4xx always fail fast; existing callers see zero behaviour change until they opt in. The new _handle_api_errors decorator consolidates the ~80 lines of triplicated try/except across the three API methods into one place and also provides the retry loop, preserving .response on translated exceptions so callers like get_gear_stats can still introspect status codes. No tenacity dependency — the backoff is ~15 lines of stdlib time.sleep + random.random() jitter. Adds 26 in-process tests covering status extraction, retry predicate, fast-fail, exhaustion, flaky recovery and constructor validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 94cbb84 commit 798b4e8

2 files changed

Lines changed: 443 additions & 82 deletions

File tree

garminconnect/__init__.py

Lines changed: 196 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Python 3 API wrapper for Garmin Connect."""
22

33
import contextlib
4+
import functools
45
import logging
56
import numbers
67
import os
8+
import random
79
import re
810
import time
911
from collections.abc import Callable
@@ -20,6 +22,12 @@
2022

2123
logger = 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
2432
MAX_ACTIVITY_LIMIT = 1000
2533
MAX_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+
101268
class 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

Comments
 (0)