99
1010from wavespeed .config import api as api_config
1111
12+ # HTTP status codes that are safe to retry
13+ _RETRYABLE_STATUS_CODES = {429 , 500 , 502 , 503 , 504 }
14+
1215
1316class Client :
1417 """WaveSpeed API client.
@@ -57,6 +60,18 @@ def __init__(
5760 retry_interval if retry_interval is not None else api_config .retry_interval
5861 )
5962
63+ @staticmethod
64+ def _is_retryable_status (status_code : int ) -> bool :
65+ """Check if an HTTP status code is retryable.
66+
67+ Args:
68+ status_code: HTTP response status code.
69+
70+ Returns:
71+ True if the status code indicates a transient error worth retrying.
72+ """
73+ return status_code in _RETRYABLE_STATUS_CODES
74+
6075 def _get_headers (self ) -> dict [str , str ]:
6176 """Get request headers with authentication."""
6277 if not self .api_key :
@@ -106,13 +121,33 @@ def _submit(
106121 )
107122 timeouts = (connect_timeout , request_timeout )
108123
124+ last_error : Exception | None = None
125+
109126 for retry in range (self .max_connection_retries + 1 ):
110127 try :
111128 response = requests .post (
112129 url , json = body , headers = self ._get_headers (), timeout = timeouts
113130 )
114131
115132 if response .status_code != 200 :
133+ # Retry on transient server errors (5xx) and rate limiting (429)
134+ if self ._is_retryable_status (response .status_code ):
135+ last_error = RuntimeError (
136+ f"Failed to submit prediction: HTTP { response .status_code } : "
137+ f"{ response .text } "
138+ )
139+ if retry < self .max_connection_retries :
140+ delay = self .retry_interval * (retry + 1 )
141+ print (
142+ f"Server error (HTTP { response .status_code } ) on attempt "
143+ f"{ retry + 1 } /{ self .max_connection_retries + 1 } , "
144+ f"retrying in { delay } seconds..."
145+ )
146+ time .sleep (delay )
147+ continue
148+ raise last_error
149+
150+ # Non-retryable HTTP errors (4xx etc.) fail immediately
116151 raise RuntimeError (
117152 f"Failed to submit prediction: HTTP { response .status_code } : "
118153 f"{ response .text } "
@@ -133,6 +168,7 @@ def _submit(
133168 requests .exceptions .ConnectionError ,
134169 requests .exceptions .Timeout ,
135170 ) as e :
171+ last_error = e
136172 print (
137173 f"Connection error on attempt { retry + 1 } /{ self .max_connection_retries + 1 } :"
138174 )
@@ -147,6 +183,11 @@ def _submit(
147183 f"Failed to submit prediction after { self .max_connection_retries + 1 } attempts"
148184 ) from e
149185
186+ # Should not reach here, but guard against it
187+ raise last_error or RuntimeError (
188+ f"Failed to submit prediction after { self .max_connection_retries + 1 } attempts"
189+ )
190+
150191 def _get_result (
151192 self , request_id : str , timeout : float | None = None
152193 ) -> dict [str , Any ]:
@@ -171,13 +212,32 @@ def _get_result(
171212 )
172213 timeouts = (connect_timeout , request_timeout )
173214
215+ last_error : Exception | None = None
216+
174217 for retry in range (self .max_connection_retries + 1 ):
175218 try :
176219 response = requests .get (
177220 url , headers = self ._get_headers (), timeout = timeouts
178221 )
179222
180223 if response .status_code != 200 :
224+ # Retry on transient server errors (5xx) and rate limiting (429)
225+ if self ._is_retryable_status (response .status_code ):
226+ last_error = RuntimeError (
227+ f"Failed to get result for task { request_id } : "
228+ f"HTTP { response .status_code } : { response .text } "
229+ )
230+ if retry < self .max_connection_retries :
231+ delay = self .retry_interval * (retry + 1 )
232+ print (
233+ f"Server error (HTTP { response .status_code } ) getting result "
234+ f"on attempt { retry + 1 } /{ self .max_connection_retries + 1 } , "
235+ f"retrying in { delay } seconds..."
236+ )
237+ time .sleep (delay )
238+ continue
239+ raise last_error
240+
181241 raise RuntimeError (
182242 f"Failed to get result for task { request_id } : "
183243 f"HTTP { response .status_code } : { response .text } "
@@ -189,6 +249,7 @@ def _get_result(
189249 requests .exceptions .ConnectionError ,
190250 requests .exceptions .Timeout ,
191251 ) as e :
252+ last_error = e
192253 print (
193254 f"Connection error getting result on attempt { retry + 1 } /{ self .max_connection_retries + 1 } :"
194255 )
@@ -204,6 +265,12 @@ def _get_result(
204265 f"after { self .max_connection_retries + 1 } attempts"
205266 ) from e
206267
268+ # Should not reach here, but guard against it
269+ raise last_error or RuntimeError (
270+ f"Failed to get result for task { request_id } "
271+ f"after { self .max_connection_retries + 1 } attempts"
272+ )
273+
207274 def _wait (
208275 self ,
209276 request_id : str ,
@@ -261,7 +328,12 @@ def _is_retryable_error(self, error: Exception) -> bool:
261328 """
262329 # Always retry timeout and connection errors
263330 if isinstance (
264- error , (requests .exceptions .Timeout , requests .exceptions .ConnectionError )
331+ error ,
332+ (
333+ requests .exceptions .Timeout ,
334+ requests .exceptions .ConnectionError ,
335+ TimeoutError ,
336+ ),
265337 ):
266338 return True
267339
@@ -291,6 +363,8 @@ def run(
291363 timeout: Maximum time to wait for completion (None = no timeout).
292364 poll_interval: Interval between status checks in seconds.
293365 enable_sync_mode: If True, use synchronous mode (single request).
366+ If sync mode fails with a gateway timeout (HTTP 502/504),
367+ the SDK automatically falls back to async mode (submit + poll).
294368 max_retries: Maximum task-level retries (overrides client setting).
295369
296370 Returns:
@@ -303,14 +377,19 @@ def run(
303377 """
304378 task_retries = max_retries if max_retries is not None else self .max_retries
305379 last_error = None
380+ # Track whether we should fall back from sync to async mode.
381+ # This happens when sync mode hits a gateway timeout (502/504) after
382+ # exhausting connection-level retries — the gateway cannot hold the
383+ # connection long enough, but the backend may still be healthy.
384+ use_sync = enable_sync_mode
306385
307386 for attempt in range (task_retries + 1 ):
308387 try :
309388 request_id , sync_result = self ._submit (
310- model , input , enable_sync_mode = enable_sync_mode , timeout = timeout
389+ model , input , enable_sync_mode = use_sync , timeout = timeout
311390 )
312391
313- if enable_sync_mode :
392+ if use_sync :
314393 # In sync mode, extract outputs from the result
315394 status = sync_result .get ("data" , {}).get ("status" )
316395 if status != "completed" :
@@ -328,6 +407,18 @@ def run(
328407
329408 except Exception as e :
330409 last_error = e
410+
411+ # Sync-to-async fallback: if sync mode got a gateway timeout
412+ # (502/504) after all connection retries, switch to async mode
413+ # and retry immediately without consuming a task-level retry.
414+ if use_sync and self ._is_gateway_timeout (e ):
415+ print (
416+ "Sync mode hit gateway timeout, "
417+ "falling back to async mode (submit + poll)..."
418+ )
419+ use_sync = False
420+ continue
421+
331422 is_retryable = self ._is_retryable_error (e )
332423
333424 if not is_retryable or attempt >= task_retries :
@@ -343,6 +434,21 @@ def run(
343434 raise last_error
344435 raise RuntimeError (f"All { task_retries + 1 } attempts failed" )
345436
437+ @staticmethod
438+ def _is_gateway_timeout (error : Exception ) -> bool :
439+ """Check if an error is a gateway timeout (HTTP 502 or 504).
440+
441+ Args:
442+ error: The exception to check.
443+
444+ Returns:
445+ True if the error indicates a gateway timeout.
446+ """
447+ if isinstance (error , RuntimeError ):
448+ error_str = str (error )
449+ return "HTTP 502" in error_str or "HTTP 504" in error_str
450+ return False
451+
346452 def upload (self , file : str | BinaryIO , * , timeout : float | None = None ) -> str :
347453 """Upload a file to WaveSpeed.
348454
0 commit comments