Disclosure: I work on Revana, a router for image and video models. The code below targets its API (spec: https://revana.si/openapi.json), but the failure modes and the fixes apply to any generation provider.
A demo calls a video model once and shows the result. A production pipeline calls it thousands of times, and then you see what the demo hid. Generation endpoints fail far more often than ordinary APIs, in more ways, and every failure mode has a cost:
-
Capacity: the provider is at capacity (
429/503) for minutes at a time, usually right when you launch a campaign. - Slow jobs: a 10-second 1080p clip can take anywhere from 40 seconds to 15 minutes, so your HTTP client times out long before the job does.
- Refusals: the model refuses the prompt (content policy) or returns text instead of a video.
- Network: a connection drops after you've submitted, and you don't know whether the job exists.
- Billing: some providers charge for failed or timed-out jobs.
Any one of these is easy to handle. The trouble is that a naive fix for one usually makes another worse. Retrying blindly turns a dropped connection into two paid videos, and falling back to another model turns a $1 clip into a $4 one. This post builds a small pipeline that handles all five, in about 120 lines of Python.
The request shape
Every model goes through the same endpoint:
POST https://api.revana.si/v1/generations
Authorization: Bearer sr_live_...
Idempotency-Key: campaign-42/shot-07/seedance-2.5/a0
Content-Type: application/json
{
"model": "seedance-2.5",
"input": {
"prompt": "close-up of hands pouring cold brew over ice, morning light",
"resolution": "720p", "duration": 8, "aspect_ratio": "9:16", "audio": true,
"image_url": "https://example.com/start-frame.png"
},
"max_cost_usd": 2.00
}
The response is 202 with a generation object (id, status, quoted_usd, reserved_usd, cost_usd, error). You poll GET /v1/generations/{id} until status is succeeded or failed, then download GET /v1/generations/{id}/content.
Two fields do most of the safety work:
-
max_cost_usdis a hard ceiling per generation. If the only available route costs more, the request fails withmax_cost_exceededand nothing is charged. -
Idempotency-Key: sending the same key with the same body returns the original generation (200) instead of starting a new one. The same key with a different body is409 idempotency_conflict.
Layer 1: routing inside one model
The same model is often sold by several upstream providers. Seedance 2.5, for example, is available from ByteDance directly and from a handful of resellers. A router can try the cheapest healthy route first and move to the next one if it's down, without the caller ever seeing the failure.
Revana does this server-side, and the generation object tells you what it may cost you:
-
quoted_usdis the price on the first route tried. -
reserved_usdis the most it can cost: the dearest backup route allowed under yourmax_cost_usd. That amount is held from your balance while the job runs. -
cost_usdis what you were actually charged: the price of the route that delivered,0if it failed,nullwhile it's running.
If you want "this model, this price, or nothing", set max_cost_usd equal to the quote and dearer backups are skipped. If you'd rather get the clip, leave headroom. To check the quote before submitting, read the live price list (no key needed):
import requests
MODELS = {m["id"]: m for m in requests.get("https://api.revana.si/v1/models", timeout=30).json()["data"]}
def quote(model: str, inp: dict) -> float:
"""Price of one generation from the live list: per_second x duration, or per_image."""
price = MODELS[model]["price"]
usd = price["usd"]
if isinstance(usd, dict):
res = inp.get("resolution")
key = f"{res}+audio" if inp.get("audio") and f"{res}+audio" in usd else res
usd = usd[key]
return round(usd * inp.get("duration", 1), 4) if price["unit"] == "per_second" else usd
As of 2026-11-05, quote("seedance-2.5", {"resolution": "720p", "duration": 8}) returns 1.68 ($0.21/s x 8 s).
There's also max_wait_s. Routes expected to take longer than that are skipped, and once that time has passed, no new route is tried. That's useful for interactive features where a clip that arrives in 20 minutes is worthless.
Layer 2: fallback across models
Route-level fallback keeps one model up. Sometimes the model itself is the problem: it refuses a prompt the others accept, or every route is saturated. At that point you want a ranked list of acceptable models, each with its own price cap:
FALLBACK = [
# (model, input overrides, max_cost_usd)
("seedance-2.5", {}, 1.68),
("kling-3.0", {"resolution": "1080p"}, 1.36),
("wan-3.0", {"resolution": "720p"}, 0.56),
]
Two rules make this safe:
-
Only fall back on errors that another model could fix.
model_busy,model_unavailable,timeout,all_routes_failedandmodel_declinedare good reasons to try the next model.invalid_input(a bad reference URL) andmax_cost_exceededwill fail everywhere, so stop and fix the request. Treatcontent_policyas a product decision: sometimes another model accepts the prompt, but often you want the refusal to stand. -
Each fallback is a new intent, so it gets a new idempotency key. The body changes (different model), so reusing the key would be a
409anyway.
Layer 3: idempotency keys that mean something
The most expensive bug in generation pipelines is the double submit. A worker submits a job, the response is lost (a timeout, a deploy, an OOM kill), the queue redelivers the message, and the worker submits again. Now you've paid twice.
The fix is to derive the key from what you're trying to make, not from the HTTP call:
{job_id}/{shot_id}/{model}/a{attempt}
- After a crash, a redelivered message rebuilds the same key, so the resubmit returns the existing generation and you resume polling it.
-
attemptincreases only after a generation has reachedfailed. Failed generations are never charged, so a fresh attempt can't double-bill. Never mint a new key while the previous attempt's status is unknown. Resubmit with the old key and let the server tell you. - Persist the generation
idnext to the key as soon as you get it, so a restart can skip straight to polling.
The HTTP layer retries with the same key: 429 (honour Retry-After), 503 busy/model_unavailable, 500, and connection errors. All of those are safe to replay because of the key.
Putting it together
import os, time, requests
API = "https://api.revana.si/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['REVANA_API_KEY']}"
RETRY_HTTP = {429, 500, 502, 503, 504}
NEXT_MODEL = {"model_busy", "model_unavailable", "timeout", "all_routes_failed", "model_declined"}
def submit(model, inp, max_cost, key, tries=6):
body = {"model": model, "input": inp, "max_cost_usd": max_cost}
for i in range(tries):
try:
r = S.post(f"{API}/generations", json=body, headers={"Idempotency-Key": key}, timeout=60)
except requests.ConnectionError:
time.sleep(2 ** i); continue # same key: safe to replay
if r.status_code in (200, 202):
return r.json()
if r.status_code in RETRY_HTTP:
time.sleep(float(r.headers.get("Retry-After", 2 ** i))); continue
err = r.json()["error"] # 4xx: retrying won't help
raise RuntimeError(f"{r.status_code} {err['code']}: {err['message']}")
raise RuntimeError("gave up submitting")
def wait(gen_id, deadline_s=1800):
t0, delay = time.time(), 5
while time.time() - t0 < deadline_s:
r = S.get(f"{API}/generations/{gen_id}", timeout=30)
if r.status_code == 200:
g = r.json()
if g["status"] in ("succeeded", "failed"):
return g
time.sleep(delay); delay = min(delay * 1.5, 30) # back off on long jobs
raise TimeoutError(gen_id)
def download(gen_id, path):
with S.get(f"{API}/generations/{gen_id}/content", stream=True, timeout=300) as r: # follows the 302
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(1 << 20):
f.write(chunk)
def make_shot(job_id, shot_id, base_input, fallback, out_path, max_attempts=2):
for model, overrides, max_cost in fallback:
inp = {**base_input, **overrides}
for attempt in range(max_attempts):
key = f"{job_id}/{shot_id}/{model}/a{attempt}"
g = wait(submit(model, inp, max_cost, key)["id"])
if g["status"] == "succeeded":
download(g["id"], out_path)
return {"model": model, "id": g["id"], "cost_usd": g["cost_usd"]}
err = g["error"]
print(f"{key}: {err['code']} (retryable={err['retryable']}) {err['message']}")
if err["code"] not in NEXT_MODEL:
raise RuntimeError(err["message"]) # fix the request, don't burn fallbacks
if not err["retryable"]:
break # this model won't do it: next model
# retryable on this model: one more attempt with a new key (the failed one cost $0)
raise RuntimeError(f"{shot_id}: every model in the fallback list failed")
if __name__ == "__main__":
shot = {"prompt": "close-up of hands pouring cold brew over ice, morning light, 9:16 vertical",
"resolution": "720p", "duration": 8, "aspect_ratio": "9:16", "audio": True}
print(make_shot("campaign-42", "shot-07", shot, FALLBACK, "shot-07.mp4"))
(FALLBACK is the list from Layer 2. Paste it above __main__.)
Notes on the choices:
- Back off on polling. Generation takes minutes, and polling every second just burns rate limit. Start at 5 s and cap at 30 s.
-
4xx on submit is final.
402 insufficient_credits,409 idempotency_conflictand422 invalid_inputall need a human or a code change, not a loop. -
cost_usdgoes in your ledger, notquoted_usd. If a backup route delivered, the charge can differ from the first quote, though never abovemax_cost_usd. -
Concurrency.
429 concurrency_limitmeans too many jobs are in flight. Run the shots through a bounded worker pool (8-16 is plenty) instead of firing 100 at once.
Only paying for what succeeds
The billing model shapes the retry strategy. If a provider charges for failed or timed-out generations, every retry is a gamble, and you end up writing elaborate pre-validation to avoid them. When failures cost $0, as with Revana and ByteDance's own API (which documents charging only successful videos), retrying a model_busy is free, and the only real cost control you need is max_cost_usd per attempt.
The worst case is easy to bound: attempts x max_cost_usd, but only for attempts that succeed, and make_shot stops at the first success. For the fallback list above, a shot costs at most $1.68 (the highest cap in the list), even if all three models are tried.
Before a big batch, check that the account can cover it. GET /v1/balance returns available_usd (what new jobs can reserve) and held_usd (what running jobs are holding):
bal = S.get(f"{API}/balance", timeout=30).json()
need = 100 * FALLBACK[0][2] # 100 shots at the first model's cap
assert bal["available_usd"] >= need, f"top up: {bal['available_usd']} < {need}"
Checklist
- [ ] Price from the live list, cap every request with
max_cost_usd. - [ ] Idempotency key = job + shot + model + attempt. Persist the generation id as soon as you have it.
- [ ] Replay transport errors,
429,5xxwith the same key. HonourRetry-After. - [ ] New attempt key only after a confirmed
failed. - [ ] Fall back to the next model on capacity/timeout errors, not on
invalid_input. - [ ] Poll with backoff, download from
/content, recordcost_usd. - [ ] Bound concurrency, and check
available_usdbefore a batch.
The OpenAPI spec with every error code is at https://revana.si/openapi.json, and live model prices are at GET https://api.revana.si/v1/models. Prices in this post are as of 2026-11-05.
Try it: revana.si has one API key for 200+ video and image models, with live prices on revana.si/pricing.
Top comments (0)