Skip to content

Commit aa2ba7d

Browse files
committed
Prevent duplicate prediction submissions
1 parent 19c2efa commit aa2ba7d

5 files changed

Lines changed: 74 additions & 67 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ from wavespeed import Client
9393

9494
client = Client(
9595
api_key="your-api-key",
96-
max_retries=0, # Task-level retries (default: 0)
97-
max_connection_retries=5, # HTTP connection retries (default: 5)
96+
max_retries=0, # Replacement task attempts (default: 0)
97+
max_connection_retries=5, # Result-query GET retries; POST is never retried
9898
retry_interval=1.0, # Base delay between retries in seconds (default: 1.0)
9999
)
100100
```

src/wavespeed/api/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def run(
4747
"""Run a model and wait for the output.
4848
4949
Args:
50-
model: Model identifier (e.g., "wavespeed-ai/flux-dev").
50+
model: Model identifier (e.g., "wavespeed-ai/z-image/turbo").
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.
@@ -78,11 +78,12 @@ def run(
7878
enable_sync_mode=True
7979
)
8080
81-
# With retry
81+
# Keep replacement task attempts disabled unless your workload accepts
82+
# a new task after a confirmed terminal failure.
8283
output = wavespeed.run(
8384
"wavespeed-ai/z-image/turbo",
8485
{"prompt": "A cat"},
85-
max_retries=3
86+
max_retries=0
8687
)
8788
"""
8889
return _get_default_client().run(

src/wavespeed/api/client.py

Lines changed: 38 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
1414

1515

16+
class _SubmissionError(RuntimeError):
17+
"""A task submission failed and must not be retried automatically."""
18+
19+
1620
class Client:
1721
"""WaveSpeed API client.
1822
@@ -21,7 +25,7 @@ class Client:
2125
base_url: Base URL for the API. If not provided, uses wavespeed.config.api.base_url.
2226
connection_timeout: Timeout for HTTP requests in seconds.
2327
max_retries: Maximum number of retries for the entire operation.
24-
max_connection_retries: Maximum retries for individual HTTP requests.
28+
max_connection_retries: Maximum retries for result-query GET requests.
2529
retry_interval: Base interval between retries in seconds.
2630
2731
Example:
@@ -31,8 +35,9 @@ class Client:
3135
# With sync mode (best-effort single request, waits for result)
3236
output = client.run("wavespeed-ai/z-image/turbo", {"prompt": "Cat"}, enable_sync_mode=True)
3337
34-
# With retry
35-
output = client.run("wavespeed-ai/z-image/turbo", {"prompt": "Cat"}, max_retries=3)
38+
# Task-level replacement attempts are opt-in; submission POSTs are
39+
# always sent at most once.
40+
output = client.run("wavespeed-ai/z-image/turbo", {"prompt": "Cat"}, max_retries=1)
3641
"""
3742

3843
def __init__(
@@ -121,67 +126,34 @@ def _submit(
121126
)
122127
timeouts = (connect_timeout, request_timeout)
123128

124-
last_error: Exception | None = None
125-
126-
for retry in range(self.max_connection_retries + 1):
127-
try:
128-
response = requests.post(
129-
url, json=body, headers=self._get_headers(), timeout=timeouts
130-
)
131-
132-
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
151-
raise RuntimeError(
152-
f"Failed to submit prediction: HTTP {response.status_code}: "
153-
f"{response.text}"
154-
)
155-
156-
result = response.json()
129+
try:
130+
response = requests.post(
131+
url, json=body, headers=self._get_headers(), timeout=timeouts
132+
)
133+
except (
134+
requests.exceptions.ConnectionError,
135+
requests.exceptions.Timeout,
136+
) as e:
137+
raise _SubmissionError(
138+
"Prediction submission did not return a response. The task may already "
139+
"have been created, so the SDK will not retry the POST automatically."
140+
) from e
157141

158-
if enable_sync_mode:
159-
return None, result
142+
if response.status_code != 200:
143+
raise _SubmissionError(
144+
f"Failed to submit prediction: HTTP {response.status_code}: {response.text}"
145+
)
160146

161-
request_id = result.get("data", {}).get("id")
162-
if not request_id:
163-
raise RuntimeError(f"No request ID in response: {result}")
147+
result = response.json()
164148

165-
return request_id, None
149+
if enable_sync_mode:
150+
return None, result
166151

167-
except (
168-
requests.exceptions.ConnectionError,
169-
requests.exceptions.Timeout,
170-
) as e:
171-
last_error = e
172-
print(
173-
f"Connection error on attempt {retry + 1}/{self.max_connection_retries + 1}:"
174-
)
175-
traceback.print_exc()
152+
request_id = result.get("data", {}).get("id")
153+
if not request_id:
154+
raise _SubmissionError(f"No request ID in response: {result}")
176155

177-
if retry < self.max_connection_retries:
178-
delay = self.retry_interval * (retry + 1)
179-
print(f"Retrying in {delay} seconds...")
180-
time.sleep(delay)
181-
else:
182-
raise RuntimeError(
183-
f"Failed to submit prediction after {self.max_connection_retries + 1} attempts"
184-
) from e
156+
return request_id, None
185157

186158
def _get_result(
187159
self, request_id: str, timeout: float | None = None
@@ -315,7 +287,12 @@ def _is_retryable_error(self, error: Exception) -> bool:
315287
Returns:
316288
True if the error is retryable.
317289
"""
318-
# Always retry timeout and connection errors
290+
# Submission errors are ambiguous: the server may already have created
291+
# the task, so never turn them into another POST automatically.
292+
if isinstance(error, _SubmissionError):
293+
return False
294+
295+
# Retry timeout and connection errors from result-query GETs.
319296
if isinstance(
320297
error,
321298
(
@@ -366,7 +343,7 @@ def run(
366343
"""Run a model and wait for the output.
367344
368345
Args:
369-
model: Model identifier (e.g., "wavespeed-ai/flux-dev").
346+
model: Model identifier (e.g., "wavespeed-ai/z-image/turbo").
370347
input: Input parameters for the model.
371348
timeout: Maximum time to wait for completion (None = no timeout).
372349
poll_interval: Interval between status checks in seconds.

src/wavespeed/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ class api:
3131
# Maximum number of retries for the entire operation (task-level retries)
3232
max_retries: int = 0
3333

34-
# Maximum number of retries for individual HTTP requests (connection errors, timeouts)
34+
# Maximum retries for idempotent result-query GET requests. Submission
35+
# POSTs are intentionally sent at most once.
3536
max_connection_retries: int = 5
3637

3738
# Base interval between retries in seconds (actual delay = retry_interval * attempt)

tests/test_api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import unittest
77
from unittest.mock import MagicMock, patch
88

9+
import requests
910
import wavespeed
1011
from wavespeed.api import Client
1112

@@ -82,6 +83,33 @@ def test_submit_failure(self, mock_post):
8283
with self.assertRaises(RuntimeError) as ctx:
8384
client._submit("wavespeed-ai/z-image/turbo", {"prompt": "test"})
8485
self.assertIn("HTTP 500", str(ctx.exception))
86+
mock_post.assert_called_once()
87+
88+
@patch("wavespeed.api.client.requests.post")
89+
def test_submit_connection_error_is_not_retried(self, mock_post):
90+
"""An ambiguous disconnect must not create a duplicate task."""
91+
mock_post.side_effect = requests.exceptions.ConnectionError("disconnected")
92+
93+
client = Client(api_key="test-key", max_connection_retries=5)
94+
with self.assertRaises(RuntimeError) as ctx:
95+
client._submit("wavespeed-ai/z-image/turbo", {"prompt": "test"})
96+
97+
self.assertIn("will not retry the POST", str(ctx.exception))
98+
mock_post.assert_called_once()
99+
100+
@patch("wavespeed.api.client.requests.post")
101+
def test_task_retries_do_not_repeat_failed_submission(self, mock_post):
102+
"""Task retry configuration must not override POST safety."""
103+
mock_response = MagicMock()
104+
mock_response.status_code = 500
105+
mock_response.text = "Internal Server Error"
106+
mock_post.return_value = mock_response
107+
108+
client = Client(api_key="test-key", max_retries=3)
109+
with self.assertRaises(RuntimeError):
110+
client.run("wavespeed-ai/z-image/turbo", {"prompt": "test"})
111+
112+
mock_post.assert_called_once()
85113

86114
@patch("wavespeed.api.client.requests.get")
87115
def test_get_result_success(self, mock_get):

0 commit comments

Comments
 (0)