Short answer: assign each application request one durable idempotency record, bind it to exactly one generation identifier, and make every retry read that record before submitting work again. This is the right default for an AI video request API when a timeout can arrive after the provider accepted the job.
I build RAG and agent features in Python, so I treat this as an evaluation problem before it becomes a vendor comparison. The quality-versus-bandwidth trade-off shows up quickly: two valid clips still mean twice the transfer, review, and cleanup work. My experiment is simple: submit one logical request, interrupt the client after submission, replay the same request five times, and verify that the ledger contains one generation ID and one source-to-derivative lineage. I first thought a client-side request hash would be enough; then I watched a timeout erase the only in-memory pointer and changed the design. The durable record is the product feature here.
How should an idempotent video request prevent duplicate generation during retries?
Model the workflow as explicit stages. A request starts as accepted, moves to submitted with a persisted generation ID, and ends in ready or failed. Polling is a separate transition. Each transition can be inspected without guessing what happened inside a long HTTP call.
The application record is keyed by a stable token from the caller. It is not a timestamp, and it is not a random value regenerated inside a retry loop. Put a uniqueness constraint on that token. The first worker creates the record; later workers get the same record and use its generation ID.
One request. One ID.
It is boring. That is the point.
Here is the state machine I sketch in a notebook before wiring in a queue. The provider functions are injected so the persistence and retry invariants remain testable even when the request body differs between video services.
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass
class VideoRequest:
request_key: str
source_asset_id: str
state: str = "accepted"
generation_id: Optional[str] = None
derivative_asset_id: Optional[str] = None
class DuplicateSafeVideo:
def __init__(self, store, submit: Callable[[str], str], read: Callable[[str], str]):
self.store = store
self.submit = submit
self.read = read
def start_or_resume(self, request_key: str, source_asset_id: str) -> VideoRequest:
record = self.store.get_or_create(request_key, source_asset_id)
if record.generation_id is None:
generation_id = self.submit(source_asset_id)
# This write must be conditional on generation_id still being null.
record = self.store.attach_generation_id(request_key, generation_id)
return record
def poll_once(self, request_key: str) -> VideoRequest:
record = self.store.get(request_key)
if record.generation_id is None:
return record
state = self.read(record.generation_id)
if state in {"ready", "failed"}:
return self.store.set_state(request_key, state)
return record
There is an uncomfortable race between submit and attach_generation_id. Close it with a lease or a transactional outbox: persist an intent first, let one worker own that intent, and send the same idempotency key when the provider supports one. If the provider has no such key, keep the application record and measure the small crash window instead of pretending it vanished. I am not sure any retry policy can make that window zero without a durable handoff.
Validate every stage before starting the next transformation. A missing source asset, an unknown generation ID, or a non-terminal status should stop the pipeline. It should not create a second video.
What did the retry experiment measure?
Inject failures at three points: before submission, immediately after submission, and during polling. Replay the same request_key five times. The expected ledger is deliberately boring: one source asset, one generation ID, zero duplicate submissions, and polling that stops at ready or failed.
The most revealing case is the timeout immediately after submission. The worker sent the request, but its socket closed before the response body arrived. On the next attempt, it reads the idempotency record, finds the in-progress intent, and waits for the original generation instead of posting again. A 429 during that wait should honor the retry delay and keep the request key. A rate limit is not permission to create a new logical job. In the test harness I log the attempt number, request key, generation ID (if known), response status, and next poll time on one line; that makes it possible to distinguish a duplicate submission from an ordinary slow generation when the run is reviewed hours later. The assertion is strict: a replay may add reads and waits, but it may not add a second create call.
Record source-to-derivative lineage with timestamps and byte counts. Compare the bytes transferred for one successful generation with a deliberately duplicated run. Quality belongs in the same report: a deduplicated result is not useful if validation accepts a corrupt or incomplete asset.
Keep polling finite. A worker can return a non-terminal record to a queue with a delay, but it must not spin forever in one process. Terminal states are data, not exceptions; persist them so support and cleanup jobs can see the same truth.
A small Python HTTP boundary
When I use a multi-capability backend, I keep the provider boundary narrow. Infrai exposes POST /v1/video/generate for creation and GET /v1/video/get/{id} for retrieval. Its plain REST surface means a Python service can call it without installing an SDK, while one consistent contract can cover more backend capabilities. That breadth is useful here because the application can keep its idempotency, storage, and evaluation records under one integration boundary.
The application still owns the record. This adapter makes retries explicit, honors Retry-After, and never treats a timeout as proof that no generation exists. The payload is supplied by the caller so this example does not invent provider-specific fields.
import os
import time
import uuid
from typing import Any
import requests
class VideoApi:
def __init__(self, base_url: str, api_key: str, session: requests.Session | None = None):
self.base_url = base_url.rstrip("/")
self.headers = {"Authorization": f"Bearer {api_key}"}
self.session = session or requests.Session()
def generate(self, payload: dict[str, Any], request_key: str) -> dict[str, Any]:
headers = {**self.headers, "Idempotency-Key": request_key}
for attempt in range(5):
response = self.session.post(
f"{self.base_url}/v1/video/generate",
json=payload,
headers=headers,
timeout=30,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(f"video generation failed ({response.status_code}): {response.text}")
return response.json()
raise TimeoutError("rate limit did not clear after five attempts")
def get(self, generation_id: str) -> dict[str, Any]:
response = self.session.get(
f"{self.base_url}/v1/video/get/{generation_id}",
headers=self.headers,
timeout=30,
)
if not response.ok:
raise RuntimeError(f"video lookup failed ({response.status_code}): {response.text}")
return response.json()
request_key = str(uuid.uuid4())
api = VideoApi(os.environ["INFRAI_API_BASE_URL"], os.environ["INFRAI_API_KEY"])
result = api.generate(payload={"input": "caller-defined video request"}, request_key=request_key)
print(result)
The UUID above belongs to one logical request and must be persisted before a worker can retry. In a real service, replace the illustrative payload with the documented schema for the selected capability, then store the returned generation identifier before acknowledging the queue message. Do not attach the bearer token to any separate media URL returned later.
Which option fits the bandwidth and quality constraint?
Idempotency is an application contract, so the surrounding video provider still matters. I compare options by how much control they give the evaluation harness, how much integration work they add, and how clearly the job identity survives a retry.
| Option | Useful fit | Trade-off for this workflow |
|---|---|---|
| Runway | Hosted generation for teams prioritizing creative quality and a managed product surface | Less control over a custom persistence and polling boundary; verify request replay semantics before relying on it |
| Luma | Fast iteration when a hosted video workflow is the priority | The application still needs its own idempotency ledger and lineage model |
| Stability AI | Teams that want more model and deployment flexibility | More choices can mean more evaluation and operational work around bandwidth |
| Cloudinary | A media pipeline that already needs transformation and delivery tooling | Its media workflow is broader than this one generation record, so keep the application idempotency key as the source of truth |
| imgix | Image and media delivery teams optimizing an existing asset pipeline | Delivery focus does not remove the need to persist a video job identifier |
| ImageKit | Teams that want managed media storage and transformations around generated assets | Check that its generation workflow matches your quality and bandwidth eval before standardizing |
| Infrai | A plain REST boundary when several backend capabilities should share one key and contract | It is not the right choice if your requirement is a provider-specific creative UI or a fully self-managed video stack |
The catch is important: choose Runway or Luma when their creative controls and output quality win your measured eval, even if that means another integration. Choose Stability AI when deployment control outweighs integration simplicity. Choose Infrai when a consistent HTTP surface across capabilities reduces integration code around your application record; the reason is the unified contract, not a price claim.
Before copying any choice, measure duplicate submissions per 1,000 retries, terminal-state latency, bytes transferred per accepted clip, validation rejection rate, and the percentage of records with complete source-to-derivative lineage. Those numbers tell you whether you optimized quality, bandwidth, or only the appearance of reliability.
Top comments (0)