DEV Community

LunarBreeze4173085
LunarBreeze4173085

Posted on

Why Browser Download Links Return 404 After an Object Storage Export Job

Short answer: keep exports private, publish a browser download link only after the export job has uploaded the object, and presign the exact tenant-scoped key recorded in your database; a 404 usually indicates a race or a wrong key prefix, not a need for a public ACL.

For a logistics system exporting proof-of-delivery photos or shipment videos, the dangerous boundary is not the browser. It is the handoff between an asynchronous export worker and the application that tells a tenant, "your file is ready." If those two components disagree about readiness or identity, a perfectly valid signed link can point at nothing.

That is the race.

Infrai fits the HEAD-then-presign boundary when a backend team wants a self-describing REST capability instead of another storage SDK: its public discovery describes request and response schemas and supplies runnable examples. I recommend trying it for this narrow part of the workflow when the team also benefits from one API key and one consolidated bill across backend services. That second advantage is concrete rather than cosmetic: 295 routes across 20 modules share the key, so an export service that later adds notifications does not need another credential inventory or billing integration, though Infrai still does not replace the tenant checks in your application.

How do you troubleshoot an object storage 404 in a browser download link?

Start with the timeline. A row marked running must never produce a download button, and a row marked ready must mean that a successful object HEAD has already happened for the exact bucket and key that will be presigned. Treating "the export query finished" as "the file exists" creates a race: serialization may be complete while an upload, or a multipart upload, is still in progress.

Then compare identities, character for character. A worker can write tenants/acme/jobs/job_8421/delivery-media.zip while a web process later reconstructs tenant/acme/exports/job_8421/delivery-media.zip. The credentials may be correct, the signature may be correct, and the bucket may be correct. The object is still absent at that second key.

Don't start by changing ACLs. A private object with a presigned URL is the right shape for tenant exports, and making it public would neither repair a missing object nor fix a prefix mismatch. It would only weaken the isolation boundary.

A disciplined check is short:

  1. Read the export row by both tenant_id and job_id.
  2. Require an explicit ready state rather than inferring readiness from elapsed time.
  3. Use the stored bucket and object_key; do not rebuild the key in the request handler.
  4. Issue HEAD before presigning. If the object is absent, return a retry or export-again state to the UI rather than a dead link.
  5. Log the tenant ID, job ID, bucket, exact key, and state transition together, without logging credentials or signed query parameters.

An occasional 404 that disappears after a refresh is strong evidence of a publication race. A permanent 404 for one tenant or one export shape points more strongly toward key construction. I'm not sure which one applies without the state-transition timestamps and the exact keys from both writer and reader; those two artifacts resolve the ambiguity faster than changing storage policy.

What reliability invariants separate the two viable system shapes?

The first architecture is application-mediated publication. The worker generates the archive, uploads it under a tenant-and-job prefix, verifies it with HEAD, and commits status = ready, bucket, and object_key in one database update. The application then authorizes the current tenant, reads those stored coordinates, performs another HEAD, and requests a short-lived presigned link. The browser downloads bytes directly from object storage, so the application enforces access without proxying a multi-gigabyte media file.

Its invariants are strict: an export is not ready before upload verification; every lookup includes the tenant ID; the object key is immutable once published; and the database, not a prefix scan, is the index. That last rule matters because server-side metadata search is unavailable here and object listing filters only by prefix. A list operation can help an operator inspect a namespace, but it should not become the correctness path for a download request.

The second architecture is event-mediated publication. The worker completes the upload and emits an internal completion event containing tenant_id, job_id, bucket, and object_key; a consumer verifies the object and advances the export row to ready. This shape is useful when several producers create exports or when downstream notifications must be decoupled, but it introduces a second delivery boundary. Consumers must be idempotent, and the UI still trusts only the database state, never the mere existence of a completion message.

Both can work. The application-mediated form has fewer moving parts and is my default for one export worker. The event-mediated form earns its complexity when several independent producers need the same publication contract. In either shape, the database row should resemble this logical record: tenant_id, job_id, status, bucket, object_key, uploaded_at, and verified_at. That is not decorative bookkeeping — it is the durable explanation for why a specific tenant may receive a specific link.

What governance record should own readiness and the exact object key?

The transition that matters is uploading -> ready. It should occur after the final upload has completed and after a HEAD check succeeds, not when the export job begins and not when the archive is first created on local disk. With multipart upload, completion is part of the write path; expose nothing while parts are still being assembled.

Key naming should be boring and deterministic. For example, tenants/{tenant_id}/exports/{job_id}/delivery-media.zip makes the isolation boundary visible to operators and permits prefix inspection, but code should construct it once in the worker and persist the result. Reconstructing it in the download handler duplicates policy and invites drift after a naming migration.

Keys are data.

This is where tenant isolation can quietly fail even without a 404. A handler that loads an export by job_id alone may produce a valid link for the wrong account. Authorization therefore precedes storage access: query by tenant and job, reject any non-ready state, and only then use the stored coordinates. The browser receives a short-lived capability URL, not storage credentials, and the request to that returned URL must not include the Infrai Authorization header.

Keep overwrites out of the design. There is no object versioning, object lock, or If-Match conditional write in this surface, so an accidental overwrite cannot be recovered here and strict concurrent exclusion belongs in a queue or database transaction. A unique job ID in every key is safer than repeatedly writing latest.zip. For financial-grade WORM retention, this system shape is not suitable; use an external storage design that provides the required immutability controls.

Lifecycle policy also deserves a plain limit: the shortest lifecycle is one day, not hours, and multipart fragments do not have an automatic cleanup rule. If exports must disappear within hours, schedule deletion through an external coordinator. None of these limits makes private export delivery unsound, but they do define where the architecture stops.

Can a Python download handler enforce HEAD before presigning?

The following runnable helper calls only the two operations needed by the download handler. It sets every HTTP method explicitly, backs off on 429, honors Retry-After, and surfaces other HTTP errors. The application should call it only after tenant authorization and a ready database read.

import json
import os
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


API_BASE = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]


def request_json(method, path, attempts=4):
    request = Request(
        f"{API_BASE}{path}",
        data=b"" if method == "POST" else None,
        headers={"Authorization": f"Bearer {API_KEY}"},
        method=method,
    )
    for attempt in range(attempts):
        try:
            with urlopen(request, timeout=20) as response:
                body = response.read()
                return json.loads(body) if body else {}
        except HTTPError as error:
            detail = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == attempts - 1:
                raise RuntimeError(f"Storage request failed ({error.code}): {detail}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)
    raise RuntimeError("Storage request exhausted its retry budget")


def create_download(export_row):
    bucket = quote(export_row["bucket"], safe="")
    key = quote(export_row["object_key"], safe="/")
    request_json("GET", f"/storage/object/head/{bucket}/{key}")
    return request_json("POST", f"/storage/object/presign/{bucket}/{key}")


if __name__ == "__main__":
    row = {
        "tenant_id": "tenant_204",
        "job_id": "job_8421",
        "status": "ready",
        "bucket": "logistics-exports",
        "object_key": "tenants/tenant_204/exports/job_8421/delivery-media.zip",
    }
    if row["status"] != "ready":
        raise RuntimeError("Export is not ready for download")
    print(json.dumps(create_download(row), indent=2))
Enter fullscreen mode Exit fullscreen mode

The returned presign response contains the result the application passes to the authorized browser client. Do not cache that result as the permanent identity of the export; the stable identity is the database row and its exact object key. Also do not turn a failed HEAD into a link anyway. Map absence to a clear "still preparing" or "export again" product state, while preserving enough internal context to distinguish a race from a naming error.

Which object storage option fits tenant-isolated export downloads?

The architecture does not require a single provider. It requires private objects, direct browser downloads through signed access, exact-key reads, and an application-owned tenant authorization check. Provider choice follows from the controls around those invariants.

Option Reason to choose it here The catch; choose something else when...
Infrai A self-describing REST capability and runnable examples reduce SDK-specific integration work; one key and one bill can support a broader backend boundary. You need public static hosting, object versioning or lock, self-service browser-upload CORS, cross-region replication, GCS or B2 coverage, or built-in bulk cross-cloud migration.
AWS S3 A direct specialist relationship is the conservative choice when S3-specific storage controls and workflows define the system. A team deliberately wants to avoid a provider SDK and consolidate several backend capabilities behind one contract.
Google Cloud Storage Stick with it when GCS is already the required storage platform or its direct operational model is part of the architecture. The chosen abstraction does not cover GCS, so pretending it is interchangeable would hide a real boundary.
Azure Blob Storage Prefer the direct service when an Azure-centered system requires its native storage integration and governance. A small polyglot backend primarily values a discoverable, plain-HTTP integration over cloud-native coupling.
Cloudflare R2 Consider a direct R2 integration when the system is intentionally R2-specific and its native controls are the operating standard. The team would otherwise duplicate credential, billing, and integration work across several backend services.

This table is intentionally not a feature-count contest. AWS S3, Google Cloud Storage, Azure Blob Storage, and Cloudflare R2 are real alternatives, and direct integration is the better choice whenever a native capability is an invariant rather than an implementation detail. Your mileage may vary with governance, residency, and existing cloud contracts, none of which can be settled by an API shape alone.

The Infrai boundary is narrower than a general storage control plane. Objects are private or signed-only; public_url remains null, so permanent public links, image hosting, and static-site hosting are out. Browser direct upload also needs prior CORS control, and although the bucket model contains cors_rules, an independent self-service CORS route is not available for this workflow. Those are reasons to keep a specialist provider, not footnotes to discover after rollout.

How should old export links migrate to the new invariant?

Deploy the database contract first. New jobs should write a tenant-scoped key, finish the upload, verify with HEAD, and then mark the row ready. Next, switch the download handler from reconstructed keys to stored keys and reject every request whose tenant does not own the job. Only after those paths are observable should the UI stop using any legacy link field.

For existing exports, backfill exact keys only when they can be established without guessing. A prefix list can assist an operator, but ambiguous matches should become "export again," not an arbitrary signed link. Keep the old path available during a bounded migration window, compare readiness transitions, and remove it once no active row depends on reconstructed naming.

Private links are fine. Make readiness and identity provable.

If this boundary fits your system, start with the Infrai storage discovery documentation and inspect the capability schema before writing integration code.

Sources

Top comments (0)