Rendley docs

Platform

Errors and retries

Errors reach you in two places. A rejected request fails on the HTTP response. Accepted work that goes wrong fails inside the job body, minutes later. The two have different shapes and need different handling.

The error envelope

A rejected request returns a non-2xx status and a body in this shape:

{
  "error": {
    "code": "NOT_ENOUGH_CREDITS",
    "message": "You don't have enough credits."
  }
}

Branch on code. It is stable. message is written for whoever reads the log and changes between releases. Do not parse it.

Validation failures add a fields array naming each offending field:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "project_id must be a valid uuid",
    "fields": [{ "field": "project_id", "message": "must be a valid uuid" }]
  }
}

HTTP status codes

CodeMeaningWhat to do
200Accepted. For generation, the body carries a job_id.Continue.
400The body is invalid, a file_hash matches no upload (MEDIA_NOT_FOUND), or the account is out of credits (NOT_ENOUGH_CREDITS).Read code. Fix the request, or top up and call the endpoint’s /cost twin first. See Credits and cost.
401The API key is missing, malformed or revoked.See Authentication.
402A monthly plan quota is exhausted: transcription minutes, text-to-speech characters or free music seconds.Upgrade the plan. Credits do not lift a plan quota.
403The key is valid but not allowed to touch this resource, or the account has no active subscription.Check the resource belongs to the key’s workspace.
404No such resource, or it is not yours.Verify the id. Deleted projects also return 404.
408The request timed out.Retry safe reads. Treat a timed-out write as ambiguous.
409The resource is in a state that conflicts with the request.Read the message, then re-read the resource.
413The request payload is too large.Split the request or use an upload endpoint instead of embedding file data.
429Too many requests.Back off and retry. See below.
500 502 503 504Something failed on the Rendley side or at a gateway.Retry safe reads with backoff. See the note below before retrying writes.

Failures inside a job

A 200 means accepted, not succeeded. The work runs in the background and can still end at failed several minutes later:

{
  "data": {
    "id": "c41d8f2b-7a63-4e09-bd15-8f3a29c7e604",
    "status": "failed",
    "error": "the provider rejected this prompt"
  }
}

Common causes:

CauseWhat it looks likeFix
Parameters the model does not acceptinvalid params for model ...Every model has its own schema. Check the picker on the capability page.
An input file could not be readfailed to fetch mediaConfirm the URL is public and the media_id exists.
Content policythe provider rejected this promptRephrase the prompt.
The source is too longinput exceeds maximum durationTrim the clip before sending it.

Check two more fields on agent jobs even when they succeed. commands_failed above zero means a partial edit. save_status of failed means the timeline did not persist; synced and unchanged are both fine.

Which errors to retry

Retry safe reads after 408, 429, 500, 502, 503, or 504. Do not retry 400, 401, 402, 403, 404, or 413 without changing the request or account state. A 409 can be retried only after you resolve the reported conflict.

NOT_ENOUGH_CREDITS and 402 are both stops, not backoffs. One clears when you top up credits, the other when you upgrade the plan. Alert on them rather than retrying.

Agent jobs carry a retryable boolean. When it is present, use it instead of the status code.

Be careful with requests that create jobs or spend credits. The API does not currently accept an idempotency key. If a connection drops after a POST reaches Rendley, retrying it can create a second job. Automatically retry GET requests. Retry a billable POST only when you received a clear rejection before the job was accepted.

// These statuses can be retried for safe reads. Writes need the
// additional idempotency caution explained above.
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);

const MAX_RETRIES = 4;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));


async function request(url, options = {}, attempt = 0) {
  const response = await fetch(url, options);

  if (response.ok) {
    return response.json();
  }

  if (RETRYABLE.has(response.status) && attempt < MAX_RETRIES) {
    // Honour Retry-After when the server sends one. Otherwise back off
    // exponentially, with jitter so a batch of workers that hit the same
    // 429 do not all retry on the same tick.
    const header = Number(response.headers.get("retry-after"));
    let wait = 2 ** attempt * 1000 + Math.random() * 250;

    if (header) {
      wait = header * 1000;
    }

    await sleep(wait);

    return request(url, options, attempt + 1);
  }

  // The body may not be JSON on a gateway error, so fall back to an
  // empty envelope rather than throwing over the throw.
  let error = {};

  try {
    const body = await response.json();
    error = body.error || {};
  } catch (parseError) {
    error = {};
  }

  throw new Error(response.status + " " + (error.code || "") + ": " + (error.message || ""));
}
import random
import time
import requests

# These statuses can be retried for safe reads. Writes need the
# additional idempotency caution explained above.
RETRYABLE = {408, 429, 500, 502, 503, 504}

MAX_ATTEMPTS = 5


def request(method, url, **kwargs):
    for attempt in range(MAX_ATTEMPTS):
        response = requests.request(method, url, **kwargs)

        if response.ok:
            return response.json()

        if response.status_code in RETRYABLE:
            # Honour Retry-After when the server sends one. Otherwise back
            # off exponentially, with jitter so a batch of workers that hit
            # the same 429 do not all retry on the same tick.
            header = response.headers.get("Retry-After")
            wait = 2**attempt + random.uniform(0, 0.25)

            if header:
                wait = float(header)

            time.sleep(wait)
            continue

        try:
            error = response.json().get("error", {})
        except ValueError:
            error = {}

        raise RuntimeError(
            f"{response.status_code} {error.get('code', '')}: {error.get('message', '')}"
        )

    raise RuntimeError("giving up after 5 attempts")

Use exponential backoff with jitter. Without jitter, a batch of workers that hit a 429 together will retry together and hit it again.

Rate limits

Limits are per account and depend on your plan. Crossing one returns a 429. The most common one is AGENT_CONCURRENCY_LIMIT_REACHED, raised when more automated edits are running than your plan allows.

Do not depend on a Retry-After header. Back off on your own schedule, and read it only if it happens to be there.

Two things keep you under the limit:

  • Poll with the long-poll endpoint where one exists. GET /v1/agent/jobs/{id} holds the request open rather than returning instantly. A loop over it makes fewer calls than a short timer. Still sleep a few seconds between calls.
  • Cap concurrency on bulk work. Rendering a thousand videos does not mean sending a thousand jobs at once. Keep the number in flight below your plan limit and start another when one finishes. Bulk video generation has a worked example.