Skip to content

Commit 19c2efa

Browse files
authored
Handle sync timeout without fallback
Surface sync timeout as an actionable queryable error without falling back to async mode.
1 parent e1f0950 commit 19c2efa

4 files changed

Lines changed: 70 additions & 45 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ output = wavespeed.run(
6464
{"prompt": "Cat"},
6565
timeout=36000.0, # Max wait time in seconds (default: 36000.0)
6666
poll_interval=1.0, # Status check interval (default: 1.0)
67-
enable_sync_mode=False, # Single request mode, no polling (default: False)
67+
enable_sync_mode=False, # Best-effort sync result attempt (default: False)
6868
)
6969
```
7070

7171
### Sync Mode
7272

73-
Use `enable_sync_mode=True` for a single request that waits for the result (no polling).
73+
Use `enable_sync_mode=True` to ask the API to wait for the result in the initial
74+
request. If the server-side sync wait times out, the SDK raises an error with
75+
the task ID/result URL; the task continues processing and can be queried later.
7476

7577
> **Note:** Not all models support sync mode. Check the model documentation for availability.
7678

src/wavespeed/api/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ def run(
5151
input: Input parameters for the model.
5252
timeout: Maximum time to wait for completion (None = no timeout).
5353
poll_interval: Interval between status checks in seconds.
54-
enable_sync_mode: If True, use synchronous mode (single request).
54+
enable_sync_mode: If True, use synchronous mode (best-effort single
55+
request). If the server-side sync wait times out, an error is
56+
raised with the task ID so the result can be queried later.
5557
max_retries: Maximum retries for this request (overrides default setting).
5658
5759
Returns:

src/wavespeed/api/client.py

Lines changed: 26 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class Client:
2828
client = Client(api_key="your-api-key")
2929
output = client.run("wavespeed-ai/z-image/turbo", {"prompt": "Cat"})
3030
31-
# With sync mode (single request, waits for result)
31+
# With sync mode (best-effort single request, waits for result)
3232
output = client.run("wavespeed-ai/z-image/turbo", {"prompt": "Cat"}, enable_sync_mode=True)
3333
3434
# With retry
@@ -334,6 +334,25 @@ def _is_retryable_error(self, error: Exception) -> bool:
334334

335335
return False
336336

337+
@staticmethod
338+
def _format_sync_mode_error(data: dict[str, Any]) -> str:
339+
"""Build an actionable error for a non-completed sync-mode response."""
340+
request_id = data.get("id") or "unknown"
341+
error = data.get("error") or "Unknown error"
342+
urls = data.get("urls") or {}
343+
result_url = urls.get("get") if isinstance(urls, dict) else None
344+
345+
is_sync_timeout = data.get("code") == 5004 or (
346+
data.get("status") == "processing" and "Sync mode timed out" in error
347+
)
348+
if is_sync_timeout:
349+
message = f"Sync mode timed out (task_id: {request_id}): {error}"
350+
if result_url and result_url not in message:
351+
message += f" Query the result later at: {result_url}"
352+
return message
353+
354+
return f"Prediction failed (task_id: {request_id}): {error}"
355+
337356
def run(
338357
self,
339358
model: str,
@@ -351,9 +370,9 @@ def run(
351370
input: Input parameters for the model.
352371
timeout: Maximum time to wait for completion (None = no timeout).
353372
poll_interval: Interval between status checks in seconds.
354-
enable_sync_mode: If True, use synchronous mode (single request).
355-
If sync mode fails with a gateway timeout (HTTP 502/504),
356-
the SDK automatically falls back to async mode (submit + poll).
373+
enable_sync_mode: If True, use synchronous mode (best-effort single
374+
request). If the server-side sync wait times out, the SDK raises
375+
an error with the task ID so the result can be queried later.
357376
max_retries: Maximum task-level retries (overrides client setting).
358377
359378
Returns:
@@ -366,28 +385,19 @@ def run(
366385
"""
367386
task_retries = max_retries if max_retries is not None else self.max_retries
368387
last_error = None
369-
# Track whether we should fall back from sync to async mode.
370-
# This happens when sync mode hits a gateway timeout (502/504) after
371-
# exhausting connection-level retries — the gateway cannot hold the
372-
# connection long enough, but the backend may still be healthy.
373-
use_sync = enable_sync_mode
374388

375389
for attempt in range(task_retries + 1):
376390
try:
377391
request_id, sync_result = self._submit(
378-
model, input, enable_sync_mode=use_sync, timeout=timeout
392+
model, input, enable_sync_mode=enable_sync_mode, timeout=timeout
379393
)
380394

381-
if use_sync:
395+
if enable_sync_mode:
382396
# In sync mode, extract outputs from the result
383397
status = sync_result.get("data", {}).get("status")
384398
if status != "completed":
385-
error = (
386-
sync_result.get("data", {}).get("error") or "Unknown error"
387-
)
388-
request_id = sync_result.get("data", {}).get("id", "unknown")
389399
raise RuntimeError(
390-
f"Prediction failed (task_id: {request_id}): {error}"
400+
self._format_sync_mode_error(sync_result.get("data", {}))
391401
)
392402
data = sync_result.get("data", {})
393403
return {"outputs": data.get("outputs", [])}
@@ -397,17 +407,6 @@ def run(
397407
except Exception as e:
398408
last_error = e
399409

400-
# Sync-to-async fallback: if sync mode got a gateway timeout
401-
# (502/504) after all connection retries, switch to async mode
402-
# and retry immediately without consuming a task-level retry.
403-
if use_sync and self._is_gateway_timeout(e):
404-
print(
405-
"Sync mode hit gateway timeout, "
406-
"falling back to async mode (submit + poll)..."
407-
)
408-
use_sync = False
409-
continue
410-
411410
is_retryable = self._is_retryable_error(e)
412411

413412
if not is_retryable or attempt >= task_retries:
@@ -423,21 +422,6 @@ def run(
423422
raise last_error
424423
raise RuntimeError(f"All {task_retries + 1} attempts failed")
425424

426-
@staticmethod
427-
def _is_gateway_timeout(error: Exception) -> bool:
428-
"""Check if an error is a gateway timeout (HTTP 502 or 504).
429-
430-
Args:
431-
error: The exception to check.
432-
433-
Returns:
434-
True if the error indicates a gateway timeout.
435-
"""
436-
if isinstance(error, RuntimeError):
437-
error_str = str(error)
438-
return "HTTP 502" in error_str or "HTTP 504" in error_str
439-
return False
440-
441425
def upload(self, file: str | BinaryIO, *, timeout: float | None = None) -> str:
442426
"""Upload a file to WaveSpeed.
443427

tests/test_api.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,43 @@ def test_run_timeout(self, mock_post, mock_get, mock_sleep, mock_time):
171171
client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"}, timeout=10)
172172
self.assertIn("timed out", str(ctx.exception))
173173

174+
@patch("wavespeed.api.client.requests.get")
175+
@patch("wavespeed.api.client.requests.post")
176+
def test_run_sync_mode_timeout_raises_without_fallback(self, mock_post, mock_get):
177+
"""Test sync-mode timeout keeps the task queryable without async fallback."""
178+
result_url = "https://api.wavespeed.ai/api/v3/predictions/req-timeout/result"
179+
mock_post_response = MagicMock()
180+
mock_post_response.status_code = 200
181+
mock_post_response.json.return_value = {
182+
"data": {
183+
"id": "req-timeout",
184+
"status": "processing",
185+
"code": 5004,
186+
"error": (
187+
"Sync mode timed out after 90 seconds. The prediction is "
188+
"still processing asynchronously."
189+
),
190+
"urls": {"get": result_url},
191+
}
192+
}
193+
mock_post.return_value = mock_post_response
194+
195+
client = Client(api_key="test-key")
196+
with self.assertRaises(RuntimeError) as ctx:
197+
client.run(
198+
"wavespeed-ai/z-image/turbo",
199+
{"prompt": "test"},
200+
enable_sync_mode=True,
201+
max_retries=1,
202+
)
203+
204+
error = str(ctx.exception)
205+
self.assertIn("Sync mode timed out", error)
206+
self.assertIn("req-timeout", error)
207+
self.assertIn(result_url, error)
208+
mock_post.assert_called_once()
209+
mock_get.assert_not_called()
210+
174211

175212
class TestModuleLevelRun(unittest.TestCase):
176213
"""Tests for the module-level run() function."""

0 commit comments

Comments
 (0)