From fd4fd924ea7a75c536909022b61f8da4ebfd8140 Mon Sep 17 00:00:00 2001 From: chengzeyi Date: Wed, 17 Jun 2026 01:14:36 +0800 Subject: [PATCH 1/2] Expose sync timeout as queryable error --- README.md | 7 +++- src/api/client.ts | 96 +++++++++++++++++++++++++++++++++++++++-------- src/api/index.ts | 4 +- src/index.ts | 2 + tests/test_api.ts | 64 ++++++++++++++++++++++++++++++- 5 files changed, 153 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 63a0271..cbfddb2 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,17 @@ const output = await wavespeed.run( { timeout: 36000.0, // Max wait time in seconds (default: 36000.0) pollInterval: 1.0, // Status check interval (default: 1.0) - enableSyncMode: false, // Single request mode, no polling (default: false) + enableSyncMode: false, // Best-effort sync result attempt (default: false) } ); ``` ### Sync Mode -Use `enableSyncMode: true` for a single request that waits for the result (no polling). +Use `enableSyncMode: true` to ask the API to wait for the result in the initial +request. If the server-side sync wait times out, the SDK raises +`WavespeedSyncTimeoutException` with the task ID/result URL; the task continues +processing and can be queried later. > **Note:** Not all models support sync mode. Check the model documentation for availability. diff --git a/src/api/client.ts b/src/api/client.ts index 5c96c3c..fda8d6c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -10,7 +10,7 @@ import { api as apiConfig } from '../config'; export interface RunOptions { timeout?: number; // Maximum time to wait for completion pollInterval?: number; // Interval between status checks in seconds - enableSyncMode?: boolean; // If true, use synchronous mode (single request) + enableSyncMode?: boolean; // If true, attempt synchronous mode in one request maxRetries?: number; // Maximum task-level retries (overrides client setting) } @@ -38,6 +38,24 @@ export class WavespeedTimeoutException extends WavespeedException { } } +/** + * Sync-mode wait timed out, but the task is still processing asynchronously + */ +export class WavespeedSyncTimeoutException extends WavespeedException { + constructor( + taskId: string, + model: string, + errorMessage: string, + public readonly resultUrl?: string + ) { + const suffix = resultUrl && !errorMessage.includes(resultUrl) + ? ` Query the result later at: ${resultUrl}` + : ''; + super(`Sync mode timed out (task_id: ${taskId}): ${errorMessage}${suffix}`, taskId, model); + this.name = 'WavespeedSyncTimeoutException'; + } +} + /** * Connection exception */ @@ -73,10 +91,11 @@ export class WavespeedUnknownException extends WavespeedException { */ export interface RunDetail { taskId: string; // Task ID for tracking and debugging - status: 'completed' | 'failed'; // Task status + status: 'completed' | 'failed' | 'processing'; // Task status model: string; // Model identifier error?: WavespeedException; // Exception instance if failed createdAt?: string; // Task creation timestamp + resultUrl?: string; // URL for querying the task result later } /** @@ -108,7 +127,7 @@ interface UploadFileResp { * const client = new Client("your-api-key"); * const output = await client.run("wavespeed-ai/z-image/turbo", { prompt: "Cat" }); * - * // With sync mode (single request, waits for result) + * // With sync mode (best-effort single request, waits for result) * const output2 = await client.run("wavespeed-ai/z-image/turbo", { prompt: "Cat" }, { enableSyncMode: true }); * * // With retry @@ -413,6 +432,9 @@ export class Client { // Always retry timeout and connection errors const errorStr = error.toString().toLowerCase(); + if (errorStr.includes('sync mode timed out')) { + return false; + } if (errorStr.includes('timeout') || errorStr.includes('connection')) { return true; } @@ -425,6 +447,35 @@ export class Client { return false; } + private _resultUrlFromData(data: Record): string | undefined { + const urls = data.urls; + return urls && typeof urls === 'object' && typeof urls.get === 'string' + ? urls.get + : undefined; + } + + private _isSyncTimeoutData(data: Record): boolean { + const error = data.error || ''; + return data.code === 5004 || + (data.status === 'processing' && typeof error === 'string' && error.includes('Sync mode timed out')); + } + + private _syncModeError(data: Record, model: string): Error { + const error = data.error || 'Unknown error'; + const taskId = data.id || 'unknown'; + + if (this._isSyncTimeoutData(data)) { + return new WavespeedSyncTimeoutException( + taskId, + model, + error, + this._resultUrlFromData(data) + ); + } + + return new Error(`Prediction failed (task_id: ${taskId}): ${error}`); + } + /** * Run a model and wait for the output. * @@ -433,7 +484,7 @@ export class Client { * input: Input parameters for the model. * options.timeout: Maximum time to wait for completion (undefined = no timeout). * options.pollInterval: Interval between status checks in seconds. - * options.enableSyncMode: If true, use synchronous mode (single request). + * options.enableSyncMode: If true, use synchronous mode (best-effort single request). * options.maxRetries: Maximum task-level retries (overrides client setting). * * Returns: @@ -469,9 +520,7 @@ export class Client { const data = syncResult?.data || {}; const status = data.status; if (status !== 'completed') { - const error = data.error || 'Unknown error'; - const requestId = data.id || 'unknown'; - throw new Error(`Prediction failed (task_id: ${requestId}): ${error}`); + throw this._syncModeError(data, model); } return { outputs: data.outputs || [] }; } @@ -516,7 +565,7 @@ export class Client { * input: Input parameters for the model. * options.timeout: Maximum time to wait for completion (undefined = no timeout). * options.pollInterval: Interval between status checks in seconds. - * options.enableSyncMode: If true, use synchronous mode (single request). + * options.enableSyncMode: If true, use synchronous mode (best-effort single request). * options.maxRetries: Maximum task-level retries (overrides client setting). * * Returns: @@ -562,14 +611,19 @@ export class Client { if (status !== 'completed') { const errorMsg = data.error || 'Unknown error'; + const resultUrl = this._resultUrlFromData(data); + const isSyncTimeout = this._isSyncTimeoutData(data); return { outputs: null, detail: { taskId, - status: 'failed', + status: isSyncTimeout ? 'processing' : 'failed', model, - error: new WavespeedPredictionException(taskId, model, errorMsg), - createdAt: data.created_at + error: isSyncTimeout + ? new WavespeedSyncTimeoutException(taskId, model, errorMsg, resultUrl) + : new WavespeedPredictionException(taskId, model, errorMsg), + createdAt: data.created_at, + resultUrl } }; } @@ -614,19 +668,28 @@ export class Client { // If not retryable or last attempt, return error result if (!isRetryable || attempt >= taskRetries) { // Try to extract taskId from error message - const taskIdMatch = error.message?.match(/task_id: ([a-f0-9-]+)/); + const taskIdMatch = error.message?.match(/task_id:\s*([^)]+)/); const taskId = taskIdMatch ? taskIdMatch[1] : 'unknown'; + const resultUrlMatch = error.message?.match(/Query the result later at:\s*(\S+)/); + const resultUrl = resultUrlMatch ? resultUrlMatch[1] : undefined; // Determine exception type based on error let exception: WavespeedException; const errorStr = error.toString().toLowerCase(); - if (errorStr.includes('timeout') || errorStr.includes('timed out')) { + if (errorStr.includes('sync mode timed out')) { + exception = new WavespeedSyncTimeoutException( + taskId, + model, + error.message?.replace(/Sync mode timed out \(task_id: [^)]+\):\s*/, '') || String(error), + resultUrl + ); + } else if (errorStr.includes('timeout') || errorStr.includes('timed out')) { exception = new WavespeedTimeoutException(taskId, model, timeout || 0); } else if (errorStr.includes('connection') || errorStr.includes('fetch') || error.name === 'AbortError' || error.name === 'TypeError') { exception = new WavespeedConnectionException(taskId, model, error.message || String(error)); } else if (errorStr.includes('prediction failed')) { - const errorMsg = error.message?.replace(/Prediction failed \(task_id: [a-f0-9-]+\): /, '') || 'Unknown error'; + const errorMsg = error.message?.replace(/Prediction failed \(task_id: [^)]+\): /, '') || 'Unknown error'; exception = new WavespeedPredictionException(taskId, model, errorMsg); } else { exception = new WavespeedUnknownException(taskId, model, error); @@ -636,9 +699,10 @@ export class Client { outputs: null, detail: { taskId, - status: 'failed', + status: errorStr.includes('sync mode timed out') ? 'processing' : 'failed', model, - error: exception + error: exception, + resultUrl } }; } diff --git a/src/api/index.ts b/src/api/index.ts index a742046..5cead72 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -23,6 +23,7 @@ import type { RunOptions, RunDetail, RunNoThrowResult } from './client'; import { WavespeedException, WavespeedTimeoutException, + WavespeedSyncTimeoutException, WavespeedConnectionException, WavespeedPredictionException, WavespeedUnknownException @@ -33,6 +34,7 @@ export type { RunOptions, RunDetail, RunNoThrowResult }; export { WavespeedException, WavespeedTimeoutException, + WavespeedSyncTimeoutException, WavespeedConnectionException, WavespeedPredictionException, WavespeedUnknownException @@ -59,7 +61,7 @@ function _getDefaultClient(): Client { * input: Input parameters for the model. * options.timeout: Maximum time to wait for completion (undefined = no timeout). * options.pollInterval: Interval between status checks in seconds. - * options.enableSyncMode: If true, use synchronous mode (single request). + * options.enableSyncMode: If true, use synchronous mode (best-effort single request). * options.maxRetries: Maximum retries for this request (overrides default setting). * * Returns: diff --git a/src/index.ts b/src/index.ts index a4e9382..fcd4439 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import type { RunOptions, RunDetail, RunNoThrowResult } from './api/client'; import { WavespeedException, WavespeedTimeoutException, + WavespeedSyncTimeoutException, WavespeedConnectionException, WavespeedPredictionException, WavespeedUnknownException @@ -36,6 +37,7 @@ export type { RunOptions, RunDetail, RunNoThrowResult }; export { WavespeedException, WavespeedTimeoutException, + WavespeedSyncTimeoutException, WavespeedConnectionException, WavespeedPredictionException, WavespeedUnknownException diff --git a/tests/test_api.ts b/tests/test_api.ts index 72bb852..5b32786 100644 --- a/tests/test_api.ts +++ b/tests/test_api.ts @@ -3,7 +3,7 @@ */ import * as wavespeed from '../src/index'; -import { Client } from '../src/api/client'; +import { Client, WavespeedSyncTimeoutException } from '../src/api/client'; import { api as apiConfig } from '../src/config'; // Mock fetch globally @@ -224,6 +224,68 @@ describe('Client', () => { ).rejects.toThrow('Prediction failed (task_id: req-456): Model error'); }); + test('run sync mode timeout raises queryable error', async () => { + const resultUrl = 'https://api.wavespeed.ai/api/v3/predictions/req-timeout/result'; + const mockResponse = { + ok: true, + status: 200, + json: async () => ({ + data: { + id: 'req-timeout', + status: 'processing', + code: 5004, + error: 'Sync mode timed out after 90 seconds. The prediction is still processing asynchronously.', + urls: { get: resultUrl } + } + }), + }; + + (global.fetch as jest.Mock).mockResolvedValue(mockResponse); + + const client = new Client('test-key'); + + await expect( + client.run('wavespeed-ai/z-image/turbo', { prompt: 'test' }, { enableSyncMode: true, maxRetries: 1 }) + ).rejects.toThrow(WavespeedSyncTimeoutException); + await expect( + client.run('wavespeed-ai/z-image/turbo', { prompt: 'test' }, { enableSyncMode: true }) + ).rejects.toThrow(resultUrl); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + test('runNoThrow sync mode timeout returns processing detail', async () => { + const resultUrl = 'https://api.wavespeed.ai/api/v3/predictions/req-timeout/result'; + const mockResponse = { + ok: true, + status: 200, + json: async () => ({ + data: { + id: 'req-timeout', + status: 'processing', + code: 5004, + error: 'Sync mode timed out after 90 seconds. The prediction is still processing asynchronously.', + urls: { get: resultUrl } + } + }), + }; + + (global.fetch as jest.Mock).mockResolvedValue(mockResponse); + + const client = new Client('test-key'); + const result = await client.runNoThrow( + 'wavespeed-ai/z-image/turbo', + { prompt: 'test' }, + { enableSyncMode: true } + ); + + expect(result.outputs).toBeNull(); + expect(result.detail.status).toBe('processing'); + expect(result.detail.taskId).toBe('req-timeout'); + expect(result.detail.resultUrl).toBe(resultUrl); + expect(result.detail.error).toBeInstanceOf(WavespeedSyncTimeoutException); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + test('_submit no request id', async () => { const mockResponse = { ok: true, From a1ed49e7a566477a9aa3736735967396ce743599 Mon Sep 17 00:00:00 2001 From: chengzeyi Date: Wed, 17 Jun 2026 01:33:10 +0800 Subject: [PATCH 2/2] Bump version to 0.2.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5fdf17c..f7fa5e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wavespeed", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wavespeed", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.5", diff --git a/package.json b/package.json index a526abd..d064484 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wavespeed", - "version": "0.2.3", + "version": "0.2.4", "description": "WaveSpeed Client SDK for Wavespeed API", "main": "dist/index.js", "types": "dist/index.d.ts",