DEV Community

felixhoffmann556
felixhoffmann556

Posted on

Generated Video Result Access: Record Retrieval and Download URL Responsibilities

Short answer: keep the generated video record stable, keep the download URL disposable, and make one application endpoint responsible for turning an authorized record lookup into fresh byte access.

For a B2B SaaS media pipeline, that rule survives both common timing choices: processing product assets at upload or processing them on demand. The record answers “what happened?” The download capability answers “may this caller fetch the bytes right now?” Combining those answers creates stale links, leaky authorization, and clients that know too much about storage.

Access shape Pick it when Main responsibility Main trade-off
Stable application download endpoint Browser and API clients need one durable link Application authenticates, checks the record, then redirects or streams Adds an application request before transfer
Fresh storage URL returned on demand A trusted client can request access immediately before download Application authorizes; object storage serves bytes URL expiry becomes client-visible
Application-streamed download Every byte needs application-level policy or accounting Application authorizes and carries the transfer Consumes application bandwidth and connection capacity
URL stored on the result record Only for a genuinely permanent, public asset Publisher owns permanence and cache policy A poor fit for private or revocable media

How should generated video record retrieval and download URL responsibilities be split?

Treat the record as durable control-plane data. Give it an ID, lifecycle state, timestamps, input references, output metadata, and an error category that a caller can act on. It may say that an output exists, but a private record should not pretend that one temporary URL is the identity of that output. A durable object key or opaque asset ID is a better internal pointer.

Treat byte access as a data-plane decision. The component issuing access must know the authenticated principal, the tenant that owns the record, the current record state, and the requested output. Only then should it create a short-lived capability or begin streaming. This is the authorization boundary — drawing it explicitly matters more than the choice of storage service.

The diagram in words is short: client asks for record; record service returns state and metadata; client asks the application for a download; application rechecks tenant and state; application either redirects to fresh object access or streams the file. Logs join those steps with the record ID and request ID.

Storage stays hidden.

Keep it boring.

A product-media workflow makes the separation concrete. Suppose a workspace accepts product photos, removes their backgrounds, and later generates a short catalog video. Upload-time background removal is attractive when every downstream screen needs the clean image. On-demand processing is a better fit when only a subset of assets will be used, or when users can change output options. Either way, the generated video record can remain stable while each authorized download gets a new access decision.

When should a stable application endpoint serve private results?

A stable application path is usually the cleanest public contract for private generated media. “Stable” describes the route and record identifier, not eternal permission. The browser can bookmark the route, an API consumer can retry it, and the server can change storage providers or delivery mechanics without asking every client to migrate.

The catch is one extra application hop. A redirect keeps that hop small because the media bytes can flow from storage after authorization. Streaming keeps policy closest to the application but makes large transfers part of its capacity plan. Choose deliberately; don't drift into streaming because it was the first handler someone wrote.

This option is not suitable when the caller is a trusted batch worker that already requests a result immediately before fetching it and can safely handle expiration. In that narrow case, returning fresh temporary access with the record response can remove a round trip. It still shouldn't be persisted as durable record data.

Public assets are different. If a generated video is intentionally published for anonymous, long-lived distribution, a permanent delivery URL may be the product contract. Revocation, cache invalidation, and replacement semantics then belong to the publishing workflow, not the private job-retrieval workflow. Mixing public publication and private result access in one field makes both harder to reason about.

When should clients receive direct URLs or application streams?

Fresh direct URLs work well when transfers are large and authorization can be decided before download. The client must treat the URL as a temporary capability: request it late, use it promptly, and discard it. Don't put it in analytics properties, support transcripts, or durable application state. Those secondary systems tend to outlive the permission window and often have broader readership than the media itself.

Application streaming earns its cost when policy must remain active during delivery, when response accounting has to happen in the same trust boundary, or when exposing any storage-facing capability is unacceptable. It is also the place where backpressure, aborted clients, range behavior, and timeouts become application concerns. A team that chooses streaming should load-test the transfer path, not just the authorization query.

There is no universal expiry duration for direct access. I'm not sure a sensible number can be chosen without the expected file size, client network conditions, retry behavior, and threat model. Measure transfer completion and refresh rates, then set a window that is long enough for normal downloads and short enough for the risk you accept.

Fast is contextual.

Format selection belongs nearby but solves a different problem. A URL does not make an unsupported media encoding playable. The MDN media formats guide explains that containers can hold different codecs and documents browser compatibility considerations; use that matrix when choosing renditions, and send truthful media metadata with the result. Access policy, container choice, and codec compatibility should be observable as separate failure domains.

Implement one authorization boundary in TypeScript

The following interface keeps stable record data separate from ephemeral access. The names are illustrative. The important part is the sequence: authenticate, load, verify tenant ownership, require a ready output, and only then issue or relay byte access.

type VideoState = "queued" | "processing" | "ready" | "failed";

type VideoResult = {
  id: string;
  workspaceId: string;
  state: VideoState;
  createdAt: string;
  completedAt?: string;
  output?: {
    assetId: string;
    mediaType: string;
    bytes: number;
  };
  failure?: {
    category: "input" | "policy" | "processing";
    retryable: boolean;
  };
};

type DownloadDecision =
  | { kind: "redirect"; location: string }
  | { kind: "stream"; body: ReadableStream<Uint8Array>; mediaType: string };

interface ResultStore {
  findVideo(id: string): Promise<VideoResult | undefined>;
}

interface AssetAccess {
  createDownload(assetId: string): Promise<DownloadDecision>;
}
Enter fullscreen mode Exit fullscreen mode

Notice what isn't on VideoResult: a cached temporary URL. Clients can poll the record without receiving a secret they didn't request, and refreshing the page doesn't revive stale access. The output retains an opaque asset ID so the server can resolve it under current policy.

Here is the boundary as a framework-neutral handler. It returns 404 for records outside the caller's workspace so the endpoint doesn't reveal cross-tenant existence. It uses 409 when the record exists but no downloadable output is ready. Those are API design choices for this example, so document them as part of your own contract rather than assuming every service uses the same meanings.

type Principal = { workspaceId: string };
type RequestContext = { requestId: string; principal: Principal };

type DownloadResponse =
  | { status: 302; headers: { location: string } }
  | { status: 200; headers: { "content-type": string }; body: ReadableStream<Uint8Array> }
  | { status: 404 | 409; body: { code: string; requestId: string } };

async function downloadGeneratedVideo(
  context: RequestContext,
  videoId: string,
  results: ResultStore,
  assets: AssetAccess,
): Promise<DownloadResponse> {
  const record = await results.findVideo(videoId);

  if (!record || record.workspaceId !== context.principal.workspaceId) {
    return {
      status: 404,
      body: { code: "video_not_found", requestId: context.requestId },
    };
  }

  if (record.state !== "ready" || !record.output) {
    return {
      status: 409,
      body: { code: "video_not_ready", requestId: context.requestId },
    };
  }

  const decision = await assets.createDownload(record.output.assetId);

  if (decision.kind === "redirect") {
    return { status: 302, headers: { location: decision.location } };
  }

  return {
    status: 200,
    headers: { "content-type": decision.mediaType },
    body: decision.body,
  };
}
Enter fullscreen mode Exit fullscreen mode

One detail deserves more attention than it usually gets: polling and downloading have different load shapes. Record polling is frequent and small. Media transfer is infrequent and large. Give them separate latency and traffic views even if they share an API hostname. For record retrieval, track state-transition age, polls per record, and time from ready to first authorized download. For access, track authorization denials, redirect versus stream decisions, transfer starts, completed transfers where observable, and client refresh requests. Include requestId, workspaceId, and videoId in structured logs, but exclude temporary URLs and bearer credentials.

Alerts should point to a user-visible failure mode. A growing age for records stuck in processing indicates a processing path problem. A jump in video_not_ready responses can instead mean clients are requesting downloads before respecting record state. A rise in repeated access refreshes may indicate that the access window and real transfer time no longer match. These signals lead to three different owners and three different fixes; one generic “video API errors” alert hides that distinction.

Test the boundary as a state machine. Cover queued, processing, ready, and failed; same-tenant and other-tenant callers; records with and without output metadata; redirect and stream decisions; retries after a client abort; and two simultaneous download requests. The invariant is crisp: no asset access call occurs until identity, ownership, and readiness all pass. Then add an end-to-end browser check using each supported container and codec combination, because a successful download is not proof of successful playback.

Limits and the final decision rule

The stable application endpoint is not automatically best for public, immutable media, and application streaming is not a free privacy upgrade. The former can add avoidable authorization work to a public delivery path; the latter moves bandwidth, backpressure, and long connections into the application tier. A fresh direct URL is also a poor contract for clients that need a link to work hours later without refresh logic.

Use a stable record for status and metadata. Use a separate, authenticated action for private bytes. At upload time, pre-process assets when nearly every downstream workflow needs the result; on demand, defer work when use is sparse or options can change. This keeps processing timing independent from access control — and lets each evolve without rewriting every client.

References

Top comments (0)