Fix review issues: lyrics polling, validation, undici fetch, retry, timeout, throttle, payload helper
This commit is contained in:
+84
-38
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Suno API HTTP client with rate limiting and polling helpers.
|
||||
* Suno API HTTP client with rate limiting, proxy support, retries and polling helpers.
|
||||
*
|
||||
* Rate limit: 20 requests per 10 seconds (sunoapi.org).
|
||||
* Proxy support via SUNO_HTTP_PROXY / HTTPS_PROXY / HTTP_PROXY env vars.
|
||||
*/
|
||||
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { fetch, ProxyAgent } from 'undici';
|
||||
|
||||
export interface SunoApiResponse<T = unknown> {
|
||||
code: number;
|
||||
@@ -33,13 +33,20 @@ export interface SunoTrack {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface SunoLyricsVariant {
|
||||
text?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface SunoTaskData {
|
||||
taskId: string;
|
||||
parentMusicId?: string;
|
||||
param?: string;
|
||||
response?: {
|
||||
taskId?: string;
|
||||
data?: SunoTrack[];
|
||||
data?: SunoTrack[] | SunoLyricsVariant[];
|
||||
sunoData?: SunoTrack[];
|
||||
};
|
||||
status: string;
|
||||
@@ -59,12 +66,26 @@ function getProxyUrl(): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
return msg.includes('timeout') || msg.includes('econnreset') || msg.includes('socket') || msg.includes('fetch failed');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export class SunoClient {
|
||||
private readonly baseUrl = 'https://api.sunoapi.org/api/v1';
|
||||
private readonly apiKey: string;
|
||||
private readonly maxRequests = 20;
|
||||
private readonly windowMs = 10_000;
|
||||
private readonly proxyAgent?: ProxyAgent;
|
||||
private readonly requestTimeoutMs = 30_000;
|
||||
private readonly maxRetries = 2;
|
||||
private requestTimestamps: number[] = [];
|
||||
|
||||
constructor(apiKey: string) {
|
||||
@@ -79,21 +100,19 @@ export class SunoClient {
|
||||
* Enforce rate limit: max 20 requests per 10 seconds.
|
||||
*/
|
||||
private async throttle(): Promise<void> {
|
||||
const now = Date.now();
|
||||
this.requestTimestamps = this.requestTimestamps.filter(
|
||||
(ts) => now - ts < this.windowMs
|
||||
);
|
||||
while (true) {
|
||||
const now = Date.now();
|
||||
this.requestTimestamps = this.requestTimestamps.filter((ts) => now - ts < this.windowMs);
|
||||
|
||||
if (this.requestTimestamps.length >= this.maxRequests) {
|
||||
const oldest = this.requestTimestamps[0];
|
||||
const wait = this.windowMs - (now - oldest);
|
||||
if (wait > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
return this.throttle();
|
||||
if (this.requestTimestamps.length < this.maxRequests) {
|
||||
this.requestTimestamps.push(now);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.requestTimestamps.push(now);
|
||||
const oldest = this.requestTimestamps[0];
|
||||
const wait = Math.max(0, this.windowMs - (now - oldest));
|
||||
await sleep(wait);
|
||||
}
|
||||
}
|
||||
|
||||
async request<T = unknown>(
|
||||
@@ -109,30 +128,56 @@ export class SunoClient {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit & { dispatcher?: any } = {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
if (this.proxyAgent) {
|
||||
fetchOptions.dispatcher = this.proxyAgent;
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
const fetchOptions: RequestInit & { dispatcher?: ProxyAgent } = {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
};
|
||||
if (this.proxyAgent) {
|
||||
fetchOptions.dispatcher = this.proxyAgent;
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const text = await response.text();
|
||||
|
||||
let json: SunoApiResponse<T>;
|
||||
try {
|
||||
json = JSON.parse(text) as SunoApiResponse<T>;
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON response from Suno API: ${text}`);
|
||||
}
|
||||
|
||||
// Retry on transient server errors / rate-limit before throwing.
|
||||
if (json.code === 429 || json.code === 503 || json.code === 504) {
|
||||
if (attempt < this.maxRetries) {
|
||||
const backoff = Math.min(1000 * 2 ** attempt, 8000);
|
||||
await sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (json.code !== 200) {
|
||||
throw new Error(`Suno API error ${json.code}: ${json.msg || 'unknown error'}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
} catch (error: unknown) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
const retryable = isRetryableError(lastError);
|
||||
if (retryable && attempt < this.maxRetries) {
|
||||
const backoff = Math.min(1000 * 2 ** attempt, 8000);
|
||||
await sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
const text = await response.text();
|
||||
let json: SunoApiResponse<T>;
|
||||
try {
|
||||
json = JSON.parse(text) as SunoApiResponse<T>;
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON response from Suno API: ${text}`);
|
||||
}
|
||||
|
||||
if (json.code !== 200) {
|
||||
throw new Error(`Suno API error ${json.code}: ${json.msg || 'unknown error'}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
throw lastError ?? new Error('Suno API request failed');
|
||||
}
|
||||
|
||||
get<T = unknown>(path: string): Promise<SunoApiResponse<T>> {
|
||||
@@ -167,6 +212,7 @@ export class SunoClient {
|
||||
status === 'FAILED' ||
|
||||
status === 'CREATE_TASK_FAILED' ||
|
||||
status === 'GENERATE_AUDIO_FAILED' ||
|
||||
status === 'GENERATE_LYRICS_FAILED' ||
|
||||
status === 'CALLBACK_EXCEPTION' ||
|
||||
status === 'SENSITIVE_WORD_ERROR'
|
||||
) {
|
||||
@@ -174,7 +220,7 @@ export class SunoClient {
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user