Skip to content

Commit f19e49d

Browse files
committed
Async context manager + retry/backoff logic
1 parent a094da0 commit f19e49d

File tree

6 files changed

+268
-53
lines changed

6 files changed

+268
-53
lines changed

README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import asyncio, json
2424
from fmd_api import FmdClient
2525

2626
async def main():
27-
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
28-
27+
# Recommended: async context manager auto-closes session
28+
async with await FmdClient.create("https://fmd.example.com", "alice", "secret") as client:
2929
# Request a fresh GPS fix and wait a bit on your side
3030
await client.request_location("gps")
3131

@@ -37,8 +37,6 @@ async def main():
3737
# Take a picture (validated helper)
3838
await client.take_picture("front")
3939

40-
await client.close()
41-
4240
asyncio.run(main())
4341
```
4442

docs/HOME_ASSISTANT_REVIEW.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ async def _make_api_request(self, ..., timeout: int = 30):
6767

6868
**HA Rationale:** Only stable, released versions accepted as integration dependencies.
6969

70-
**Status:** ❌ TODO
70+
**Status:** ✅ FIXED
71+
- Implemented configurable retry policy in `FmdClient._make_api_request()`
72+
- Handles 429 with `Retry-After` header and exponential backoff with jitter
73+
- Retries transient 5xx (500/502/503/504) and connection errors
74+
- Avoids unsafe retries for `/api/v1/command` POST requests
75+
- Configurable via constructor: `max_retries`, `backoff_base`, `backoff_max`, `jitter`
76+
- Added unit tests for 429 + Retry-After and 500 -> success flows
7177

7278
---
7379

@@ -147,7 +153,13 @@ async with await FmdClient.create(...) as client:
147153

148154
**HA Rationale:** Context managers are the Python standard for resource management. HA prefers libraries that follow this pattern.
149155

150-
**Status:** ❌ TODO
156+
**Status:** ✅ FIXED
157+
- Implemented `__aenter__` and `__aexit__` on `FmdClient`
158+
- Usage supported:
159+
- `async with FmdClient(base_url) as client:`
160+
- `async with await FmdClient.create(base_url, fmd_id, password) as client:`
161+
- On exit, aiohttp session is closed automatically via `close()`
162+
- Added unit tests verifying auto-close behavior
151163

152164
---
153165

fmd_api/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.0.0.dev10"
1+
__version__ = "2.0.0.dev11"

fmd_api/client.py

Lines changed: 152 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
from __future__ import annotations
1616

1717
import base64
18+
import asyncio
1819
import json
1920
import logging
2021
import time
22+
import random
2123
from typing import Optional, List, Any
2224

2325
import aiohttp
@@ -40,11 +42,26 @@
4042

4143

4244
class FmdClient:
43-
def __init__(self, base_url: str, session_duration: int = 3600, *, cache_ttl: int = 30, timeout: float = 30.0):
45+
def __init__(
46+
self,
47+
base_url: str,
48+
session_duration: int = 3600,
49+
*,
50+
cache_ttl: int = 30,
51+
timeout: float = 30.0,
52+
max_retries: int = 3,
53+
backoff_base: float = 0.5,
54+
backoff_max: float = 10.0,
55+
jitter: bool = True,
56+
):
4457
self.base_url = base_url.rstrip('/')
4558
self.session_duration = session_duration
4659
self.cache_ttl = cache_ttl
4760
self.timeout = timeout # default timeout for all HTTP requests (seconds)
61+
self.max_retries = max(0, int(max_retries))
62+
self.backoff_base = float(backoff_base)
63+
self.backoff_max = float(backoff_max)
64+
self.jitter = bool(jitter)
4865

4966
self._fmd_id: Optional[str] = None
5067
self._password: Optional[str] = None
@@ -53,6 +70,15 @@ def __init__(self, base_url: str, session_duration: int = 3600, *, cache_ttl: in
5370

5471
self._session: Optional[aiohttp.ClientSession] = None
5572

73+
# -------------------------
74+
# Async context manager
75+
# -------------------------
76+
async def __aenter__(self) -> "FmdClient":
77+
return self
78+
79+
async def __aexit__(self, exc_type, exc, tb) -> None:
80+
await self.close()
81+
5682
@classmethod
5783
async def create(
5884
cls,
@@ -180,61 +206,110 @@ def decrypt_data_blob(self, data_b64: str) -> bytes:
180206
# HTTP helper
181207
# -------------------------
182208
async def _make_api_request(self, method: str, endpoint: str, payload: Any,
183-
stream: bool = False, expect_json: bool = True, retry_auth: bool = True, timeout: Optional[float] = None):
209+
stream: bool = False, expect_json: bool = True, retry_auth: bool = True,
210+
timeout: Optional[float] = None, max_retries: Optional[int] = None):
184211
"""
185212
Makes an API request and returns Data or text depending on expect_json/stream.
186213
Mirrors get_all_locations/_make_api_request logic from original file (including 401 re-auth).
187214
"""
188215
url = self.base_url + endpoint
189216
await self._ensure_session()
190217
req_timeout = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout)
191-
try:
192-
async with self._session.request(method, url, json=payload, timeout=req_timeout) as resp:
193-
# Handle 401 -> re-authenticate once
194-
if resp.status == 401 and retry_auth and self._fmd_id and self._password:
195-
log.info("Received 401 Unauthorized, re-authenticating...")
196-
await self.authenticate(
197-
self._fmd_id, self._password, self.session_duration)
198-
payload["IDT"] = self.access_token
199-
return await self._make_api_request(
200-
method, endpoint, payload, stream, expect_json,
201-
retry_auth=False, timeout=timeout)
202218

203-
resp.raise_for_status()
204-
log.debug(
205-
f"{endpoint} response - status: {resp.status}, "
206-
f"content-type: {resp.content_type}, "
207-
f"content-length: {resp.content_length}")
208-
209-
if not stream:
210-
if expect_json:
211-
# server sometimes reports wrong content-type -> force JSON parse
212-
try:
213-
json_data = await resp.json(content_type=None)
214-
log.debug(f"{endpoint} JSON response: {json_data}")
215-
return json_data["Data"]
216-
except (KeyError, ValueError, json.JSONDecodeError) as e:
217-
# fall back to text
218-
log.debug(f"{endpoint} JSON parsing failed ({e}), trying as text")
219+
# Determine retry policy
220+
attempts_left = self.max_retries if max_retries is None else max(0, int(max_retries))
221+
222+
# Avoid unsafe retries for commands unless it's a 401 (handled separately) or 429 with Retry-After
223+
is_command = endpoint.rstrip('/').endswith('/api/v1/command')
224+
225+
backoff_attempt = 0
226+
while True:
227+
try:
228+
async with self._session.request(method, url, json=payload, timeout=req_timeout) as resp:
229+
# Handle 401 -> re-authenticate once
230+
if resp.status == 401 and retry_auth and self._fmd_id and self._password:
231+
log.info("Received 401 Unauthorized, re-authenticating...")
232+
await self.authenticate(
233+
self._fmd_id, self._password, self.session_duration)
234+
payload["IDT"] = self.access_token
235+
return await self._make_api_request(
236+
method, endpoint, payload, stream, expect_json,
237+
retry_auth=False, timeout=timeout, max_retries=attempts_left)
238+
239+
# Rate limit handling (429)
240+
if resp.status == 429:
241+
if attempts_left <= 0:
242+
# Exhausted retries
243+
body_text = await _safe_read_text(resp)
244+
raise FmdApiException(f"Rate limited (429) and retries exhausted. Body={body_text[:200] if body_text else ''}")
245+
retry_after = resp.headers.get('Retry-After')
246+
delay = _parse_retry_after(retry_after)
247+
if delay is None:
248+
delay = _compute_backoff(self.backoff_base, backoff_attempt, self.backoff_max, self.jitter)
249+
log.warning(f"Received 429 Too Many Requests. Sleeping {delay:.2f}s before retrying...")
250+
attempts_left -= 1
251+
backoff_attempt += 1
252+
await asyncio.sleep(delay)
253+
continue
254+
255+
# Transient server errors -> retry (except for unsafe command POSTs)
256+
if resp.status in (500, 502, 503, 504) and not (is_command and method.upper() == 'POST'):
257+
if attempts_left > 0:
258+
delay = _compute_backoff(self.backoff_base, backoff_attempt, self.backoff_max, self.jitter)
259+
log.warning(f"Server error {resp.status}. Retrying in {delay:.2f}s ({attempts_left} retries left)...")
260+
attempts_left -= 1
261+
backoff_attempt += 1
262+
await asyncio.sleep(delay)
263+
continue
264+
265+
# For all other statuses, raise for non-2xx
266+
resp.raise_for_status()
267+
268+
log.debug(
269+
f"{endpoint} response - status: {resp.status}, "
270+
f"content-type: {resp.content_type}, "
271+
f"content-length: {resp.content_length}")
272+
273+
if not stream:
274+
if expect_json:
275+
# server sometimes reports wrong content-type -> force JSON parse
276+
try:
277+
json_data = await resp.json(content_type=None)
278+
log.debug(f"{endpoint} JSON response: {json_data}")
279+
return json_data["Data"]
280+
except (KeyError, ValueError, json.JSONDecodeError) as e:
281+
# fall back to text
282+
log.debug(f"{endpoint} JSON parsing failed ({e}), trying as text")
283+
text_data = await resp.text()
284+
if text_data:
285+
log.debug(f"{endpoint} first 200 chars: {text_data[:200]}")
286+
else:
287+
log.warning(f"{endpoint} returned EMPTY response body")
288+
return text_data
289+
else:
219290
text_data = await resp.text()
220-
if text_data:
221-
log.debug(f"{endpoint} first 200 chars: {text_data[:200]}")
222-
else:
223-
log.warning(f"{endpoint} returned EMPTY response body")
291+
log.debug(f"{endpoint} text response length: {len(text_data)}")
224292
return text_data
225293
else:
226-
text_data = await resp.text()
227-
log.debug(f"{endpoint} text response length: {len(text_data)}")
228-
return text_data
229-
else:
230-
# Return the aiohttp response for streaming consumers
231-
return resp
232-
except aiohttp.ClientError as e:
233-
log.error(f"API request failed for {endpoint}: {e}")
234-
raise FmdApiException(f"API request failed for {endpoint}: {e}") from e
235-
except (KeyError, ValueError) as e:
236-
log.error(f"Failed to parse server response for {endpoint}: {e}")
237-
raise FmdApiException(f"Failed to parse server response for {endpoint}: {e}") from e
294+
# Return the aiohttp response for streaming consumers
295+
return resp
296+
except aiohttp.ClientConnectionError as e:
297+
# Transient connection issues -> retry if allowed (avoid unsafe command repeats)
298+
if attempts_left > 0 and not (is_command and method.upper() == 'POST'):
299+
delay = _compute_backoff(self.backoff_base, backoff_attempt, self.backoff_max, self.jitter)
300+
log.warning(f"Connection error calling {endpoint}: {e}. Retrying in {delay:.2f}s...")
301+
attempts_left -= 1
302+
backoff_attempt += 1
303+
await asyncio.sleep(delay)
304+
continue
305+
log.error(f"API request failed for {endpoint}: {e}")
306+
raise FmdApiException(f"API request failed for {endpoint}: {e}") from e
307+
except aiohttp.ClientError as e:
308+
log.error(f"API request failed for {endpoint}: {e}")
309+
raise FmdApiException(f"API request failed for {endpoint}: {e}") from e
310+
except (KeyError, ValueError) as e:
311+
log.error(f"Failed to parse server response for {endpoint}: {e}")
312+
raise FmdApiException(f"Failed to parse server response for {endpoint}: {e}") from e
238313

239314
# -------------------------
240315
# Location / picture access
@@ -534,3 +609,35 @@ async def take_picture(self, camera: str = "back") -> bool:
534609
command = "camera front" if camera == "front" else "camera back"
535610
log.info(f"Requesting picture from {camera} camera")
536611
return await self.send_command(command)
612+
613+
614+
# -------------------------
615+
# Internal helpers for retry/backoff (module-level)
616+
# -------------------------
617+
def _compute_backoff(base: float, attempt: int, max_delay: float, jitter: bool) -> float:
618+
delay = min(max_delay, base * (2 ** attempt))
619+
if jitter:
620+
# Full jitter: random between 0 and delay
621+
return random.uniform(0, delay)
622+
return delay
623+
624+
625+
def _parse_retry_after(retry_after_header: Optional[str]) -> Optional[float]:
626+
"""Parse Retry-After header. Supports seconds; returns None if not usable."""
627+
if not retry_after_header:
628+
return None
629+
try:
630+
seconds = int(retry_after_header.strip())
631+
if seconds < 0:
632+
return None
633+
return float(seconds)
634+
except Exception:
635+
# Parsing HTTP-date would require email.utils; skip and return None
636+
return None
637+
638+
639+
async def _safe_read_text(resp: aiohttp.ClientResponse) -> Optional[str]:
640+
try:
641+
return await resp.text()
642+
except Exception:
643+
return None

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fmd_api"
3-
version = "2.0.0.dev10"
3+
version = "2.0.0.dev11"
44
authors = [{name = "devinslick"}]
55
description = "A Python client for the FMD (Find My Device) server API"
66
readme = "README.md"

0 commit comments

Comments
 (0)