Skip to content

Commit b2940bb

Browse files
authored
Expose sync timeout as queryable error
Expose sync timeout details through exceptions and no-throw responses, and bump package version to 0.2.4.
1 parent d299ed7 commit b2940bb

7 files changed

Lines changed: 156 additions & 23 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,17 @@ const output = await wavespeed.run(
6969
{
7070
timeout: 36000.0, // Max wait time in seconds (default: 36000.0)
7171
pollInterval: 1.0, // Status check interval (default: 1.0)
72-
enableSyncMode: false, // Single request mode, no polling (default: false)
72+
enableSyncMode: false, // Best-effort sync result attempt (default: false)
7373
}
7474
);
7575
```
7676

7777
### Sync Mode
7878

79-
Use `enableSyncMode: true` for a single request that waits for the result (no polling).
79+
Use `enableSyncMode: true` to ask the API to wait for the result in the initial
80+
request. If the server-side sync wait times out, the SDK raises
81+
`WavespeedSyncTimeoutException` with the task ID/result URL; the task continues
82+
processing and can be queried later.
8083

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

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "wavespeed",
3-
"version": "0.2.3",
3+
"version": "0.2.4",
44
"description": "WaveSpeed Client SDK for Wavespeed API",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/api/client.ts

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { api as apiConfig } from '../config';
1010
export interface RunOptions {
1111
timeout?: number; // Maximum time to wait for completion
1212
pollInterval?: number; // Interval between status checks in seconds
13-
enableSyncMode?: boolean; // If true, use synchronous mode (single request)
13+
enableSyncMode?: boolean; // If true, attempt synchronous mode in one request
1414
maxRetries?: number; // Maximum task-level retries (overrides client setting)
1515
}
1616

@@ -38,6 +38,24 @@ export class WavespeedTimeoutException extends WavespeedException {
3838
}
3939
}
4040

41+
/**
42+
* Sync-mode wait timed out, but the task is still processing asynchronously
43+
*/
44+
export class WavespeedSyncTimeoutException extends WavespeedException {
45+
constructor(
46+
taskId: string,
47+
model: string,
48+
errorMessage: string,
49+
public readonly resultUrl?: string
50+
) {
51+
const suffix = resultUrl && !errorMessage.includes(resultUrl)
52+
? ` Query the result later at: ${resultUrl}`
53+
: '';
54+
super(`Sync mode timed out (task_id: ${taskId}): ${errorMessage}${suffix}`, taskId, model);
55+
this.name = 'WavespeedSyncTimeoutException';
56+
}
57+
}
58+
4159
/**
4260
* Connection exception
4361
*/
@@ -73,10 +91,11 @@ export class WavespeedUnknownException extends WavespeedException {
7391
*/
7492
export interface RunDetail {
7593
taskId: string; // Task ID for tracking and debugging
76-
status: 'completed' | 'failed'; // Task status
94+
status: 'completed' | 'failed' | 'processing'; // Task status
7795
model: string; // Model identifier
7896
error?: WavespeedException; // Exception instance if failed
7997
createdAt?: string; // Task creation timestamp
98+
resultUrl?: string; // URL for querying the task result later
8099
}
81100

82101
/**
@@ -108,7 +127,7 @@ interface UploadFileResp {
108127
* const client = new Client("your-api-key");
109128
* const output = await client.run("wavespeed-ai/z-image/turbo", { prompt: "Cat" });
110129
*
111-
* // With sync mode (single request, waits for result)
130+
* // With sync mode (best-effort single request, waits for result)
112131
* const output2 = await client.run("wavespeed-ai/z-image/turbo", { prompt: "Cat" }, { enableSyncMode: true });
113132
*
114133
* // With retry
@@ -413,6 +432,9 @@ export class Client {
413432

414433
// Always retry timeout and connection errors
415434
const errorStr = error.toString().toLowerCase();
435+
if (errorStr.includes('sync mode timed out')) {
436+
return false;
437+
}
416438
if (errorStr.includes('timeout') || errorStr.includes('connection')) {
417439
return true;
418440
}
@@ -425,6 +447,35 @@ export class Client {
425447
return false;
426448
}
427449

450+
private _resultUrlFromData(data: Record<string, any>): string | undefined {
451+
const urls = data.urls;
452+
return urls && typeof urls === 'object' && typeof urls.get === 'string'
453+
? urls.get
454+
: undefined;
455+
}
456+
457+
private _isSyncTimeoutData(data: Record<string, any>): boolean {
458+
const error = data.error || '';
459+
return data.code === 5004 ||
460+
(data.status === 'processing' && typeof error === 'string' && error.includes('Sync mode timed out'));
461+
}
462+
463+
private _syncModeError(data: Record<string, any>, model: string): Error {
464+
const error = data.error || 'Unknown error';
465+
const taskId = data.id || 'unknown';
466+
467+
if (this._isSyncTimeoutData(data)) {
468+
return new WavespeedSyncTimeoutException(
469+
taskId,
470+
model,
471+
error,
472+
this._resultUrlFromData(data)
473+
);
474+
}
475+
476+
return new Error(`Prediction failed (task_id: ${taskId}): ${error}`);
477+
}
478+
428479
/**
429480
* Run a model and wait for the output.
430481
*
@@ -433,7 +484,7 @@ export class Client {
433484
* input: Input parameters for the model.
434485
* options.timeout: Maximum time to wait for completion (undefined = no timeout).
435486
* options.pollInterval: Interval between status checks in seconds.
436-
* options.enableSyncMode: If true, use synchronous mode (single request).
487+
* options.enableSyncMode: If true, use synchronous mode (best-effort single request).
437488
* options.maxRetries: Maximum task-level retries (overrides client setting).
438489
*
439490
* Returns:
@@ -469,9 +520,7 @@ export class Client {
469520
const data = syncResult?.data || {};
470521
const status = data.status;
471522
if (status !== 'completed') {
472-
const error = data.error || 'Unknown error';
473-
const requestId = data.id || 'unknown';
474-
throw new Error(`Prediction failed (task_id: ${requestId}): ${error}`);
523+
throw this._syncModeError(data, model);
475524
}
476525
return { outputs: data.outputs || [] };
477526
}
@@ -516,7 +565,7 @@ export class Client {
516565
* input: Input parameters for the model.
517566
* options.timeout: Maximum time to wait for completion (undefined = no timeout).
518567
* options.pollInterval: Interval between status checks in seconds.
519-
* options.enableSyncMode: If true, use synchronous mode (single request).
568+
* options.enableSyncMode: If true, use synchronous mode (best-effort single request).
520569
* options.maxRetries: Maximum task-level retries (overrides client setting).
521570
*
522571
* Returns:
@@ -562,14 +611,19 @@ export class Client {
562611

563612
if (status !== 'completed') {
564613
const errorMsg = data.error || 'Unknown error';
614+
const resultUrl = this._resultUrlFromData(data);
615+
const isSyncTimeout = this._isSyncTimeoutData(data);
565616
return {
566617
outputs: null,
567618
detail: {
568619
taskId,
569-
status: 'failed',
620+
status: isSyncTimeout ? 'processing' : 'failed',
570621
model,
571-
error: new WavespeedPredictionException(taskId, model, errorMsg),
572-
createdAt: data.created_at
622+
error: isSyncTimeout
623+
? new WavespeedSyncTimeoutException(taskId, model, errorMsg, resultUrl)
624+
: new WavespeedPredictionException(taskId, model, errorMsg),
625+
createdAt: data.created_at,
626+
resultUrl
573627
}
574628
};
575629
}
@@ -614,19 +668,28 @@ export class Client {
614668
// If not retryable or last attempt, return error result
615669
if (!isRetryable || attempt >= taskRetries) {
616670
// Try to extract taskId from error message
617-
const taskIdMatch = error.message?.match(/task_id: ([a-f0-9-]+)/);
671+
const taskIdMatch = error.message?.match(/task_id:\s*([^)]+)/);
618672
const taskId = taskIdMatch ? taskIdMatch[1] : 'unknown';
673+
const resultUrlMatch = error.message?.match(/Query the result later at:\s*(\S+)/);
674+
const resultUrl = resultUrlMatch ? resultUrlMatch[1] : undefined;
619675

620676
// Determine exception type based on error
621677
let exception: WavespeedException;
622678
const errorStr = error.toString().toLowerCase();
623679

624-
if (errorStr.includes('timeout') || errorStr.includes('timed out')) {
680+
if (errorStr.includes('sync mode timed out')) {
681+
exception = new WavespeedSyncTimeoutException(
682+
taskId,
683+
model,
684+
error.message?.replace(/Sync mode timed out \(task_id: [^)]+\):\s*/, '') || String(error),
685+
resultUrl
686+
);
687+
} else if (errorStr.includes('timeout') || errorStr.includes('timed out')) {
625688
exception = new WavespeedTimeoutException(taskId, model, timeout || 0);
626689
} else if (errorStr.includes('connection') || errorStr.includes('fetch') || error.name === 'AbortError' || error.name === 'TypeError') {
627690
exception = new WavespeedConnectionException(taskId, model, error.message || String(error));
628691
} else if (errorStr.includes('prediction failed')) {
629-
const errorMsg = error.message?.replace(/Prediction failed \(task_id: [a-f0-9-]+\): /, '') || 'Unknown error';
692+
const errorMsg = error.message?.replace(/Prediction failed \(task_id: [^)]+\): /, '') || 'Unknown error';
630693
exception = new WavespeedPredictionException(taskId, model, errorMsg);
631694
} else {
632695
exception = new WavespeedUnknownException(taskId, model, error);
@@ -636,9 +699,10 @@ export class Client {
636699
outputs: null,
637700
detail: {
638701
taskId,
639-
status: 'failed',
702+
status: errorStr.includes('sync mode timed out') ? 'processing' : 'failed',
640703
model,
641-
error: exception
704+
error: exception,
705+
resultUrl
642706
}
643707
};
644708
}

src/api/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { RunOptions, RunDetail, RunNoThrowResult } from './client';
2323
import {
2424
WavespeedException,
2525
WavespeedTimeoutException,
26+
WavespeedSyncTimeoutException,
2627
WavespeedConnectionException,
2728
WavespeedPredictionException,
2829
WavespeedUnknownException
@@ -33,6 +34,7 @@ export type { RunOptions, RunDetail, RunNoThrowResult };
3334
export {
3435
WavespeedException,
3536
WavespeedTimeoutException,
37+
WavespeedSyncTimeoutException,
3638
WavespeedConnectionException,
3739
WavespeedPredictionException,
3840
WavespeedUnknownException
@@ -59,7 +61,7 @@ function _getDefaultClient(): Client {
5961
* input: Input parameters for the model.
6062
* options.timeout: Maximum time to wait for completion (undefined = no timeout).
6163
* options.pollInterval: Interval between status checks in seconds.
62-
* options.enableSyncMode: If true, use synchronous mode (single request).
64+
* options.enableSyncMode: If true, use synchronous mode (best-effort single request).
6365
* options.maxRetries: Maximum retries for this request (overrides default setting).
6466
*
6567
* Returns:

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type { RunOptions, RunDetail, RunNoThrowResult } from './api/client';
2626
import {
2727
WavespeedException,
2828
WavespeedTimeoutException,
29+
WavespeedSyncTimeoutException,
2930
WavespeedConnectionException,
3031
WavespeedPredictionException,
3132
WavespeedUnknownException
@@ -36,6 +37,7 @@ export type { RunOptions, RunDetail, RunNoThrowResult };
3637
export {
3738
WavespeedException,
3839
WavespeedTimeoutException,
40+
WavespeedSyncTimeoutException,
3941
WavespeedConnectionException,
4042
WavespeedPredictionException,
4143
WavespeedUnknownException

tests/test_api.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import * as wavespeed from '../src/index';
6-
import { Client } from '../src/api/client';
6+
import { Client, WavespeedSyncTimeoutException } from '../src/api/client';
77
import { api as apiConfig } from '../src/config';
88

99
// Mock fetch globally
@@ -224,6 +224,68 @@ describe('Client', () => {
224224
).rejects.toThrow('Prediction failed (task_id: req-456): Model error');
225225
});
226226

227+
test('run sync mode timeout raises queryable error', async () => {
228+
const resultUrl = 'https://api.wavespeed.ai/api/v3/predictions/req-timeout/result';
229+
const mockResponse = {
230+
ok: true,
231+
status: 200,
232+
json: async () => ({
233+
data: {
234+
id: 'req-timeout',
235+
status: 'processing',
236+
code: 5004,
237+
error: 'Sync mode timed out after 90 seconds. The prediction is still processing asynchronously.',
238+
urls: { get: resultUrl }
239+
}
240+
}),
241+
};
242+
243+
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
244+
245+
const client = new Client('test-key');
246+
247+
await expect(
248+
client.run('wavespeed-ai/z-image/turbo', { prompt: 'test' }, { enableSyncMode: true, maxRetries: 1 })
249+
).rejects.toThrow(WavespeedSyncTimeoutException);
250+
await expect(
251+
client.run('wavespeed-ai/z-image/turbo', { prompt: 'test' }, { enableSyncMode: true })
252+
).rejects.toThrow(resultUrl);
253+
expect(global.fetch).toHaveBeenCalledTimes(2);
254+
});
255+
256+
test('runNoThrow sync mode timeout returns processing detail', async () => {
257+
const resultUrl = 'https://api.wavespeed.ai/api/v3/predictions/req-timeout/result';
258+
const mockResponse = {
259+
ok: true,
260+
status: 200,
261+
json: async () => ({
262+
data: {
263+
id: 'req-timeout',
264+
status: 'processing',
265+
code: 5004,
266+
error: 'Sync mode timed out after 90 seconds. The prediction is still processing asynchronously.',
267+
urls: { get: resultUrl }
268+
}
269+
}),
270+
};
271+
272+
(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
273+
274+
const client = new Client('test-key');
275+
const result = await client.runNoThrow(
276+
'wavespeed-ai/z-image/turbo',
277+
{ prompt: 'test' },
278+
{ enableSyncMode: true }
279+
);
280+
281+
expect(result.outputs).toBeNull();
282+
expect(result.detail.status).toBe('processing');
283+
expect(result.detail.taskId).toBe('req-timeout');
284+
expect(result.detail.resultUrl).toBe(resultUrl);
285+
expect(result.detail.error).toBeInstanceOf(WavespeedSyncTimeoutException);
286+
expect(global.fetch).toHaveBeenCalledTimes(1);
287+
});
288+
227289
test('_submit no request id', async () => {
228290
const mockResponse = {
229291
ok: true,

0 commit comments

Comments
 (0)