wavespeed 2.0.3__tar.gz → 2.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {wavespeed-2.0.3/src/wavespeed.egg-info → wavespeed-2.0.5}/PKG-INFO +10 -2
  2. {wavespeed-2.0.3 → wavespeed-2.0.5}/README.md +9 -1
  3. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed/_version.py +1 -1
  4. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed/api/__init__.py +3 -1
  5. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed/api/client.py +67 -50
  6. wavespeed-2.0.5/src/wavespeed/config.py +59 -0
  7. {wavespeed-2.0.3 → wavespeed-2.0.5/src/wavespeed.egg-info}/PKG-INFO +10 -2
  8. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed.egg-info/SOURCES.txt +2 -1
  9. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed.egg-info/scm_file_list.json +2 -1
  10. wavespeed-2.0.5/src/wavespeed.egg-info/scm_version.json +8 -0
  11. {wavespeed-2.0.3 → wavespeed-2.0.5}/tests/test_api.py +102 -2
  12. {wavespeed-2.0.3 → wavespeed-2.0.5}/tests/test_config.py +5 -1
  13. wavespeed-2.0.5/tests/test_timeouts.py +235 -0
  14. wavespeed-2.0.3/src/wavespeed/config.py +0 -42
  15. wavespeed-2.0.3/src/wavespeed.egg-info/scm_version.json +0 -8
  16. {wavespeed-2.0.3 → wavespeed-2.0.5}/.github/dependabot.yml +0 -0
  17. {wavespeed-2.0.3 → wavespeed-2.0.5}/.github/workflows/claude.yml +0 -0
  18. {wavespeed-2.0.3 → wavespeed-2.0.5}/.github/workflows/pre-commit.yml +0 -0
  19. {wavespeed-2.0.3 → wavespeed-2.0.5}/.github/workflows/python-packages.yml +0 -0
  20. {wavespeed-2.0.3 → wavespeed-2.0.5}/.github/workflows/python-publish.yml +0 -0
  21. {wavespeed-2.0.3 → wavespeed-2.0.5}/.github/workflows/tests.yml +0 -0
  22. {wavespeed-2.0.3 → wavespeed-2.0.5}/.gitignore +0 -0
  23. {wavespeed-2.0.3 → wavespeed-2.0.5}/.pre-commit-config.yaml +0 -0
  24. {wavespeed-2.0.3 → wavespeed-2.0.5}/CHANGELOG.md +0 -0
  25. {wavespeed-2.0.3 → wavespeed-2.0.5}/CLAUDE.md +0 -0
  26. {wavespeed-2.0.3 → wavespeed-2.0.5}/LICENSE +0 -0
  27. {wavespeed-2.0.3 → wavespeed-2.0.5}/MANIFEST.in +0 -0
  28. {wavespeed-2.0.3 → wavespeed-2.0.5}/VERSIONING.md +0 -0
  29. {wavespeed-2.0.3 → wavespeed-2.0.5}/assets/wavespeed-logo-dark.svg +0 -0
  30. {wavespeed-2.0.3 → wavespeed-2.0.5}/assets/wavespeed-logo-light.svg +0 -0
  31. {wavespeed-2.0.3 → wavespeed-2.0.5}/pyproject.toml +0 -0
  32. {wavespeed-2.0.3 → wavespeed-2.0.5}/pytest.ini +0 -0
  33. {wavespeed-2.0.3 → wavespeed-2.0.5}/setup.cfg +0 -0
  34. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed/__init__.py +0 -0
  35. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed/_config_module.py +0 -0
  36. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed.egg-info/dependency_links.txt +0 -0
  37. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed.egg-info/not-zip-safe +0 -0
  38. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed.egg-info/requires.txt +0 -0
  39. {wavespeed-2.0.3 → wavespeed-2.0.5}/src/wavespeed.egg-info/top_level.txt +0 -0
  40. {wavespeed-2.0.3 → wavespeed-2.0.5}/tests/__init__.py +0 -0
  41. {wavespeed-2.0.3 → wavespeed-2.0.5}/tests/conftest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavespeed
3
- Version: 2.0.3
3
+ Version: 2.0.5
4
4
  Summary: WaveSpeed Python Client — Official Python SDK for the WaveSpeed inference platform
5
5
  Author-email: WaveSpeedAI <support@wavespeed.ai>
6
6
  License: MIT
@@ -94,12 +94,20 @@ output = client.run("wavespeed-ai/z-image/turbo", {"prompt": "Cat"})
94
94
  output = wavespeed.run(
95
95
  "wavespeed-ai/z-image/turbo",
96
96
  {"prompt": "Cat"},
97
- timeout=36000.0, # Max wait time in seconds (default: 36000.0)
97
+ timeout=None, # Max wait time in seconds (default: None = wait until the task finishes)
98
98
  poll_interval=1.0, # Status check interval (default: 1.0)
99
99
  enable_sync_mode=False, # Best-effort sync result attempt (default: False)
100
100
  )
101
101
  ```
102
102
 
103
+ ### Timeouts
104
+
105
+ `timeout` is the overall wait for a task and defaults to no limit. Each HTTP
106
+ request also has its own read timeout (time waiting for the server, not a
107
+ total-transfer deadline): 30s for result queries, 60s for async submit and
108
+ 120s for uploads. Sync mode has no default read timeout. Set
109
+ `wavespeed.config.api.read_timeout` to override all of these at once.
110
+
103
111
  ### Sync Mode
104
112
 
105
113
  Use `enable_sync_mode=True` to ask the API to wait for the result in the initial
@@ -65,12 +65,20 @@ output = client.run("wavespeed-ai/z-image/turbo", {"prompt": "Cat"})
65
65
  output = wavespeed.run(
66
66
  "wavespeed-ai/z-image/turbo",
67
67
  {"prompt": "Cat"},
68
- timeout=36000.0, # Max wait time in seconds (default: 36000.0)
68
+ timeout=None, # Max wait time in seconds (default: None = wait until the task finishes)
69
69
  poll_interval=1.0, # Status check interval (default: 1.0)
70
70
  enable_sync_mode=False, # Best-effort sync result attempt (default: False)
71
71
  )
72
72
  ```
73
73
 
74
+ ### Timeouts
75
+
76
+ `timeout` is the overall wait for a task and defaults to no limit. Each HTTP
77
+ request also has its own read timeout (time waiting for the server, not a
78
+ total-transfer deadline): 30s for result queries, 60s for async submit and
79
+ 120s for uploads. Sync mode has no default read timeout. Set
80
+ `wavespeed.config.api.read_timeout` to override all of these at once.
81
+
74
82
  ### Sync Mode
75
83
 
76
84
  Use `enable_sync_mode=True` to ask the API to wait for the result in the initial
@@ -1,2 +1,2 @@
1
1
  # This file is automatically generated by setuptools_scm
2
- __version__ = "2.0.3"
2
+ __version__ = "2.0.5"
@@ -173,7 +173,9 @@ def upload(file: str | BinaryIO, *, timeout: float | None = None) -> str:
173
173
 
174
174
  Args:
175
175
  file: File path string or file-like object to upload.
176
- timeout: Total API call timeout in seconds.
176
+ timeout: Per-request read timeout in seconds (default:
177
+ ``api_config.read_timeout_upload``;
178
+ ``api_config.read_timeout`` overrides). Not a total-transfer deadline.
177
179
 
178
180
  Returns:
179
181
  URL of the uploaded file.
@@ -44,6 +44,10 @@ class _SubmissionError(RuntimeError):
44
44
  """A task submission failed and must not be retried automatically."""
45
45
 
46
46
 
47
+ class _TaskFailedError(RuntimeError):
48
+ """The task itself reported status ``failed``; a task-level re-run is allowed."""
49
+
50
+
47
51
  class Client:
48
52
  """WaveSpeed API client.
49
53
 
@@ -129,6 +133,23 @@ class Client:
129
133
  "X-Client-OS": _get_client_os(),
130
134
  }
131
135
 
136
+ @staticmethod
137
+ def _default_read_timeout(per_type_default: float) -> float:
138
+ """Return ``api_config.read_timeout`` if set, else the per-type default."""
139
+ override = api_config.read_timeout
140
+ return override if override is not None else per_type_default
141
+
142
+ def _request_timeouts(
143
+ self, read_timeout: float | None
144
+ ) -> tuple[float, float | None]:
145
+ """Build a (connect, read) timeouts tuple; read None means no limit."""
146
+ connect_timeout = (
147
+ min(self.connection_timeout, read_timeout)
148
+ if read_timeout
149
+ else self.connection_timeout
150
+ )
151
+ return (connect_timeout, read_timeout)
152
+
132
153
  def _submit(
133
154
  self,
134
155
  model: str,
@@ -142,7 +163,9 @@ class Client:
142
163
  model: Model identifier.
143
164
  input: Input parameters.
144
165
  enable_sync_mode: If True, wait for result in single request.
145
- timeout: Request timeout in seconds.
166
+ timeout: Request read timeout in seconds. Default:
167
+ ``api_config.read_timeout_submit`` (async; ``api_config.read_timeout``
168
+ overrides) or ``api_config.timeout`` (sync mode; None = no limit).
146
169
 
147
170
  Returns:
148
171
  Tuple of (request_id, result). In async mode, result is None.
@@ -157,14 +180,14 @@ class Client:
157
180
  if enable_sync_mode:
158
181
  body["enable_sync_mode"] = True
159
182
 
160
- request_timeout = timeout if timeout is not None else api_config.timeout
161
- # Use connection timeout for connect, request_timeout for read
162
- connect_timeout = (
163
- min(self.connection_timeout, request_timeout)
164
- if request_timeout
165
- else self.connection_timeout
166
- )
167
- timeouts = (connect_timeout, request_timeout)
183
+ if timeout is not None:
184
+ request_timeout = timeout
185
+ elif enable_sync_mode:
186
+ # The sync-mode POST stays open until the task finishes.
187
+ request_timeout = api_config.timeout
188
+ else:
189
+ request_timeout = self._default_read_timeout(api_config.read_timeout_submit)
190
+ timeouts = self._request_timeouts(request_timeout)
168
191
 
169
192
  try:
170
193
  response = requests.post(
@@ -231,13 +254,12 @@ class Client:
231
254
  RuntimeError: If fetching result fails after retries.
232
255
  """
233
256
  url = f"{self.base_url}/api/v3/predictions/{request_id}/result"
234
- request_timeout = timeout if timeout is not None else api_config.timeout
235
- connect_timeout = (
236
- min(self.connection_timeout, request_timeout)
237
- if request_timeout
238
- else self.connection_timeout
257
+ request_timeout = (
258
+ timeout
259
+ if timeout is not None
260
+ else self._default_read_timeout(api_config.read_timeout_get)
239
261
  )
240
- timeouts = (connect_timeout, request_timeout)
262
+ timeouts = self._request_timeouts(request_timeout)
241
263
 
242
264
  last_error: Exception | None = None
243
265
 
@@ -302,7 +324,8 @@ class Client:
302
324
 
303
325
  Args:
304
326
  request_id: The prediction request ID.
305
- timeout: Maximum wait time in seconds (None = no timeout).
327
+ timeout: Maximum wait time in seconds. None falls back to
328
+ ``api_config.timeout``, which defaults to None (no limit).
306
329
  poll_interval: Time between polls in seconds.
307
330
 
308
331
  Returns:
@@ -313,14 +336,15 @@ class Client:
313
336
  TimeoutError: If prediction times out.
314
337
  """
315
338
  start_time = time.time()
339
+ overall_timeout = timeout if timeout is not None else api_config.timeout
316
340
 
317
341
  while True:
318
342
  # Check timeout
319
- if timeout is not None:
343
+ if overall_timeout is not None:
320
344
  elapsed = time.time() - start_time
321
- if elapsed >= timeout:
345
+ if elapsed >= overall_timeout:
322
346
  raise TimeoutError(
323
- f"Prediction timed out after {timeout} seconds (task_id: {request_id})"
347
+ f"Prediction timed out after {overall_timeout} seconds (task_id: {request_id})"
324
348
  )
325
349
 
326
350
  result = self._get_result(request_id, timeout=timeout)
@@ -332,14 +356,13 @@ class Client:
332
356
 
333
357
  if status in ("failed", "cancelled", "timeout", "deleted"):
334
358
  error = data.get("error") or "Unknown error"
335
- raise RuntimeError(
336
- f"Prediction {status} (task_id: {request_id}): {error}"
337
- )
359
+ error_cls = _TaskFailedError if status == "failed" else RuntimeError
360
+ raise error_cls(f"Prediction {status} (task_id: {request_id}): {error}")
338
361
 
339
362
  time.sleep(poll_interval)
340
363
 
341
364
  def _is_retryable_error(self, error: Exception) -> bool:
342
- """Determine if an error is worth retrying at the task level.
365
+ """Determine if an error is worth re-running the task for.
343
366
 
344
367
  Args:
345
368
  error: The exception to check.
@@ -347,29 +370,17 @@ class Client:
347
370
  Returns:
348
371
  True if the error is retryable.
349
372
  """
350
- # Submission errors are ambiguous: the server may already have created
351
- # the task, so never turn them into another POST automatically.
352
- if isinstance(error, _SubmissionError):
373
+ # Only a failure reported by the task itself is re-run, and only when it
374
+ # was a transient upstream error (HTTP 5xx / 429), as before: a
375
+ # deterministic failure such as a content-policy rejection would fail
376
+ # again. Submission errors are ambiguous (the task may already exist),
377
+ # and poll-side HTTP errors, poll retry exhaustion and client-side wait
378
+ # timeouts all happen after the task was created and may be charged, so
379
+ # none of them may turn into another POST.
380
+ if not isinstance(error, _TaskFailedError):
353
381
  return False
354
-
355
- # Retry timeout and connection errors from result-query GETs.
356
- if isinstance(
357
- error,
358
- (
359
- requests.exceptions.Timeout,
360
- requests.exceptions.ConnectionError,
361
- TimeoutError,
362
- ),
363
- ):
364
- return True
365
-
366
- # Retry server errors (5xx) and rate limiting (429)
367
- if isinstance(error, RuntimeError):
368
- error_str = str(error)
369
- if "HTTP 5" in error_str or "HTTP 429" in error_str:
370
- return True
371
-
372
- return False
382
+ error_str = str(error)
383
+ return "HTTP 5" in error_str or "HTTP 429" in error_str
373
384
 
374
385
  def _format_sync_mode_error(self, data: dict[str, Any]) -> str:
375
386
  """Build an actionable error for a non-completed sync-mode response."""
@@ -464,8 +475,10 @@ class Client:
464
475
  @staticmethod
465
476
  def _task_id_from_error(error: Exception) -> str:
466
477
  """Extract a task ID from an error message, mirroring the JS SDK."""
467
- match = re.search(r"task_id:\s*([^)]+)", str(error))
468
- return match.group(1).strip() if match else "unknown"
478
+ match = re.search(r"task_id:\s*([^)]+)|for task ([^\s:]+)", str(error))
479
+ if not match:
480
+ return "unknown"
481
+ return (match.group(1) or match.group(2)).strip()
469
482
 
470
483
  def run_no_throw(
471
484
  self,
@@ -581,7 +594,9 @@ class Client:
581
594
 
582
595
  Args:
583
596
  file: File path string or file-like object to upload.
584
- timeout: Total API call timeout in seconds.
597
+ timeout: Per-request read timeout in seconds (default:
598
+ ``api_config.read_timeout_upload``;
599
+ ``api_config.read_timeout`` overrides). Not a total-transfer deadline.
585
600
 
586
601
  Returns:
587
602
  URL of the uploaded file.
@@ -604,8 +619,10 @@ class Client:
604
619
 
605
620
  ticket_url = f"{self.base_url}/api/v3/media/uploads"
606
621
  headers = self._get_headers()
607
- timeout = timeout or api_config.timeout
608
- request_timeout = (min(self.connection_timeout, timeout), timeout)
622
+ # Per-request read timeout (socket inactivity), not a transfer deadline.
623
+ request_timeout = self._request_timeouts(
624
+ timeout or self._default_read_timeout(api_config.read_timeout_upload)
625
+ )
609
626
 
610
627
  close_stream = False
611
628
  if isinstance(file, str):
@@ -0,0 +1,59 @@
1
+ """Configuration module for WaveSpeed SDK."""
2
+
3
+ import os
4
+ import sys
5
+ from typing import Optional
6
+
7
+ from ._config_module import install_config_module
8
+
9
+
10
+ _save_config_ignore = {
11
+ # workaround: "Can't pickle <function ...>"
12
+ }
13
+
14
+
15
+ class api:
16
+ """API client configuration options."""
17
+
18
+ # Authentication
19
+ api_key: Optional[str] = os.environ.get("WAVESPEED_API_KEY")
20
+
21
+ # API base URL
22
+ base_url: str = "https://api.wavespeed.ai"
23
+
24
+ # Connection timeout in seconds
25
+ connection_timeout: float = 10.0
26
+
27
+ # Overall wait cap in seconds for a task. None (default) means no cap: the
28
+ # SDK waits until the task reaches a terminal status. An explicit
29
+ # ``timeout=`` argument takes priority over this value.
30
+ timeout: Optional[float] = None
31
+
32
+ # Per-request read timeout in seconds (time waiting for the server, not a
33
+ # total-transfer deadline). Bounds a half-open connection to one request
34
+ # instead of the whole task. Defaults by request type:
35
+ # result queries and other read-only GETs
36
+ read_timeout_get: float = 30.0
37
+ # async submit POST (a timeout here is an ambiguous outcome, so more headroom)
38
+ read_timeout_submit: float = 60.0
39
+ # upload ticket POST and file PUT
40
+ read_timeout_upload: float = 120.0
41
+ # Sync-mode submit stays open until the task finishes: no default limit.
42
+
43
+ # Optional single override applied to all of the above (not sync-mode
44
+ # submit). None keeps the per-type defaults.
45
+ read_timeout: Optional[float] = None
46
+
47
+ # Maximum number of retries for the entire operation (task-level retries)
48
+ max_retries: int = 0
49
+
50
+ # Maximum retries for idempotent result-query GET requests. Submission
51
+ # POSTs are intentionally sent at most once.
52
+ max_connection_retries: int = 5
53
+
54
+ # Base interval between retries in seconds (actual delay = retry_interval * attempt)
55
+ retry_interval: float = 1.0
56
+
57
+
58
+ # adds patch, save_config, etc
59
+ install_config_module(sys.modules[__name__])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavespeed
3
- Version: 2.0.3
3
+ Version: 2.0.5
4
4
  Summary: WaveSpeed Python Client — Official Python SDK for the WaveSpeed inference platform
5
5
  Author-email: WaveSpeedAI <support@wavespeed.ai>
6
6
  License: MIT
@@ -94,12 +94,20 @@ output = client.run("wavespeed-ai/z-image/turbo", {"prompt": "Cat"})
94
94
  output = wavespeed.run(
95
95
  "wavespeed-ai/z-image/turbo",
96
96
  {"prompt": "Cat"},
97
- timeout=36000.0, # Max wait time in seconds (default: 36000.0)
97
+ timeout=None, # Max wait time in seconds (default: None = wait until the task finishes)
98
98
  poll_interval=1.0, # Status check interval (default: 1.0)
99
99
  enable_sync_mode=False, # Best-effort sync result attempt (default: False)
100
100
  )
101
101
  ```
102
102
 
103
+ ### Timeouts
104
+
105
+ `timeout` is the overall wait for a task and defaults to no limit. Each HTTP
106
+ request also has its own read timeout (time waiting for the server, not a
107
+ total-transfer deadline): 30s for result queries, 60s for async submit and
108
+ 120s for uploads. Sync mode has no default read timeout. Set
109
+ `wavespeed.config.api.read_timeout` to override all of these at once.
110
+
103
111
  ### Sync Mode
104
112
 
105
113
  Use `enable_sync_mode=True` to ask the API to wait for the result in the initial
@@ -34,4 +34,5 @@ src/wavespeed/api/client.py
34
34
  tests/__init__.py
35
35
  tests/conftest.py
36
36
  tests/test_api.py
37
- tests/test_config.py
37
+ tests/test_config.py
38
+ tests/test_timeouts.py
@@ -27,6 +27,7 @@
27
27
  "tests/__init__.py",
28
28
  "tests/conftest.py",
29
29
  "tests/test_api.py",
30
- "tests/test_config.py"
30
+ "tests/test_config.py",
31
+ "tests/test_timeouts.py"
31
32
  ]
32
33
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.0.5",
3
+ "distance": 0,
4
+ "node": "g7bcfc516a9110256323601831fd5f5bab8b923c1",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-17"
8
+ }
@@ -149,6 +149,98 @@ class TestClient(unittest.TestCase):
149
149
 
150
150
  mock_post.assert_called_once()
151
151
 
152
+ @staticmethod
153
+ def _response(status_code, payload=None, text=""):
154
+ response = MagicMock()
155
+ response.status_code = status_code
156
+ response.text = text
157
+ response.json.return_value = payload
158
+ return response
159
+
160
+ @patch("wavespeed.api.client.time.sleep")
161
+ @patch("wavespeed.api.client.requests.get")
162
+ @patch("wavespeed.api.client.requests.post")
163
+ def test_task_retries_do_not_repost_after_poll_retry_exhaustion(
164
+ self, mock_post, mock_get, _mock_sleep
165
+ ):
166
+ """A poll that exhausts its GET retries must not create a second task."""
167
+ mock_post.return_value = self._response(200, {"data": {"id": "req-1"}})
168
+ mock_get.return_value = self._response(502, text="Bad Gateway")
169
+
170
+ client = Client(api_key="test-key", max_retries=2, max_connection_retries=1)
171
+ with self.assertRaises(RuntimeError) as ctx:
172
+ client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"})
173
+
174
+ self.assertIn("req-1", str(ctx.exception))
175
+ self.assertIn("HTTP 502", str(ctx.exception))
176
+ self.assertEqual(mock_post.call_count, 1)
177
+ self.assertEqual(mock_get.call_count, 2)
178
+
179
+ result = client.run_no_throw("wavespeed-ai/z-image/turbo", {"prompt": "test"})
180
+ self.assertEqual(result["task_id"], "req-1")
181
+ self.assertEqual(mock_post.call_count, 2) # one POST per run, never per poll
182
+
183
+ @patch("wavespeed.api.client.time.sleep")
184
+ @patch("wavespeed.api.client.requests.get")
185
+ @patch("wavespeed.api.client.requests.post")
186
+ def test_task_retries_do_not_repost_after_wait_timeout(
187
+ self, mock_post, mock_get, _mock_sleep
188
+ ):
189
+ """A client-side wait timeout leaves the task alive and must not re-POST."""
190
+ mock_post.return_value = self._response(200, {"data": {"id": "req-1"}})
191
+ mock_get.return_value = self._response(200, {"data": {"status": "processing"}})
192
+
193
+ client = Client(api_key="test-key", max_retries=2)
194
+ with self.assertRaises(TimeoutError) as ctx:
195
+ client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"}, timeout=0)
196
+
197
+ self.assertIn("req-1", str(ctx.exception))
198
+ self.assertEqual(mock_post.call_count, 1)
199
+
200
+ @patch("wavespeed.api.client.time.sleep")
201
+ @patch("wavespeed.api.client.requests.get")
202
+ @patch("wavespeed.api.client.requests.post")
203
+ def test_task_retries_rerun_when_task_reports_failed(
204
+ self, mock_post, mock_get, _mock_sleep
205
+ ):
206
+ """A task that fails with a transient upstream error is re-run when max_retries > 0."""
207
+ mock_post.side_effect = [
208
+ self._response(200, {"data": {"id": "req-1"}}),
209
+ self._response(200, {"data": {"id": "req-2"}}),
210
+ ]
211
+ mock_get.side_effect = [
212
+ self._response(
213
+ 200, {"data": {"status": "failed", "error": "HTTP 500 upstream error"}}
214
+ ),
215
+ self._response(200, {"data": {"status": "completed", "outputs": ["url"]}}),
216
+ ]
217
+
218
+ client = Client(api_key="test-key", max_retries=1)
219
+ result = client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"})
220
+
221
+ self.assertEqual(result, {"outputs": ["url"]})
222
+ self.assertEqual(mock_post.call_count, 2)
223
+
224
+ @patch("wavespeed.api.client.time.sleep")
225
+ @patch("wavespeed.api.client.requests.get")
226
+ @patch("wavespeed.api.client.requests.post")
227
+ def test_task_retries_do_not_rerun_deterministic_failure(
228
+ self, mock_post, mock_get, _mock_sleep
229
+ ):
230
+ """A task that fails for a non-transient reason is not re-submitted."""
231
+ mock_post.side_effect = [self._response(200, {"data": {"id": "req-1"}})]
232
+ mock_get.side_effect = [
233
+ self._response(
234
+ 200, {"data": {"status": "failed", "error": "content policy violation"}}
235
+ ),
236
+ ]
237
+
238
+ client = Client(api_key="test-key", max_retries=2)
239
+ with self.assertRaises(RuntimeError):
240
+ client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"})
241
+
242
+ self.assertEqual(mock_post.call_count, 1)
243
+
152
244
  @patch("wavespeed.api.client.requests.get")
153
245
  def test_get_result_success(self, mock_get):
154
246
  """Test successful result retrieval."""
@@ -286,7 +378,11 @@ class TestModuleLevelRun(unittest.TestCase):
286
378
  mock_config.api_key = "config-key"
287
379
  mock_config.base_url = "https://api.wavespeed.ai"
288
380
  mock_config.connection_timeout = 10.0
289
- mock_config.timeout = 36000.0
381
+ mock_config.timeout = None
382
+ mock_config.read_timeout = None
383
+ mock_config.read_timeout_get = 30.0
384
+ mock_config.read_timeout_submit = 60.0
385
+ mock_config.read_timeout_upload = 120.0
290
386
  mock_config.max_retries = 0
291
387
  mock_config.max_connection_retries = 5
292
388
  mock_config.retry_interval = 1.0
@@ -470,7 +566,11 @@ class TestRunNoThrow(unittest.TestCase):
470
566
  mock_config.api_key = "config-key"
471
567
  mock_config.base_url = "https://api.wavespeed.ai"
472
568
  mock_config.connection_timeout = 10.0
473
- mock_config.timeout = 36000.0
569
+ mock_config.timeout = None
570
+ mock_config.read_timeout = None
571
+ mock_config.read_timeout_get = 30.0
572
+ mock_config.read_timeout_submit = 60.0
573
+ mock_config.read_timeout_upload = 120.0
474
574
  mock_config.max_retries = 0
475
575
  mock_config.max_connection_retries = 5
476
576
  mock_config.retry_interval = 1.0
@@ -26,7 +26,11 @@ class TestApiConfig(unittest.TestCase):
26
26
  """Test the shipped default values."""
27
27
  self.assertEqual(api.base_url, "https://api.wavespeed.ai")
28
28
  self.assertEqual(api.connection_timeout, 10.0)
29
- self.assertEqual(api.timeout, 36000.0)
29
+ self.assertIsNone(api.timeout)
30
+ self.assertIsNone(api.read_timeout)
31
+ self.assertEqual(api.read_timeout_get, 30.0)
32
+ self.assertEqual(api.read_timeout_submit, 60.0)
33
+ self.assertEqual(api.read_timeout_upload, 120.0)
30
34
  self.assertEqual(api.max_retries, 0)
31
35
  self.assertEqual(api.max_connection_retries, 5)
32
36
  self.assertEqual(api.retry_interval, 1.0)
@@ -0,0 +1,235 @@
1
+ """Tests for the overall-wait cap vs. per-request read timeout split."""
2
+
3
+ import http.server
4
+ import io
5
+ import threading
6
+ import time
7
+ import unittest
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import requests
11
+
12
+ from wavespeed.api import Client
13
+
14
+
15
+ def _resp(status=200, payload=None):
16
+ r = MagicMock()
17
+ r.status_code = status
18
+ r.text = ""
19
+ r.json.return_value = payload if payload is not None else {}
20
+ return r
21
+
22
+
23
+ def _submit_ok():
24
+ return _resp(200, {"data": {"id": "t1"}})
25
+
26
+
27
+ def _status(status, outputs=None):
28
+ return _resp(200, {"data": {"status": status, "outputs": outputs or []}})
29
+
30
+
31
+ class FakeClock:
32
+ """time.time()/sleep() replacement: sleep advances the clock instantly."""
33
+
34
+ def __init__(self):
35
+ """Start at t=0."""
36
+ self.now = 0.0
37
+
38
+ def time(self):
39
+ """Return the fake now."""
40
+ return self.now
41
+
42
+ def sleep(self, seconds):
43
+ """Advance the fake clock."""
44
+ self.now += seconds
45
+
46
+
47
+ @patch("wavespeed.api.client.time")
48
+ @patch("wavespeed.api.client.requests.get")
49
+ @patch("wavespeed.api.client.requests.post")
50
+ class TestTimeouts(unittest.TestCase):
51
+ """Overall cap and per-request read timeouts."""
52
+
53
+ def setUp(self):
54
+ """Create a client."""
55
+ self.client = Client(api_key="k")
56
+
57
+ def _clock(self, mock_time):
58
+ clock = FakeClock()
59
+ mock_time.time.side_effect = clock.time
60
+ mock_time.sleep.side_effect = clock.sleep
61
+ return clock
62
+
63
+ def test_default_has_no_overall_cap(self, post, get, mock_time):
64
+ """Test default has no overall cap."""
65
+ clock = self._clock(mock_time)
66
+ post.return_value = _submit_ok()
67
+ # Polls at 1000s intervals: stays processing past 36000s, then completes.
68
+ get.side_effect = [_status("processing")] * 40 + [_status("completed", ["u"])]
69
+ out = self.client.run("m/x", {}, poll_interval=1000.0)
70
+ self.assertEqual(out, {"outputs": ["u"]})
71
+ self.assertEqual(get.call_count, 41)
72
+ self.assertGreater(clock.now, 36000.0)
73
+
74
+ def test_explicit_timeout_still_raises(self, post, get, mock_time):
75
+ """Test explicit timeout still raises."""
76
+ self._clock(mock_time)
77
+ post.return_value = _submit_ok()
78
+ get.return_value = _status("processing")
79
+ with self.assertRaises(TimeoutError):
80
+ self.client.run("m/x", {}, timeout=50.0, poll_interval=10.0)
81
+
82
+ def test_config_timeout_still_caps(self, post, get, mock_time):
83
+ """Test config timeout still caps."""
84
+ self._clock(mock_time)
85
+ post.return_value = _submit_ok()
86
+ get.return_value = _status("processing")
87
+ with patch("wavespeed.api.client.api_config.timeout", 50.0):
88
+ with self.assertRaises(TimeoutError):
89
+ self.client.run("m/x", {}, poll_interval=10.0)
90
+
91
+ def test_async_submit_and_get_use_read_timeout(self, post, get, mock_time):
92
+ """Test async submit and get use read timeout."""
93
+ self._clock(mock_time)
94
+ post.return_value = _submit_ok()
95
+ get.return_value = _status("completed")
96
+ self.client.run("m/x", {})
97
+ self.assertEqual(post.call_args.kwargs["timeout"], (10.0, 60.0))
98
+ self.assertEqual(get.call_args.kwargs["timeout"], (10.0, 30.0))
99
+
100
+ def test_override_applies_to_get_and_submit_not_sync(self, post, get, mock_time):
101
+ """Single read_timeout override covers GET and async submit, not sync."""
102
+ self._clock(mock_time)
103
+ post.return_value = _submit_ok()
104
+ get.return_value = _status("completed")
105
+ with patch("wavespeed.api.client.api_config.read_timeout", 7.0):
106
+ self.client.run("m/x", {})
107
+ self.assertEqual(post.call_args.kwargs["timeout"], (7.0, 7.0))
108
+ self.assertEqual(get.call_args.kwargs["timeout"], (7.0, 7.0))
109
+ post.return_value = _resp(200, {"data": {"status": "completed"}})
110
+ self.client.run("m/x", {}, enable_sync_mode=True)
111
+ self.assertEqual(post.call_args.kwargs["timeout"], (10.0, None))
112
+
113
+ def test_sync_submit_no_read_timeout_by_default(self, post, get, mock_time):
114
+ """Test sync submit no read timeout by default."""
115
+ post.return_value = _resp(200, {"data": {"status": "completed"}})
116
+ self.client.run("m/x", {}, enable_sync_mode=True)
117
+ self.assertEqual(post.call_args.kwargs["timeout"], (10.0, None))
118
+
119
+ def test_sync_submit_explicit_timeout(self, post, get, mock_time):
120
+ """Test sync submit explicit timeout."""
121
+ post.return_value = _resp(200, {"data": {"status": "completed"}})
122
+ self.client.run("m/x", {}, enable_sync_mode=True, timeout=300.0)
123
+ self.assertEqual(post.call_args.kwargs["timeout"], (10.0, 300.0))
124
+
125
+ def test_explicit_timeout_applies_to_requests(self, post, get, mock_time):
126
+ """Test explicit timeout applies to requests."""
127
+ self._clock(mock_time)
128
+ post.return_value = _submit_ok()
129
+ get.return_value = _status("completed")
130
+ self.client.run("m/x", {}, timeout=300.0)
131
+ self.assertEqual(post.call_args.kwargs["timeout"], (10.0, 300.0))
132
+ self.assertEqual(get.call_args.kwargs["timeout"], (10.0, 300.0))
133
+
134
+ def test_poll_retries_502_then_succeeds(self, post, get, mock_time):
135
+ """Test poll retries 502 then succeeds."""
136
+ self._clock(mock_time)
137
+ post.return_value = _submit_ok()
138
+ get.side_effect = [_resp(502), _status("completed", ["u"])]
139
+ out = self.client.run("m/x", {})
140
+ self.assertEqual(out, {"outputs": ["u"]})
141
+ self.assertEqual(get.call_count, 2)
142
+
143
+ def test_submit_timeout_is_not_retried(self, post, get, mock_time):
144
+ """Test submit timeout is not retried."""
145
+ post.side_effect = requests.exceptions.ReadTimeout()
146
+ with self.assertRaises(RuntimeError):
147
+ self.client.run("m/x", {})
148
+ self.assertEqual(post.call_count, 1)
149
+ get.assert_not_called()
150
+
151
+
152
+ if __name__ == "__main__":
153
+ unittest.main()
154
+
155
+
156
+ def _ticket(url):
157
+ return _resp(
158
+ 200,
159
+ {
160
+ "code": 200,
161
+ "data": {
162
+ "download_url": "https://example.com/f.bin",
163
+ "upload": {"method": "PUT", "url": url, "headers": {}},
164
+ },
165
+ },
166
+ )
167
+
168
+
169
+ @patch("wavespeed.api.client.requests.put")
170
+ @patch("wavespeed.api.client.requests.post")
171
+ class TestUploadTimeouts(unittest.TestCase):
172
+ """upload() uses the per-request read timeout for every request."""
173
+
174
+ def test_default_read_timeout(self, post, put):
175
+ """Ticket POST and file PUT carry (connect, 120.0); override applies."""
176
+ post.return_value = _ticket("https://s.example.com/u")
177
+ put.return_value = _resp(200)
178
+ Client(api_key="k").upload(io.BytesIO(b"abc"))
179
+ self.assertEqual(post.call_args.kwargs["timeout"], (10.0, 120.0))
180
+ self.assertEqual(put.call_args.kwargs["timeout"], (10.0, 120.0))
181
+ with patch("wavespeed.api.client.api_config.read_timeout", 7.0):
182
+ Client(api_key="k").upload(io.BytesIO(b"abc"))
183
+ self.assertEqual(post.call_args.kwargs["timeout"], (7.0, 7.0))
184
+ self.assertEqual(put.call_args.kwargs["timeout"], (7.0, 7.0))
185
+
186
+ def test_explicit_timeout(self, post, put):
187
+ """An explicit timeout applies to both requests."""
188
+ post.return_value = _ticket("https://s.example.com/u")
189
+ put.return_value = _resp(200)
190
+ Client(api_key="k").upload(io.BytesIO(b"abc"), timeout=300.0)
191
+ self.assertEqual(post.call_args.kwargs["timeout"], (10.0, 300.0))
192
+ self.assertEqual(put.call_args.kwargs["timeout"], (10.0, 300.0))
193
+
194
+
195
+ class TestSlowProgressingPut(unittest.TestCase):
196
+ """A slow but progressing PUT is not aborted by the read timeout."""
197
+
198
+ def test_slow_progressing_put_not_aborted(self):
199
+ """Body takes longer than read_timeout in total, gaps stay below it."""
200
+ received = []
201
+
202
+ class Handler(http.server.BaseHTTPRequestHandler):
203
+ def do_PUT(self):
204
+ remaining = int(self.headers["Content-Length"])
205
+ got = 0
206
+ while remaining:
207
+ chunk = self.rfile.read(min(65536, remaining))
208
+ got += len(chunk)
209
+ remaining -= len(chunk)
210
+ time.sleep(0.01) # slow consumer, keeps making progress
211
+ received.append(got)
212
+ time.sleep(0.5) # response headers arrive late but < timeout
213
+ self.send_response(200)
214
+ self.send_header("Content-Length", "0")
215
+ self.end_headers()
216
+
217
+ def log_message(self, *args):
218
+ pass
219
+
220
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
221
+ threading.Thread(target=server.serve_forever, daemon=True).start()
222
+ url = f"http://127.0.0.1:{server.server_address[1]}/u"
223
+ size = 24 * 1024 * 1024
224
+ try:
225
+ with patch("wavespeed.api.client.requests.post") as post:
226
+ post.return_value = _ticket(url)
227
+ with patch("wavespeed.api.client.api_config.read_timeout_upload", 2.0):
228
+ start = time.time()
229
+ out = Client(api_key="k").upload(io.BytesIO(b"x" * size))
230
+ elapsed = time.time() - start
231
+ finally:
232
+ server.shutdown()
233
+ self.assertEqual(out, "https://example.com/f.bin")
234
+ self.assertEqual(received, [size])
235
+ self.assertGreater(elapsed, 2.0) # total transfer exceeded read_timeout
@@ -1,42 +0,0 @@
1
- """Configuration module for WaveSpeed SDK."""
2
-
3
- import os
4
- import sys
5
- from typing import Optional
6
-
7
- from ._config_module import install_config_module
8
-
9
-
10
- _save_config_ignore = {
11
- # workaround: "Can't pickle <function ...>"
12
- }
13
-
14
-
15
- class api:
16
- """API client configuration options."""
17
-
18
- # Authentication
19
- api_key: Optional[str] = os.environ.get("WAVESPEED_API_KEY")
20
-
21
- # API base URL
22
- base_url: str = "https://api.wavespeed.ai"
23
-
24
- # Connection timeout in seconds
25
- connection_timeout: float = 10.0
26
-
27
- # Total API call timeout in seconds
28
- timeout: float = 36000.0
29
-
30
- # Maximum number of retries for the entire operation (task-level retries)
31
- max_retries: int = 0
32
-
33
- # Maximum retries for idempotent result-query GET requests. Submission
34
- # POSTs are intentionally sent at most once.
35
- max_connection_retries: int = 5
36
-
37
- # Base interval between retries in seconds (actual delay = retry_interval * attempt)
38
- retry_interval: float = 1.0
39
-
40
-
41
- # adds patch, save_config, etc
42
- install_config_module(sys.modules[__name__])
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.0.3",
3
- "distance": 0,
4
- "node": "g3e926667ae3ef93fc11dc277b6f09b081750ad7d",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-09-21"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes