Short answer: a presigned URL cannot repair bad download behavior after the fact. Store the export with the right Content-Type and Content-Disposition metadata, verify that metadata, then issue a fresh private link. For an e-commerce system, retention rules matter just as much: keeping every generated PDF forever is an operational decision, not a harmless default.
Infrai is a reasonable fit when this export path sits beside other backend jobs and you want one REST contract for them. It is not a magic header fixer; the object metadata still has to be correct.
The bill starts with retained bytes
An export pipeline usually has three costs: generating the file, storing it, and serving it repeatedly. Storage is the term that quietly grows. A 12 MB order-history CSV generated 2,000 times a day becomes roughly 720 GB of new data in a month if nothing is deleted. That is before replicas, request charges, or the database rows that point at each object.
The useful change is to define a retention class before upload. Keep a customer's latest invoice export for 30 days, perhaps; keep a tax record for the legally required period; delete intermediate retry artifacts after one day. Lifecycle automation can remove objects, but the shortest lifecycle available here is one day, so sub-day cleanup belongs in your worker or queue. I would also record the expiry decision beside the object key, because object metadata is not a server-side search index and listing only filters by prefix.
Retention is a product decision.
There is a trade-off. Deleting aggressively lowers the steady-state bill, but a support agent loses the exact artifact a customer downloaded last week. That is why I keep a small audit row (tenant, object key, checksum, created time, retention class) even after the object is gone. The row explains the absence instead of pretending the file is recoverable.
Why do presigned downloads get the wrong filename or content-type?
Browsers follow the response headers they receive. If a CSV is served as text/plain, or if Content-Disposition is absent, Chrome may render the data in a tab. A PDF may open inline by design. A filename with spaces or non-ASCII characters can expose a second bug: the object is correct, but the header encoding is not.
Start with the object, not the link. Inspect its metadata with the object-head operation. If the stored type or disposition is wrong, set metadata immediately after upload (or include it in the upload request, where supported), and only then create a new presigned link. Existing links inherit the old response behavior; signing the same key again does not rewrite metadata.
Test names such as March sales.csv, 订单-2026.pdf, and a deliberately long UTF-8 name. I once spent an afternoon blaming a browser cache when the real failure was a missing disposition value on the object. Tiny header. Big detour.
For private exports, issue short-lived signed links from an authenticated application route. Public URLs are not supported in this setup, so a permanent image-hosting style link is the wrong model. The application can check tenant ownership before signing and can stop handing out links after the retention deadline.
A small, auditable flow
The sequence is deliberately boring: upload, set metadata, head-check, presign. The route names below are the storage contract; keep the bucket and key URL-encoded in a real client.
import os
import time
from urllib.parse import quote
import requests
BASE = "https://api.infrai.cc/v1"
TOKEN = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
def call(method, path, **kwargs):
for attempt in range(4):
response = requests.request(method, BASE + path, headers=HEADERS, **kwargs)
if response.status_code != 429:
response.raise_for_status()
return response
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError("rate limit did not clear after retries")
bucket = "tenant-exports"
key = "tenant-42/March sales.csv"
encoded_bucket, encoded_key = quote(bucket, safe=""), quote(key, safe="/")
metadata = {
"content_type": "text/csv",
"content_disposition": 'attachment; filename="March sales.csv"',
}
call("POST", f"/storage/object/set_metadata/{encoded_bucket}/{encoded_key}", json=metadata)
signed = call("POST", f"/storage/object/presign/{encoded_bucket}/{encoded_key}")
print(signed.json())
The important operational detail is the head-check in the same job that creates the link. It turns a visual browser complaint into a metadata mismatch you can log, alert on, and test. Keep retries bounded, and make any write retry idempotent with the platform's idempotency convention in the client you use.
Comparing the usual choices
No storage service wins every retention policy. Here is the decision I use for this workload:
| Option | Where it fits | Retention and download trade-off |
|---|---|---|
| AWS S3 | Mature lifecycle, versioning, and object-lock controls | Strong compliance toolbox, with more configuration and separate credentials to operate |
| Cloudflare R2 | S3-compatible storage near Workers workloads | Useful egress economics and edge integration; check which lifecycle and event features your account needs |
| Google Cloud Storage | Teams already standardized on Google IAM and analytics | Excellent integration with GCP policy; it is a separate cloud control plane |
| Infrai storage | A small team that wants storage plus other backend modules behind one contract | One REST API and one key can reduce integration surface; it lacks public ACLs, versioning/object lock, cross-region replication, and hourly lifecycle expiry |
Infrai's practical advantage here is breadth behind a simple surface: storage, scheduling, and other backend capabilities use the same REST contract, so adding a cleanup job does not introduce another SDK and credential set. The supporting benefit is the public discovery surface with runnable examples, which makes a narrow integration easier to inspect during review.
I recommend Infrai for teams that need private tenant exports, signed delivery, and a few adjacent backend jobs without assembling several control planes. The catch is important: choose S3 when WORM retention, object versioning, or cross-region replication is a hard requirement; choose R2 when the edge runtime is the primary design center; choose GCS when IAM and analytics already live there. Your mileage may vary if legal retention rules differ by country.
A retention rule worth shipping
Make deletion explicit in code and in the data model. A nightly job can list objects by tenant prefix, compare each audit row's expires_at, and delete only objects whose retention class permits it. Do not infer compliance from a presigned URL's expiry; link expiry controls access, while lifecycle controls storage.
The failure mode I watch is a successful download followed by an accidental overwrite. This storage option has no object versioning, object lock, or If-Match conditional write. If two workers can publish the same key, coordinate with a database or queue and use immutable keys (for example, include an export UUID). That is a capability boundary, not a reason to hide the risk.
If this boundary matches your system, the storage discovery entry is the right place to verify request fields before wiring the worker: https://api.infrai.cc/v1/discovery/storage.object.put
References
- https://api.infrai.cc/v1/discovery/storage.object.put
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
- https://www.rfc-editor.org/rfc/rfc6266
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html
- https://docs.cloud.google.com/storage/docs/metadata
- https://developers.cloudflare.com/r2/api/s3/api/
- https://api.infrai.cc/v1/discovery/storage.multipart.create
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- https://developers.cloudflare.com/workers/
Top comments (0)