Skip to content

Commit c70dbe8

Browse files
authored
Merge pull request #3 from WaveSpeedAI/dev
fix:504 and 429 related errors handled
2 parents 6f28d16 + 1e1a511 commit c70dbe8

1 file changed

Lines changed: 109 additions & 3 deletions

File tree

src/wavespeed/api/client.py

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
from 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

1316
class 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

Comments
 (0)