55# mypy: disable-error-code=unreachable
66
77import asyncio
8+ import random
89from typing import Any , Callable , Dict , List , Optional
910from 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