Rendley docs

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.
409The resource is in a state that conflicts with the request.Read the message, then re-read the resource.
413The uploaded file is over the size limit.Compress it, or upload to storage and pass a url.
429Too many requests.Back off and retry. See below.
500 502 503Something failed on the Rendley side.Retry with backoff.

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 429, 500, 502 and 503. Do not retry 400, 401, 402, 403, 404 or 413. The same request will fail the same way.

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.

// Only these are worth sending again. Everything else fails the same
// way on a second attempt.
const RETRYABLE = new Set([429, 500, 502, 503]);

const MAX_ATTEMPTS = 5;

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_ATTEMPTS) {
    // 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

# Only these are worth sending again. Everything else fails the same
# way on a second attempt.
RETRYABLE = {429, 500, 502, 503}

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

        error = response.json().get("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 a thousand simultaneous requests. Keep ten to twenty in flight. Bulk video generation has a worked example.