230 lines
6.3 KiB
TypeScript
230 lines
6.3 KiB
TypeScript
/**
|
|
* 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<T = unknown> {
|
|
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<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) {
|
|
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<void> {
|
|
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<T = unknown>(
|
|
method: 'GET' | 'POST',
|
|
path: string,
|
|
body?: Record<string, unknown>
|
|
): Promise<SunoApiResponse<T>> {
|
|
await this.throttle();
|
|
|
|
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
|
|
const headers: Record<string, string> = {
|
|
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<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;
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error('Suno API request failed');
|
|
}
|
|
|
|
get<T = unknown>(path: string): Promise<SunoApiResponse<T>> {
|
|
return this.request<T>('GET', path);
|
|
}
|
|
|
|
post<T = unknown>(path: string, body: Record<string, unknown>): Promise<SunoApiResponse<T>> {
|
|
return this.request<T>('POST', path, body);
|
|
}
|
|
|
|
/**
|
|
* Poll task status until terminal state or timeout.
|
|
*/
|
|
async pollTaskStatus(
|
|
taskId: string,
|
|
options: {
|
|
intervalMs?: number;
|
|
maxAttempts?: number;
|
|
statusPath?: string;
|
|
} = {}
|
|
): Promise<SunoTaskData> {
|
|
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<SunoTaskData>(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`);
|
|
}
|
|
}
|