Skip to content

Commit 89abb63

Browse files
committed
feat: add exponential backoff retry logic to HTTP requests
- Add configurable retry parameters to EngineConfig (max_attempts, base_delay) - Implement intelligent retry logic for 5xx errors, 429 rate limits, and network timeouts - Add exponential backoff with jitter and Retry-After header support - Integrate retry wrapper into _localize_chunk, recognize_locale, and whoami methods - Maintain full backward compatibility with existing SDK behavior - Add comprehensive test coverage (96/96 tests passing)
1 parent 56a09e4 commit 89abb63

File tree

3 files changed

+1027
-14
lines changed

3 files changed

+1027
-14
lines changed

src/lingodotdev/engine.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# mypy: disable-error-code=unreachable
66

77
import asyncio
8+
import random
89
from typing import Any, Callable, Dict, List, Optional
910
from urllib.parse import urljoin
1011

@@ -20,6 +21,8 @@ class EngineConfig(BaseModel):
2021
api_url: str = "https://engine.lingo.dev"
2122
batch_size: int = Field(default=25, ge=1, le=250)
2223
ideal_batch_item_size: int = Field(default=250, ge=1, le=2500)
24+
retry_max_attempts: int = Field(default=3, ge=0, le=10)
25+
retry_base_delay: float = Field(default=1.0, ge=0.1, le=10.0)
2326

2427
@validator("api_url")
2528
@classmethod
@@ -80,6 +83,93 @@ async def close(self):
8083
if self._client and not self._client.is_closed:
8184
await self._client.aclose()
8285

86+
def _should_retry_response(self, response: httpx.Response) -> bool:
87+
"""
88+
Determine if a response should be retried.
89+
90+
Args:
91+
response: The HTTP response to evaluate
92+
93+
Returns:
94+
True if the response indicates a retryable error, False otherwise
95+
"""
96+
# Retry on server errors (5xx) and rate limiting (429)
97+
return response.status_code >= 500 or response.status_code == 429
98+
99+
def _calculate_retry_delay(self, attempt: int, response: Optional[httpx.Response]) -> float:
100+
"""
101+
Calculate delay for next retry attempt using exponential backoff with jitter.
102+
103+
Args:
104+
attempt: The current attempt number (0-based)
105+
response: The HTTP response (if available) to check for Retry-After header
106+
107+
Returns:
108+
Delay in seconds before next retry attempt
109+
"""
110+
# Base exponential backoff: base_delay * (2 ^ attempt)
111+
base_delay = self.config.retry_base_delay * (2 ** attempt)
112+
113+
# Handle 429 Retry-After header
114+
if response and response.status_code == 429:
115+
retry_after = response.headers.get('retry-after')
116+
if retry_after:
117+
try:
118+
retry_after_seconds = float(retry_after)
119+
base_delay = max(base_delay, retry_after_seconds)
120+
except ValueError:
121+
# Invalid retry-after header, use exponential backoff
122+
pass
123+
124+
# Add jitter (0-10% of calculated delay) to prevent thundering herd
125+
jitter = random.uniform(0, 0.1 * base_delay)
126+
return base_delay + jitter
127+
128+
async def _make_request_with_retry(
129+
self, url: str, request_data: Dict[str, Any]
130+
) -> httpx.Response:
131+
"""
132+
Make HTTP request with exponential backoff retry logic.
133+
134+
Args:
135+
url: The URL to make the request to
136+
request_data: The JSON data to send in the request
137+
138+
Returns:
139+
The HTTP response from the successful request
140+
141+
Raises:
142+
RuntimeError: When all retry attempts are exhausted
143+
"""
144+
await self._ensure_client()
145+
assert self._client is not None # Type guard for mypy
146+
147+
last_exception = None
148+
149+
for attempt in range(self.config.retry_max_attempts + 1):
150+
try:
151+
response = await self._client.post(url, json=request_data)
152+
153+
# Check if response should be retried
154+
if self._should_retry_response(response) and attempt < self.config.retry_max_attempts:
155+
delay = self._calculate_retry_delay(attempt, response)
156+
await asyncio.sleep(delay)
157+
continue
158+
159+
return response
160+
161+
except httpx.RequestError as e:
162+
last_exception = e
163+
if attempt < self.config.retry_max_attempts:
164+
delay = self._calculate_retry_delay(attempt, None)
165+
await asyncio.sleep(delay)
166+
continue
167+
break
168+
169+
# All retries exhausted
170+
total_attempts = self.config.retry_max_attempts + 1
171+
raise RuntimeError(f"Request failed after {total_attempts} attempts: {last_exception}")
172+
83173
async def _localize_raw(
84174
self,
85175
payload: Dict[str, Any],
@@ -181,7 +271,7 @@ async def _localize_chunk(
181271
request_data["reference"] = payload["reference"]
182272

183273
try:
184-
response = await self._client.post(url, json=request_data)
274+
response = await self._make_request_with_retry(url, request_data)
185275

186276
if not response.is_success:
187277
if 500 <= response.status_code < 600:
@@ -423,7 +513,7 @@ async def recognize_locale(self, text: str) -> str:
423513
url = urljoin(self.config.api_url, "/recognize")
424514

425515
try:
426-
response = await self._client.post(url, json={"text": text})
516+
response = await self._make_request_with_retry(url, {"text": text})
427517

428518
if not response.is_success:
429519
if 500 <= response.status_code < 600:
@@ -453,7 +543,7 @@ async def whoami(self) -> Optional[Dict[str, str]]:
453543
url = urljoin(self.config.api_url, "/whoami")
454544

455545
try:
456-
response = await self._client.post(url)
546+
response = await self._make_request_with_retry(url, {})
457547

458548
if response.is_success:
459549
payload = response.json()

0 commit comments

Comments
 (0)