wavespeed 2.0.3__tar.gz → 2.0.4__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 (39) hide show
  1. {wavespeed-2.0.3 → wavespeed-2.0.4}/PKG-INFO +1 -1
  2. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed/_version.py +1 -1
  3. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed/api/client.py +21 -28
  4. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed.egg-info/PKG-INFO +1 -1
  5. wavespeed-2.0.4/src/wavespeed.egg-info/scm_version.json +8 -0
  6. {wavespeed-2.0.3 → wavespeed-2.0.4}/tests/test_api.py +92 -0
  7. wavespeed-2.0.3/src/wavespeed.egg-info/scm_version.json +0 -8
  8. {wavespeed-2.0.3 → wavespeed-2.0.4}/.github/dependabot.yml +0 -0
  9. {wavespeed-2.0.3 → wavespeed-2.0.4}/.github/workflows/claude.yml +0 -0
  10. {wavespeed-2.0.3 → wavespeed-2.0.4}/.github/workflows/pre-commit.yml +0 -0
  11. {wavespeed-2.0.3 → wavespeed-2.0.4}/.github/workflows/python-packages.yml +0 -0
  12. {wavespeed-2.0.3 → wavespeed-2.0.4}/.github/workflows/python-publish.yml +0 -0
  13. {wavespeed-2.0.3 → wavespeed-2.0.4}/.github/workflows/tests.yml +0 -0
  14. {wavespeed-2.0.3 → wavespeed-2.0.4}/.gitignore +0 -0
  15. {wavespeed-2.0.3 → wavespeed-2.0.4}/.pre-commit-config.yaml +0 -0
  16. {wavespeed-2.0.3 → wavespeed-2.0.4}/CHANGELOG.md +0 -0
  17. {wavespeed-2.0.3 → wavespeed-2.0.4}/CLAUDE.md +0 -0
  18. {wavespeed-2.0.3 → wavespeed-2.0.4}/LICENSE +0 -0
  19. {wavespeed-2.0.3 → wavespeed-2.0.4}/MANIFEST.in +0 -0
  20. {wavespeed-2.0.3 → wavespeed-2.0.4}/README.md +0 -0
  21. {wavespeed-2.0.3 → wavespeed-2.0.4}/VERSIONING.md +0 -0
  22. {wavespeed-2.0.3 → wavespeed-2.0.4}/assets/wavespeed-logo-dark.svg +0 -0
  23. {wavespeed-2.0.3 → wavespeed-2.0.4}/assets/wavespeed-logo-light.svg +0 -0
  24. {wavespeed-2.0.3 → wavespeed-2.0.4}/pyproject.toml +0 -0
  25. {wavespeed-2.0.3 → wavespeed-2.0.4}/pytest.ini +0 -0
  26. {wavespeed-2.0.3 → wavespeed-2.0.4}/setup.cfg +0 -0
  27. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed/__init__.py +0 -0
  28. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed/_config_module.py +0 -0
  29. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed/api/__init__.py +0 -0
  30. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed/config.py +0 -0
  31. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed.egg-info/SOURCES.txt +0 -0
  32. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed.egg-info/dependency_links.txt +0 -0
  33. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed.egg-info/not-zip-safe +0 -0
  34. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed.egg-info/requires.txt +0 -0
  35. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed.egg-info/scm_file_list.json +0 -0
  36. {wavespeed-2.0.3 → wavespeed-2.0.4}/src/wavespeed.egg-info/top_level.txt +0 -0
  37. {wavespeed-2.0.3 → wavespeed-2.0.4}/tests/__init__.py +0 -0
  38. {wavespeed-2.0.3 → wavespeed-2.0.4}/tests/conftest.py +0 -0
  39. {wavespeed-2.0.3 → wavespeed-2.0.4}/tests/test_config.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.4
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
@@ -1,2 +1,2 @@
1
1
  # This file is automatically generated by setuptools_scm
2
- __version__ = "2.0.3"
2
+ __version__ = "2.0.4"
@@ -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
 
@@ -332,14 +336,13 @@ class Client:
332
336
 
333
337
  if status in ("failed", "cancelled", "timeout", "deleted"):
334
338
  error = data.get("error") or "Unknown error"
335
- raise RuntimeError(
336
- f"Prediction {status} (task_id: {request_id}): {error}"
337
- )
339
+ error_cls = _TaskFailedError if status == "failed" else RuntimeError
340
+ raise error_cls(f"Prediction {status} (task_id: {request_id}): {error}")
338
341
 
339
342
  time.sleep(poll_interval)
340
343
 
341
344
  def _is_retryable_error(self, error: Exception) -> bool:
342
- """Determine if an error is worth retrying at the task level.
345
+ """Determine if an error is worth re-running the task for.
343
346
 
344
347
  Args:
345
348
  error: The exception to check.
@@ -347,29 +350,17 @@ class Client:
347
350
  Returns:
348
351
  True if the error is retryable.
349
352
  """
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):
353
+ # Only a failure reported by the task itself is re-run, and only when it
354
+ # was a transient upstream error (HTTP 5xx / 429), as before: a
355
+ # deterministic failure such as a content-policy rejection would fail
356
+ # again. Submission errors are ambiguous (the task may already exist),
357
+ # and poll-side HTTP errors, poll retry exhaustion and client-side wait
358
+ # timeouts all happen after the task was created and may be charged, so
359
+ # none of them may turn into another POST.
360
+ if not isinstance(error, _TaskFailedError):
353
361
  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
362
+ error_str = str(error)
363
+ return "HTTP 5" in error_str or "HTTP 429" in error_str
373
364
 
374
365
  def _format_sync_mode_error(self, data: dict[str, Any]) -> str:
375
366
  """Build an actionable error for a non-completed sync-mode response."""
@@ -464,8 +455,10 @@ class Client:
464
455
  @staticmethod
465
456
  def _task_id_from_error(error: Exception) -> str:
466
457
  """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"
458
+ match = re.search(r"task_id:\s*([^)]+)|for task ([^\s:]+)", str(error))
459
+ if not match:
460
+ return "unknown"
461
+ return (match.group(1) or match.group(2)).strip()
469
462
 
470
463
  def run_no_throw(
471
464
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavespeed
3
- Version: 2.0.3
3
+ Version: 2.0.4
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
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.0.4",
3
+ "distance": 0,
4
+ "node": "g1bf08ac65f5019f75a190187231450423b203238",
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."""
@@ -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
File without changes