Video generation timeouts and retries without paying twice
An idempotency key makes a retried submit safe; a poll timeout is not a failure. The four states to branch on and the retry policy that doesn't double your bill.
The expensive mistake in video generation is retrying a submit you’re not sure landed. POST /v1/video/generate on Infrai accepts an idempotency_key, and with one set a retried request returns the original job instead of starting a second render — which matters more here than almost anywhere else, because you’re billed per second of output and a duplicate is a duplicate charge.
The second mistake is treating your own poll timeout as a failure. It isn’t; the job is still running.
Make the submit retry-safe
curl -sS -X POST "https://api.infrai.cc/v1/video/generate" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"prompt": "a paper boat drifting down a gutter after rain",
"model": "Kling:2.5",
"resolution": "720p",
"duration_seconds": 5,
"aspect_ratio": "9:16",
"idempotency_key": "render-post-4821-v1",
"store": true
}'
{
"ok": true,
"data": {
"job_id": "vid_2fVc8nRqLmT4xBzY",
"state": "queued",
"model": "Kling:2.5",
"vendor": "tencent_vod",
"duration_seconds": 5,
"cost_usd": null,
"created_at": "2026-09-21T03:30:00Z",
"retention_days": 7
}
}
Derive the key from your own domain object, not from a random value. render-post-4821-v1 is stable across retries of the same intent and different from the next intent — a UUID generated per attempt defeats the whole mechanism, because each retry looks like a new request.
Bump the suffix when the user genuinely wants another take.
That single convention carries the whole distinction the platform can’t infer for you: “my request timed out, try again” and “generate me a different one” arrive as identical HTTP requests, and the only thing that separates them is whether the key changed — which is why deriving it from a version counter on your own object, rather than from the clock or a random source, is the difference between a retry that costs nothing and a retry that bills a second render.
A poll timeout is not a failure
import os
import time
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
TERMINAL = {"succeeded", "failed", "cancelled"}
RETRYABLE_SUBMIT = {"RATE_LIMIT_ACCOUNT", "RATE_LIMIT_VENDOR", "VENDOR_TIMEOUT", "NETWORK_ERROR"}
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def submit(prompt: str, key: str, seconds: int = 5, model: str = "Kling:2.5",
attempts: int = 4) -> dict:
"""Retry the SUBMIT only for transport-level failures, and always with the same
idempotency key — so a retry that turns out to be a duplicate returns the
original job instead of billing for a second render."""
body = {"prompt": prompt, "model": model, "resolution": "720p",
"duration_seconds": seconds, "aspect_ratio": "9:16",
"idempotency_key": key, "store": True}
for attempt in range(attempts):
try:
resp = SESSION.post(f"{API}/v1/video/generate", json=body, timeout=60)
except requests.RequestException:
time.sleep(min(30, 2 ** attempt))
continue
payload = resp.json()
if payload.get("ok"):
return payload["data"]
code = payload.get("error", {}).get("code")
if code == "IDEMPOTENCY_KEY_CONFLICT":
# Same key, different body: somebody changed the prompt without
# changing the key. That is a bug in the caller, not a transient fault.
raise RuntimeError("idempotency key reused with different parameters")
if code not in RETRYABLE_SUBMIT:
raise RuntimeError(f"submit refused: {code}")
time.sleep(min(30, 2 ** attempt))
raise RuntimeError("submit retries exhausted")
def await_job(job_id: str, budget_seconds: int = 1200) -> dict:
"""Return the job whatever happens. Exceeding OUR budget means we stop waiting,
not that the render failed — so hand back the last known state and let the
caller decide, rather than raising and losing the job id."""
deadline = time.monotonic() + budget_seconds
interval, last = 5, {}
while time.monotonic() < deadline:
status = SESSION.get(f"{API}/v1/video/status/{job_id}", timeout=25)
status.raise_for_status()
last = status.json()["data"]
if last.get("state") in TERMINAL:
final = SESSION.get(f"{API}/v1/video/get/{job_id}", timeout=25)
final.raise_for_status()
return final.json()["data"]
time.sleep(interval)
interval = min(20, interval + 2)
return {"job_id": job_id, "state": last.get("state", "running"), "timed_out_waiting": True}
if __name__ == "__main__":
job = submit("a paper boat drifting down a gutter after rain", "render-post-4821-v1")
print(await_job(job["job_id"]))
The timed_out_waiting flag is the important design choice. A worker that raises on its own timeout loses the job_id, and now you have a render you’re paying for and can’t find — store the id before you start waiting, and treat waiting as a separate concern from the job’s outcome.
Branch on the state, not on the exception
| Outcome | Retry the submit? | Why |
|---|---|---|
| Network error before a response | yes, same idempotency key | you don’t know if it landed |
RATE_LIMIT_* | yes, with backoff, same key | transient |
state: "failed" with an error | maybe — read the error first | a bad prompt fails identically forever |
| Your poll budget elapsed | no | the job is still running |
state: "succeeded" | no | you have output |
IDEMPOTENCY_KEY_CONFLICT | no | fix the caller |
The third row deserves care. A failed render is not automatically worth retrying: if the prompt violated a content rule or named an unsupported combination, the second attempt fails the same way and costs the same. Read error on the job, and only retry the failures that look transient.
Cap the retries per intent, in your own code
Nothing on the platform stops a loop that submits with a fresh key each time, so the ceiling is yours to impose. Count attempts against your own domain object — three takes per post, say — and refuse the fourth with a message rather than a silent retry.
Pair that with a per-job unit ceiling: duration times the resolution multiplier from GET /v1/video/capabilities, refused above a limit you chose. Between them, an agent stuck in a loop costs you a bounded amount instead of an open-ended one, and PUT /v1/account/budget/set catches whatever gets past both.
Limitations
There’s no automatic retry inside the platform for a failed generation, and no partial refund mechanism you can call — cost_usd on the finished job is the figure, and a failed job’s cost is whatever it is, so read it rather than assuming zero. Cancelling with POST /v1/video/cancel/{id} is your only in-flight control.
Idempotency also protects the submit, not the outcome: the same key returns the same job, but two different keys with the same prompt are two renders and two charges. Deduplicating by prompt is your own concern if users can submit the same thing twice.
Going direct to a provider such as Runway or Kling gets you their own job semantics and sometimes finer retry controls, which is worth it if one model is your product. What you keep here is that the submit, the queue behind it on POST /v1/queue/publish, the archive on PUT /v1/storage/object/put/{bucket}/{key}, the failure record on POST /v1/errors/capture and the spend in GET /v1/account/usage are one credential and one invoice. The per-second rate is live in GET /v1/discovery/video.generate (verified 2026-09-21) and drifts downward as vendor contracts improve.