Skip to content

Commit 132684d

Browse files
committed
Feat: Add rate limit retry with exponential backoff
AsyncClient now automatically retries on 429 responses with exponential backoff (1s, 2s, 4s). Max 3 retries before returning None.
1 parent 70f95ab commit 132684d

1 file changed

Lines changed: 38 additions & 13 deletions

File tree

leakix/async_client.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Async LeakIX API client using httpx."""
22

3+
import asyncio
34
import json
45
from typing import AsyncIterator
56

@@ -64,17 +65,31 @@ async def _get(
6465
self,
6566
path: str,
6667
params: dict | None = None,
68+
max_retries: int = 3,
6769
) -> tuple[int, dict | list | None]:
68-
"""Make a GET request and return status code and JSON response."""
70+
"""Make a GET request and return status code and JSON response.
71+
72+
Automatically retries on rate limit (429) with exponential backoff.
73+
"""
6974
client = await self._get_client()
70-
response = await client.get(path, params=params)
71-
if response.status_code == 204:
72-
return response.status_code, []
73-
if response.status_code == 200:
74-
return response.status_code, response.json() if response.content else []
75-
if response.status_code == 429:
76-
return response.status_code, None
77-
return response.status_code, response.json()
75+
retries = 0
76+
delay = 1.0
77+
78+
while True:
79+
response = await client.get(path, params=params)
80+
81+
if response.status_code == 204:
82+
return response.status_code, []
83+
if response.status_code == 200:
84+
return response.status_code, response.json() if response.content else []
85+
if response.status_code == 429:
86+
if retries >= max_retries:
87+
return response.status_code, None
88+
retries += 1
89+
await asyncio.sleep(delay)
90+
delay *= 2
91+
continue
92+
return response.status_code, response.json()
7893

7994
async def get(
8095
self,
@@ -163,11 +178,21 @@ async def get_host(self, ip: str) -> dict:
163178
services = data.get("Services") or []
164179
leaks = data.get("Leaks") or []
165180
return {
166-
"services": [l9format.L9Event.from_dict(s) for s in services],
167-
"leaks": [l9format.L9Event.from_dict(l) for l in leaks],
181+
"services": self._parse_events(services),
182+
"leaks": self._parse_events(leaks),
168183
}
169184
return {"services": [], "leaks": []}
170185

186+
def _parse_events(self, items: list) -> list:
187+
"""Parse events, falling back to raw dicts if l9format fails."""
188+
results = []
189+
for item in items:
190+
try:
191+
results.append(l9format.L9Event.from_dict(item))
192+
except Exception:
193+
results.append(item)
194+
return results
195+
171196
async def get_domain(self, domain: str) -> dict:
172197
"""
173198
Get services and leaks for a specific domain.
@@ -183,8 +208,8 @@ async def get_domain(self, domain: str) -> dict:
183208
services = data.get("Services") or []
184209
leaks = data.get("Leaks") or []
185210
return {
186-
"services": [l9format.L9Event.from_dict(s) for s in services],
187-
"leaks": [l9format.L9Event.from_dict(l) for l in leaks],
211+
"services": self._parse_events(services),
212+
"leaks": self._parse_events(leaks),
188213
}
189214
return {"services": [], "leaks": []}
190215

0 commit comments

Comments
 (0)