Tunova

Suno API error handling

Updated 2026-07-15

The short version: a robust Suno integration doesn’t just check HTTP status codes. It distinguishes request rejection, rate limiting, accepted-job uncertainty, generation failure, and webhook delivery failure. Each needs a different action: fix, back off, poll, start a new logical attempt, or recover the existing result. The one thing that changes how aggressive you can be is billing: when failed renders are refunded, safe recovery is cheap. This page gives you the taxonomy, a decision table, and a copy-paste wrapper.

The real failure modes (not just HTTP codes)

Generating a song is a long, async operation, so failures happen in more than one place. These five states matter, and they call for different handling:

  • Input rejected — your request is malformed (missing prompt, wrong field type, invalid callback URL). Retrying the same body will fail the same way. Fix the request.
  • Rate-limit — you sent too fast (429). Nothing is wrong with your request; honorRetry-After, back off, and retry the same logical submission with the same idempotency key.
  • Generation-transport uncertainty — the job was recorded, but the submit response from the generation transport was ambiguous. Tunova still returns the normal 202 withstatus: queued: poll that job instead of creating a duplicate. It may proceed normally; if it was truly undelivered, it normally becomes failed and refunds automatically within about 30–45 minutes.
  • Generation failed — the job was accepted, then the render failed downstream. On Tunova the job reaches status: failed and the tokens are refunded. A new generation is safe, but it is a new logical attempt and needs a new idempotency key.
  • Delivery failed — the song rendered but your webhook didn’t get the news (your endpoint was down, or the signature check failed on your side). The result still exists — poll for it.

A structured error taxonomy

Tunova returns structured errors with at least { code, detail }; use theX-Request-Id response header as the canonical trace id when contacting support. Here is the client-facing set and exactly what to do with each:

Code (HTTP)MeaningRetry?Billed?
VALIDATION_ERROR (400)Malformed request — bad or missing fieldNo — fix the bodyNo
UNAUTHORIZED (401)Invalid or missing API keyNo — fix the keyNo
INSUFFICIENT_TOKENS (402)Balance too low (body carries balance + cost)No — top up firstNo
CLIENT_INACTIVE (403)The client account is suspendedNo — contact the account operatorNo
RATE_LIMITED (429)Per-client rate limit (60 req/min across all keys, sliding window)Yes — back off per Retry-AfterNever
202, job status: queuedAccepted and waiting; this also covers uncertain generation-transport deliveryNo new submit — poll this jobHeld until success or failure
Job status: failedAccepted, then the render or delivery failedYes — start a new attempt with a NEW idempotency keyRefunded
404 on a jobUnknown or expired job id (jobs retained ~7 days)No

429 / rate limits: back off, and it never costs a credit

Tunova’s per-key limit is 60 requests/minute over a sliding 60-second window. You don’t have to hit it to find out where it is: RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset are returned on rate-checked generation responses (including successful accepts) when limiter state is available, so you can self-pace. RateLimit-Reset is the number of seconds until reset (0–60), not a Unix timestamp. Quota headers are omitted during a fail-open limiter outage and are not promised on validation/auth errors or job-status reads. If you do get a 429, honor Retry-After and retry. A rate-limited request never reserves a token, so retrying it costs nothing but time.

Timeouts on long generations: idempotency keys make retries safe

A render takes minutes, so submits are async: you get 202 { job_id } back immediately, then poll GET /api/jobs/{id} or receive a webhook. The risk on a retried submit is a duplicate render (and a double charge) if your first request actually went through and you just didn’t see the response. Send an Idempotency-Key header and that risk disappears — a retry with the same key returns the same job:

# Same Idempotency-Key on every retry of ONE logical request → same job, no double charge. curl -X POST https://api.tunova.ai/api/generate \ -H "X-API-Key: sk_live_..." \ -H "Idempotency-Key: gen-2f9c1e7a" \ -H "content-type: application/json" \ -d '{"prompt": "calm rainy-night lofi"}' # -> 202 {"job_id": "8f3a...", "status": "queued"} (a retry returns the same job_id)

The key identifies the logical job, not merely one HTTP request. While that job is retained (normally about 7 days), the key keeps returning it even after complete or failed. Reuse it while recovering from a lost or timed-out submit response; if a terminal failed job should be generated again, create a new key for that new logical attempt. The key is bound to the accepted prompt, endpoint, model, options, and callback URL: changing any of them while reusing the key returns 409 IDEMPOTENCY_CONFLICT, never an unrelated old job. After retention expires, the old key may create a new job.

Why an accepted job can remain queued

A transport timeout does not prove that the generation message was rejected: it may have been accepted just before the connection failed. Refunding immediately would risk giving away a generation that is already running. Tunova therefore returns the normal 202, leaves the job queued, and lets its real outcome decide billing. Keep polling the supplied status_url. The job may advance normally; if the message was truly lost, the stale-job backstop normally marks it failed and refunds the hold in about 30–45 minutes.

Retry decision table

The important distinction is whether you are recovering one logical submission or intentionally starting a new generation:

  • Retry the same logical submit (same key): request-level network failures and429, with backoff and Retry-After where present.
  • Start a new logical attempt (new key): a job that has reachedfailed. The failed job was refunded; its old key continues to replay it.
  • Don’t retry — fix it: 400 (fix the body), 401 (fix the key), 402 (top up), 409 (the idempotency key belongs to different work; use a new key). Retrying these just wastes calls.

Debug one failed generation with request-ids

Every response carries an X-Request-Id header; that header is the canonical trace id even when an error body contains only code and detail. Log it next to your own job id. When something goes wrong, you have a single handle that ties your call to what happened on the server — support becomes “here’s request req_…,” not a reproduction hunt.

Why billed-on-success changes your retry math

On a pay-per-attempt API, every retry is a gamble with real money, so you retry timidly — and users see more failures. Tunova bills only on a successful, delivered song: a failed render is auto-refunded, and 429 never reserves tokens. An accepted queued job keeps its hold until its real outcome is known; transport uncertainty is not treated as proof of failure. So the correct retry policy is to reuse one key while recovering the submission response, poll the accepted job, and use a new key only after a terminal failure when you intentionally want a new generation.

Worked example. Say 1 in 20 attempts fails transiently. On a bill-per-attempt provider at $0.05/attempt, 1,000 delivered songs cost you ~1,053 attempts = ~$52.65 — you pay for the 53 failures. On billed-on-success, the 53 failures are refunded, so 1,000 delivered songs cost exactly 1,000 — and your retry loop can be as patient as it needs to be. (See the effective-cost framework for the general formula.)

Copy-paste retry wrapper

A minimal, dependency-light submit that classifies the error, honors Retry-After, and reuses one idempotency key so a retry can never double-charge. Python first, then Node.

import time, uuid, requests API = "https://api.tunova.ai" KEY = "sk_live_..." TERMINAL = {"VALIDATION_ERROR", "UNAUTHORIZED", "INSUFFICIENT_TOKENS", "CLIENT_INACTIVE"} # don't retry — fix it def submit(prompt, tries=5): idem = "gen-" + uuid.uuid4().hex # one key for the whole retry loop for attempt in range(tries): try: r = requests.post(f"{API}/api/generate", headers={"X-API-Key": KEY, "Idempotency-Key": idem, "content-type": "application/json"}, json={"prompt": prompt}, timeout=20) except requests.RequestException: time.sleep(2 ** attempt) # unknown response: retry SAME logical submit continue try: body = r.json() except ValueError: body = {} if r.status_code == 202 and body.get("job_id"): return body["job_id"] rid = r.headers.get("X-Request-Id") if body.get("code") in TERMINAL or r.status_code in (400, 401, 402, 403): raise RuntimeError(f"{body.get('code')}: {body.get('detail')} (request {rid})") # Retry a request-level transient with the SAME key; 429 never reserves tokens. try: wait = int(r.headers.get("Retry-After", 2 ** attempt)) except ValueError: wait = 2 ** attempt time.sleep(wait) raise RuntimeError("still unavailable after retries")
const API = "https://api.tunova.ai", KEY = "sk_live_..."; const TERMINAL = new Set(["VALIDATION_ERROR", "UNAUTHORIZED", "INSUFFICIENT_TOKENS", "CLIENT_INACTIVE"]); async function submit(prompt, tries = 5) { const idem = "gen-" + crypto.randomUUID(); // one key for the whole retry loop for (let attempt = 0; attempt < tries; attempt++) { let r; try { r = await fetch(`${API}/api/generate`, { method: "POST", headers: { "X-API-Key": KEY, "Idempotency-Key": idem, "content-type": "application/json" }, body: JSON.stringify({ prompt }), }); } catch { await new Promise((res) => setTimeout(res, (2 ** attempt) * 1000)); continue; // unknown response: retry SAME logical submit } const body = await r.json().catch(() => ({})); if (r.status === 202 && body.job_id) return body.job_id; const rid = r.headers.get("X-Request-Id"); if (TERMINAL.has(body.code) || [400, 401, 402, 403].includes(r.status)) throw new Error(`${body.code}: ${body.detail} (request ${rid})`); // Retry a request-level transient with the SAME key; 429 never reserves tokens. const retryAfter = r.headers.get("Retry-After"); const parsed = retryAfter === null ? Number.NaN : Number(retryAfter); const wait = Number.isFinite(parsed) && parsed >= 0 ? parsed : 2 ** attempt; await new Promise((res) => setTimeout(res, wait * 1000)); } throw new Error("still unavailable after retries"); }

FAQ

Does a Suno API charge me for failed generations?

It depends on the provider — many do. With Tunova, no: billing is on success only. A render that reaches failed is auto-refunded, while a rate-limit (429) response never reserves a token. If generation-transport delivery is uncertain, the accepted job remains queued rather than being prematurely refunded: poll it, and if it was truly undelivered it normally fails and refunds automatically within about 30–45 minutes.

How do I handle 429 rate-limit errors from a Suno API?

Back off and retry, honoring the Retry-After header. When limiter state is available, Tunova returns RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset on rate-checked generation responses (successful accepts and 429s) so you can self-pace — and a 429 never costs a token.

Are retries safe, or will I get double-charged?

Send an Idempotency-Key header on the submit. A retried request with the same key and generation parameters returns the SAME job instead of starting a second render — no duplicate song, no double charge. Reuse the key for the whole HTTP retry loop of one logical request. If the prompt, mode, model, options, or callback URL changes, Tunova returns 409 IDEMPOTENCY_CONFLICT; use a new key. While the job is retained (normally about 7 days), a terminal failed job still replays, so use a NEW key for a new attempt; after retention expires, the old key may create a new job.

How do I debug a single failed generation?

Every response carries an X-Request-Id header. Treat that header as the canonical trace id: log it and quote it in a support request — the answer is 'here's my request id,' not 'it didn't work.'

Which Suno API errors should I not retry?

Input/validation (400), authentication (401), insufficient balance (402), and suspended client (403 CLIENT_INACTIVE) are terminal — retrying the same request won't help; fix the request, key, balance, or account status. Back off after a rate-limit (429). If an accepted job reaches failed, starting another generation is safe and free, but it is a new logical attempt and therefore needs a NEW Idempotency-Key.

Related

Tunova is an independent service, not affiliated with or endorsed by Suno. “Suno” is a trademark of its owner.