/** * 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 { fetch, ProxyAgent } from 'undici'; export interface SunoApiResponse { code: number; msg: string; data: T; } export interface SunoTrack { id: string; audio_url?: string; audioUrl?: string; source_audio_url?: string; stream_audio_url?: string; streamAudioUrl?: string; source_stream_audio_url?: string; image_url?: string; imageUrl?: string; source_image_url?: string; prompt?: string; model_name?: string; modelName?: string; title?: string; tags?: string; createTime?: string; 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[] | SunoLyricsVariant[]; sunoData?: SunoTrack[]; }; status: string; type?: string; operationType?: 'generate' | 'extend' | 'upload_cover' | 'upload_extend'; errorCode?: number | null; errorMessage?: string | null; } function getProxyUrl(): string | undefined { return ( process.env.SUNO_HTTP_PROXY || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy ); } 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 { 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) { this.apiKey = apiKey; const proxyUrl = getProxyUrl(); if (proxyUrl) { this.proxyAgent = new ProxyAgent(proxyUrl); } } /** * Enforce rate limit: max 20 requests per 10 seconds. */ private async throttle(): Promise { while (true) { const now = Date.now(); this.requestTimestamps = this.requestTimestamps.filter((ts) => now - ts < this.windowMs); if (this.requestTimestamps.length < this.maxRequests) { this.requestTimestamps.push(now); return; } const oldest = this.requestTimestamps[0]; const wait = Math.max(0, this.windowMs - (now - oldest)); await sleep(wait); } } async request( method: 'GET' | 'POST', path: string, body?: Record ): Promise> { await this.throttle(); const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`; const headers: Record = { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }; 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; try { json = JSON.parse(text) as SunoApiResponse; } 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; } } throw lastError ?? new Error('Suno API request failed'); } get(path: string): Promise> { return this.request('GET', path); } post(path: string, body: Record): Promise> { return this.request('POST', path, body); } /** * Poll task status until terminal state or timeout. */ async pollTaskStatus( taskId: string, options: { intervalMs?: number; maxAttempts?: number; statusPath?: string; } = {} ): Promise { const intervalMs = options.intervalMs ?? 15_000; const maxAttempts = options.maxAttempts ?? 60; const statusPath = options.statusPath ?? `/generate/record-info?taskId=${encodeURIComponent(taskId)}`; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const result = await this.get(statusPath); const status = result.data.status; if ( status === 'SUCCESS' || status === 'FAILED' || status === 'CREATE_TASK_FAILED' || status === 'GENERATE_AUDIO_FAILED' || status === 'GENERATE_LYRICS_FAILED' || status === 'CALLBACK_EXCEPTION' || status === 'SENSITIVE_WORD_ERROR' ) { return result.data; } if (attempt < maxAttempts) { await sleep(intervalMs); } } throw new Error(`Polling timeout for task ${taskId} after ${maxAttempts} attempts`); } }