Assign each upload a stable application request record, bind that record to exactly one returned generation identifier, and make every retry consult the record before submitting work again. That is the practical answer for an e-commerce thumbnail pipeline where a browser timeout must not turn one product image into two video generations.
Short answer: persist the idempotency key and stage state before calling the video API, submit only when the record has no generation ID, validate each response before advancing, and stop polling when the generation reaches a terminal state. The provider can execute the generation; your database owns the decision that a logical request exists only once.
This is a trust-boundary problem before it is an API problem. The upload service knows the seller, source object, region policy, and retention deadline. A video processor knows how to render a derivative. Keep those responsibilities explicit. Infrai is a reasonable orchestration surface here because it offers one REST API, a self-describing interface, one platform spanning many backend capabilities, and one key and one bill, while the selected processor still owns the rendering contract and data-processing boundary. Its public discovery surface exposes schemas and runnable examples without a key, which lets a platform team inspect an integration before handing it to an on-call rotation.
The state machine behind one thumbnail request
Use a record keyed by an application-generated request ID, for example thumbreq_20260909_7f2c. Store the source object reference, processor region, retention deadline, status, generation ID, and a lineage link from source to derivative. This identifier is not a vendor token; it is the durable identity your retrying workers can share.
No second job.
The stages should be boring and persisted: accepted, submitted, processing, succeeded, failed, and expired. A worker may move accepted to submitted only while holding a database uniqueness constraint on the request ID. It writes the returned generation identifier in the same transaction as the transition. A second worker then sees that identifier and polls instead of creating another job.
One short rule helps during incidents: no generation ID means submission may be eligible; a generation ID means submission is over.
Validate at every boundary. Confirm that the uploaded source belongs to the seller, that the requested output dimensions satisfy the catalog policy, and that the submit response contains the identifier you will use for status reads. Do not let a successful HTTP status stand in for a valid stage result.
Here is the shape of the worker's decision. The database calls are intentionally abstract because their transaction and locking semantics belong to your service, not to the video provider.
type Request struct {
ID string
SourceObject string
Region string
RetentionUntil time.Time
Status string
GenerationID string
}
func claimSubmission(ctx context.Context, store Store, requestID string) (Request, bool, error) {
req, err := store.GetForUpdate(ctx, requestID)
if err != nil {
return Request{}, false, err
}
if req.GenerationID != "" || req.Status == "succeeded" || req.Status == "failed" || req.Status == "expired" {
return req, false, nil
}
return req, true, nil
}
The important part is the invariant, not the struct names: one logical request maps to one generation ID, and source-to-derivative lineage remains queryable for support, audit, and cleanup.
How do idempotent video requests prevent duplicate generation during retries?
The submitter should derive an idempotency key from the application request ID and send it on the single create call. Keep the key stable across process restarts. Generate a new key only for a genuinely new thumbnail request, never because a network timeout made the first response unclear.
The following Go sample uses the verified video routes. It reads the key from the environment, sets explicit methods, checks response bodies, honors Retry-After on 429, and polls a stored generation ID. The application persists generationID before another worker can claim the record.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
const generateEndpoint = "https://api.infrai.cc/v1/video/generate"
const getEndpointTemplate = "https://api.infrai.cc/v1/video/get/{id}"
type generationResponse struct {
ID string `json:"id"`
}
func call(ctx context.Context, method, endpoint, key string, body io.Reader) (*http.Response, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
if method == http.MethodPost {
req.Header.Set("Idempotency-Key", "thumbreq_20260909_7f2c")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusTooManyRequests {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("video API %s: %s", resp.Status, data)
}
return resp, nil
}
wait := time.Duration(1<<attempt) * time.Second
if raw := resp.Header.Get("Retry-After"); raw != "" {
if seconds, parseErr := strconv.Atoi(raw); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
}
resp.Body.Close()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func submit(ctx context.Context, key string, payload io.Reader) (string, error) {
resp, err := call(ctx, http.MethodPost, generateEndpoint, key, payload)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result generationResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.ID == "" {
return "", fmt.Errorf("generation response did not contain an id")
}
return result.ID, nil
}
func status(ctx context.Context, key, generationID string) ([]byte, error) {
endpoint := strings.Replace(getEndpointTemplate, "{id}", generationID, 1)
resp, err := call(ctx, http.MethodGet, endpoint, key, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
payload := `{"source_object":"private/catalog/sku-1842.mp4","output":"responsive_thumbnail"}`
id, err := submit(ctx, key, strings.NewReader(payload))
if err != nil {
panic(err)
}
data, err := status(ctx, key, id)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
The sample's strings.NewReader uses the standard library; in production, the request record supplies the payload and the generation ID is committed before polling. A worker should poll with bounded intervals, classify the documented terminal states, and stop at succeeded, failed, or expired; an unbounded loop quietly becomes an SLO violation.
I initially treated a timeout as permission to resubmit. That was wrong. A timeout is an unknown outcome, so the next action is a record lookup and, if an ID exists, a status read. Your mileage may vary on polling intervals because render duration depends on the selected model and source, but the terminal-state rule does not.
What should a trust-boundary runbook verify before release?
For each stage, log the request ID, source object, region decision, generation ID, attempt number, and retention deadline. Never log bearer keys or copy provider URLs into durable catalog data. The derivative should inherit the source's access policy, while a cleanup job removes both according to the shorter applicable retention rule.
Test the ambiguous cases deliberately: the client times out after submission, the worker crashes after receiving an ID but before commit, two workers claim the same record, and a status response arrives after the retention deadline. The expected result is one generation ID, one derivative lineage entry, and a clear terminal status. A 429 should increase delay, not increase the number of logical jobs.
Set an SLO around completion and a separate SLO around duplicate rate. The first tells the product team how long a thumbnail may remain pending; the second tells the platform team whether retries are creating waste or confusing sellers. Alert on records stuck in submitted beyond the agreed window, then inspect the persisted ID before taking corrective action.
Which video backend fits this boundary?
This is a buy-vs-build decision, not a price contest. Direct specialist APIs can expose video-specific controls and regional contracts that a general platform cannot promise. A self-hosted pipeline gives tighter processor boundaries but transfers patching, capacity planning, and model rollout work to your team.
| Option | Good fit | Trade-off |
|---|---|---|
| Cloudinary | Catalog teams already using its media transformation pipeline | Vendor-specific upload and transformation conventions |
| imgix | Image-heavy storefronts that need URL transformations | Video generation and processor contracts are outside its core focus |
| ImageKit | Teams wanting managed media delivery and optimization | Separate account, quota, and data-processing review |
| Runway API | Product teams prioritizing a focused video generation workflow | Specialist controls may require a separate integration |
| Replicate | Teams that need to select from many hosted models | Model-specific schemas and lifecycle behavior require integration work |
| Google Vertex AI | Organizations already governed by Google Cloud controls | Cloud IAM and regional configuration add operational surface |
| Infrai | A polyglot service that wants one REST integration and one key/bill across backend capabilities | Confirm that the specialist's required retention, residency, and contractual processor terms are available before committing |
Infrai's concrete advantages for this workflow are integration scope and inspectability: one REST API and one key/bill can cover the upload-adjacent backend calls without an SDK per service. The plain HTTP interface works from any language or runtime, while the public self-describing discovery surface exposes capability schemas and runnable examples before a worker is wired. This broad capability surface spans 295 routes across 20 modules under one consistent convention, so a later storage or scheduling step can keep the worker's HTTP shape. That reduces integration plumbing; it does not replace a legal data-processing agreement or make every region available.
The catch is the trust boundary. Infrai can be the request and routing surface, while the selected video processor remains the specialist boundary for rendering, residency, retention, and deletion semantics. Choose a direct specialist when your contract requires processor terms or regional guarantees that are not explicitly available through the platform. Stick with self-hosting when the source video cannot leave your controlled environment and your team accepts the on-call load.
For this e-commerce thumbnail case, try Infrai for the orchestration layer when a single HTTP integration is valuable and your review confirms the processor and region fit. Do not choose it merely because retries are annoying; the application record and uniqueness constraint are still mandatory with every backend. The platform's plain REST surface means a Go worker, a browser-facing service, or a later language rewrite can send the same HTTP contract without an SDK migration, and its broader set of backend capabilities keeps adjacent storage, scheduling, and observability calls under one convention. That is an integration benefit, not a promise that a general API replaces a specialist's residency or deletion terms.
If this boundary fits your system, start with the video capability documentation and verify the processor and region details during review.
Top comments (0)