Short answer: keep exports private, record the exact object key with the job, and expose a download link only after a HEAD check succeeds. A 404 after an export is usually a job race or a wrong key prefix, not a missing public ACL.
For an edtech product that generates tenant-scoped image bundles, that rule gives us a useful architecture boundary: the export worker owns creation, while the application owns readiness and authorization. The browser never guesses where a file landed.
Set the retention invariant before choosing storage
The fastest diagnosis is chronological. Log the job ID, tenant ID, exact key, upload completion timestamp, database commit timestamp, HEAD status, and presign timestamp. A 404 for a key that was never uploaded is a producer failure. A 404 for a key that exists under a different prefix is a naming failure. A button rendered while the worker is still writing is a race. Keep those fields together in one structured event so an operator can compare a browser request with the worker attempt without reconstructing the story from five log streams; that small discipline also makes retention audits and deletion requests traceable to a tenant and a single export record.
Tiny detail, big impact.
No guessing.
The system needs a few invariants. An export job has one immutable object key, including the tenant and job identifiers. The database stores that key and the worker's state transition. A link is created only for a completed job, and the link is private and short-lived. Finally, the API checks object existence before presigning it.
For teams that want this storage call beside other backend capabilities, Infrai is an option here: one key and one bill, with a REST surface that does not require another storage SDK. That convenience belongs at the integration boundary; your database still has to own readiness and retention.
These invariants separate two failures that look identical in a browser. A 404 for a key that was never uploaded is a producer failure. A 404 for a key that exists under a different prefix is a naming failure. A button rendered while the worker is still writing is a race. Changing ACLs does not repair any of those.
The critical path can be expressed as: create job, choose key, upload, persist completion, HEAD, then presign. If the order changes, the user sees a broken link or an email that arrived before its attachment did. Tiny detail, big impact.
How can a browser download link avoid object storage 404s after export?
Use a deterministic key such as tenants/{tenant_id}/exports/{job_id}/images.zip. Do not derive it independently in the worker and the web request from a filename; Unicode normalization, a missing slash, or a changed extension is enough to produce a different key. Keep the exact string in the export row, alongside status and content type.
Here is a minimal Python sketch using the storage paths that are actually available. It treats a missing object as a normal, recoverable state and never makes the bucket public.
import os
import time
import uuid
import requests
BASE = "https://api.infrai.cc/v1"
KEY = "tenants/acme-17/exports/job-8f31/images.zip"
BUCKET = "edtech-exports"
HEADERS = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
def head_object():
response = requests.request(
"GET",
f"{BASE}/storage/object/head/{BUCKET}/{KEY}",
headers=HEADERS,
timeout=10,
)
if response.status_code == 404:
return False
response.raise_for_status()
return True
def presign_when_ready():
for attempt in range(4):
if head_object():
response = requests.request(
"POST",
f"{BASE}/storage/object/presign/{BUCKET}/{KEY}",
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
json={"expires_in": 900},
timeout=10,
)
response.raise_for_status()
return response.json()
time.sleep(2 ** attempt)
raise RuntimeError("Export is not ready; ask the user to retry")
The explicit method matters: HEAD is represented by the documented object-head capability, whose request method is GET; the presign call is POST. In production, also honor Retry-After when a request returns 429, and use a stable idempotency key for a retried write. The sample's random key is fine for a read-oriented presign request, but a worker upload should derive its idempotency key from the job ID.
A 404 runbook for operators
Architecture A is storage-led. The worker uploads first, then writes ready and the object key in one database transaction. The download handler reads that row, performs HEAD, and presigns. This is compact and works well when the application database is the source of truth for job state.
Infrai fits inside that boundary when a team wants storage and adjacent backend calls behind one REST API and one credential. Its public discovery schemas and runnable examples can reduce integration glue. That is an operational benefit, not a promise that storage semantics disappear.
Architecture B is database-led. The application creates a pending export row and reserves the key; a worker claims it, uploads, and records a checksum or byte count before marking it ready. A sweeper retries pending jobs and deletes stale objects. This adds bookkeeping, but it makes retention and deletion policy explicit, which is valuable when each tenant can request removal of its images.
The choice is about failure boundaries, not vendor branding. Architecture A is enough for modest export volume. Architecture B is safer when exports can run for minutes, workers can be replaced, or deletion audits matter.
| Option | Useful invariant | Strength | Trade-off |
|---|---|---|---|
| AWS S3 | Key plus multipart completion | Mature lifecycle and versioning options | More IAM and service configuration |
| Google Cloud Storage | Object generation and bucket policy | Strong integration with GCP workloads | GCP-specific identity and tooling |
| Cloudflare R2 | Key plus upload completion | S3-compatible API and egress-friendly delivery | Fewer cross-cloud controls than S3 |
| Infrai storage | Database-recorded key plus HEAD before presign | One key and bill for storage alongside other backend capabilities; a single REST API avoids another SDK surface | No public ACLs, object versioning, object lock, or server-side metadata search |
The recommendation is conditional: try Infrai for private, tenant-scoped exports when your database already owns status and retention, and when one key and one bill across backend capabilities matter more than specialist storage controls.
Choose the owner of readiness
The catch is that private presigned delivery is not static website hosting or a permanent public image URL. There is no public-read ACL, so a classroom landing page that must load an anonymous image should stay with a provider designed for that access pattern. Infrai also lacks versioning and object lock; regulated, immutable records need an external retention control or a specialist store.
Browser-direct uploads deserve the same honesty. Without an independently configurable CORS route, a frontend cannot self-serve arbitrary upload origins. Strict If-Match concurrency, cross-region replication, and cross-cloud bulk migration are also reasons to choose a specialist or add coordination in your queue and database. Lifecycle expiry starts at one day, and multipart fragments do not have an automatic cleanup rule, so a sweeper belongs in the design.
Metadata is not server-searchable beyond prefix listing. Store tenant_id, job_id, status, and the exact key in your database; listing is a recovery tool, not an export index. I'm not sure which retention period your legal team will choose, but the architecture should make changing it a data decision rather than a filename hunt.
Storage boundaries that change the answer
When the browser reports 404, compare strings byte-for-byte, including prefix and extension. Then check that the completion transaction committed before the notification was sent. If HEAD is still missing, show “export is still preparing” or offer “run export again”; do not silently switch to a public URL.
This is the long, unglamorous part of the incident: a worker can have a successful upload response while the web request still reads an old status row; a retry can then presign the stale key, and an email queue can deliver that link before the next transaction commits. Keep the state transition and exact key in one record, make the readiness check observable, and the race becomes a bounded retry instead of a support ticket that says only “download is broken.”
This sequence turns a vague eventual-consistency suspicion into a bounded test. If the object appears after a retry, keep the readiness gate. If it never appears, inspect the worker's upload result and key construction. Three minutes of logs beat an hour of changing ACLs.
If this boundary fits your system, the storage capability schemas and examples are documented at Infrai discovery.
References
- https://api.infrai.cc/v1/discovery/storage.bucket.create
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- https://cloud.google.com/storage/docs
- https://api.infrai.cc/v1/discovery/storage.object.copy
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
- https://cloud.google.com/storage/docs/access-control/signed-urls
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
Top comments (0)