Fix review issues: lyrics polling, validation, undici fetch, retry, timeout, throttle, payload helper

This commit is contained in:
OpenCode
2026-07-14 16:17:51 +07:00
parent ac22e28128
commit 82087be1b3
14 changed files with 628 additions and 315 deletions
+69 -31
View File
@@ -1,6 +1,6 @@
"use strict";
/**
* 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.
@@ -15,12 +15,24 @@ function getProxyUrl() {
process.env.HTTP_PROXY ||
process.env.http_proxy);
}
function isRetryableError(error) {
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) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class SunoClient {
baseUrl = 'https://api.sunoapi.org/api/v1';
apiKey;
maxRequests = 20;
windowMs = 10_000;
proxyAgent;
requestTimeoutMs = 30_000;
maxRetries = 2;
requestTimestamps = [];
constructor(apiKey) {
this.apiKey = apiKey;
@@ -33,17 +45,17 @@ class SunoClient {
* Enforce rate limit: max 20 requests per 10 seconds.
*/
async throttle() {
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();
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);
}
this.requestTimestamps.push(now);
}
async request(method, path, body) {
await this.throttle();
@@ -52,27 +64,52 @@ class SunoClient {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
};
const fetchOptions = {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
};
if (this.proxyAgent) {
fetchOptions.dispatcher = this.proxyAgent;
let lastError;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
const fetchOptions = {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(this.requestTimeoutMs),
};
if (this.proxyAgent) {
fetchOptions.dispatcher = this.proxyAgent;
}
const response = await (0, undici_1.fetch)(url, fetchOptions);
const text = await response.text();
let json;
try {
json = JSON.parse(text);
}
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) {
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;
try {
json = JSON.parse(text);
}
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(path) {
return this.request('GET', path);
@@ -94,12 +131,13 @@ class SunoClient {
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 new Promise((resolve) => setTimeout(resolve, intervalMs));
await sleep(intervalMs);
}
}
throw new Error(`Polling timeout for task ${taskId} after ${maxAttempts} attempts`);