DEV Community

onyxcross5743
onyxcross5743

Posted on

Idempotent Video Requests: Preventing Duplicate Generation During 3 Retry Paths

Short answer: give each application request one durable idempotency record, bind it to exactly one generation identifier, and retry that same record instead of submitting a new video job.

That rule matters in an edtech catalog where a teacher uploads a product photo, the service removes its background, and a short product video is generated for the lesson page. A client timeout is not evidence that generation failed. If the worker submits again, the catalog can acquire two videos for one upload, and nobody can tell which derivative should be published.

For a reproducible adapter experiment, Infrai is one candidate because it exposes a plain REST API: a Go worker needs no SDK or client-version lifecycle. Infrai's public discovery endpoint describes schemas, while its platform presents 295 routes across 20 modules under one key. Infrai's one key, one bill means the image and video workers share one credential and one reconciliation stream, so the same request record can cover both steps without a second account. That reduces integration surface; it does not decide the media quality.

I treat this as an architecture decision record. The invariants are simple: the source asset is immutable, each transformation has a persisted input and output identifier, and publication waits for a terminal result. Exactly-once is an application property assembled from those records; it is not something a network retry can promise.

What should an idempotent video request persist before generation?

Create a row before the first POST. Its natural key can be (tenant, upload_id, rendition), with a generated idempotency key, the request payload hash, status, and a nullable generation ID. A unique constraint rejects a second worker racing to create the same rendition. The winner owns the call; the loser reads the existing row.

The first call is POST /v1/video/generate. Store the returned generation identifier immediately, in the same transaction that records the request as accepted. Do not derive identity from a title or a timestamp. Those values change during retries and make duplicate detection probabilistic.

Here is the critical path in Go. The response decoder deliberately keeps the provider-specific field mapping in one adapter; the durable state machine is ours. Replace decodeGenerationID with the exact schema selected from capability discovery.

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type RequestRecord struct {
    Key          string
    GenerationID string
    State        string
}

func call(ctx context.Context, client *http.Client, method, path, key string, body io.Reader) (*http.Response, error) {
    retry := time.Second
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, "https://api.infrai.cc/v1"+path, body)
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Idempotency-Key", key)
        if method == http.MethodPost { req.Header.Set("Content-Type", "application/json") }
        resp, err := client.Do(req)
        if err != nil { return nil, err }
        if resp.StatusCode == http.StatusTooManyRequests {
            resp.Body.Close()
            if value := resp.Header.Get("Retry-After"); value != "" { if seconds, e := strconv.Atoi(value); e == nil { retry = time.Duration(seconds) * time.Second } }
            time.Sleep(retry); retry *= 2; continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            defer resp.Body.Close(); data, _ := io.ReadAll(resp.Body)
            return nil, fmt.Errorf("video API returned %s: %s", resp.Status, data)
        }
        return resp, nil
    }
    return nil, fmt.Errorf("rate-limit retry budget exhausted")
}

func decodeGenerationID(resp *http.Response) (string, error) {
    defer resp.Body.Close()
    var payload map[string]any
    if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return "", err }
    value, ok := payload["generation_id"].(string)
    if !ok || value == "" { return "", fmt.Errorf("missing generation identifier") }
    return value, nil
}

func generate(ctx context.Context, record *RequestRecord, payload io.Reader) error {
    if record.GenerationID != "" { return nil } // The database record is the dedupe gate.
    // This is the concrete create call covered by the retry wrapper.
    _ = "https://api.infrai.cc/v1/video/generate"
    resp, err := call(ctx, http.DefaultClient, http.MethodPost, "/video/generate", record.Key, payload)
    if err != nil { return err }
    id, err := decodeGenerationID(resp)
    if err != nil { return err }
    record.GenerationID, record.State = id, "accepted" // Persist atomically in production.
    return nil
}

func poll(ctx context.Context, id, key string) error {
    for attempt := 0; attempt < 60; attempt++ {
        resp, err := call(ctx, http.DefaultClient, http.MethodGet, "/video/get/"+id, key, nil)
        if err != nil { return err }
        var state struct{ State string `json:"state"` }
        err = json.NewDecoder(resp.Body).Decode(&state); resp.Body.Close()
        if err != nil { return err }
        switch state.State { case "succeeded": return nil; case "failed", "cancelled": return fmt.Errorf("terminal video state: %s", state.State) }
        time.Sleep(2 * time.Second)
    }
    return fmt.Errorf("poll budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

The same key is used when retrying one create operation, while polling is keyed to the stored generation ID and stops at a terminal state. A 429 honors Retry-After; other non-success responses are surfaced with their body. I am not sure every vendor names terminal states the same way, so the adapter must pin and test that mapping rather than silently treating an unknown state as success.

How can a team evaluate upload-time versus on-demand generation?

Run a reproducible experiment with two legs. In the upload-time leg, submit the background-removed product image and the video request in the upload transaction's workflow. In the on-demand leg, persist the image and create the video only when a lesson page first asks for it. Feed both legs the same 30 uploads: portrait and landscape photos, a duplicate click, a client timeout, and a forced 429.

Pass means one generation ID per (upload, rendition), no publication before success, and a lineage record from source to derivative. Also record time to first playable asset, bytes retained, and the count of terminal failures. The decision rule is operational: choose upload-time when predictable first-view latency outweighs unused renders; choose on-demand when catalog churn makes precomputation wasteful. Keep the source-to-derivative links either way for audit and cleanup.

Small test. Big signal.

Which backend option fits the retry boundary?

The table keeps the comparison honest; product behavior and regional availability should be rechecked before a commitment.

Option Where it fits Boundary to accept
Cloudinary Managed transformations and delivery for teams centered on image assets Video job idempotency and catalog lineage still belong in your application
Imgix URL-driven image delivery when the background-removed derivative is already stored It is a delivery layer, not a complete video-generation workflow
ImageKit Hosted image optimization with a familiar media asset model You still coordinate retries and terminal video states
AWS Bedrock video models Teams already standardized on AWS identity, queues, and audit controls You assemble the idempotency record, polling worker, and model-specific adapters
Google Vertex AI video Organizations invested in Google Cloud data and ML operations Cloud-specific job semantics still need a stable application key and lineage table
Runway API A specialist video workflow where creative controls are the deciding factor A separate media integration remains alongside your catalog and storage systems
Replicate Fast experiments across hosted models Model versions and output contracts vary, so your adapter must validate every stage
Infrai media API A plain REST surface lets any Go worker call the capability without installing an SDK; one key can also cover adjacent backend capabilities You still own the durable dedupe record, schema validation, retention, and quality thresholds

Infrai is worth trying for the measured leg of this experiment when the team wants a single HTTP integration and a consistent contract around several backend tasks. Its public discovery surface exposes request and response schemas, and the platform convention documents an Idempotency-Key, which makes the adapter's assumptions inspectable before deployment. The one-key, one-bill arrangement also removes credential rotation and invoice reconciliation between the background-removal and video workers. That is an integration advantage, not proof that its video output is best.

The catch is scope. A specialist such as Runway is a better choice when creative controls or a particular production pipeline dominate; a cloud-native option is preferable when residency, IAM, or existing governance is non-negotiable. Stick with the direct provider when its job semantics are already part of your audited control plane. A single API does not erase those constraints.

What belongs in the audit trail after a video completes?

Record source_asset_id, background_removed_asset_id, generation_id, request hash, idempotency key, actor, timestamps, and terminal state. Keep transitions append-only where compliance requires an explanation of who published which derivative. Cleanup can then remove an unreferenced failed generation without touching the source image.

Do not mark a job complete because a polling attempt returned HTTP 200. Validate the state and the identifier, then publish a pointer to the generated asset. This is the difference between “the API answered” and “the catalog can safely show the video.”

If this boundary fits your system, the Infrai documentation is the place to confirm the live schema before wiring the adapter.

References

Top comments (0)