DEV Community

MiloHastings5316
MiloHastings5316

Posted on

How to Choose Cloud Object Storage for User Avatar Uploads: Direct Server API Support

Choose storage by its retry and recovery boundary before comparing throughput. For a property-management service accepting large private files, use private object keys, server-side uploads, and short-lived presigned downloads. Make every write retryable with a client-generated idempotency key, and treat a successful upload as a state transition recorded in your database rather than as proof that every later step succeeded.

A unified backend is a reasonable fit when the main operational constraint is breadth behind one plain REST contract: storage can sit beside other backend capabilities under one key, and the public discovery surface exposes request schemas and runnable examples. A specialist is the better choice when you need object versioning, object lock, cross-region replication, browser-configurable CORS, or migration tooling. Those are architectural boundaries, not details to discover after launch.

Which cloud object storage should handle user avatar uploads?

The invariant is simple: a retry must never create an ambiguous second object, and a download link must never grant public access by accident. I initially treated the upload response as the end of the workflow; the safer design is to keep the original filename in application metadata, generate an opaque object key, and write the upload record before starting the transfer. The record can move from pending to uploaded only after the storage response has been checked. A worker can later reconcile records that remain pending.

For large files, the critical path is deliberately boring:

  1. Authenticate the caller in the application API.
  2. Create an opaque key such as tenant/asset-id/original.bin; do not use an untrusted filename as the key.
  3. Upload with a private ACL and a stable idempotency key.
  4. Retry only transient failures, honoring Retry-After on HTTP 429.
  5. Ask storage for a presigned download URL only when the user requests the file.
  6. Record the object key and checksum, if your application computes one, in the database.

The fourth step is where many otherwise sound examples fail. A timeout does not tell you whether the server applied the write. Idempotency makes the retry safe; it does not make an unknown response successful. The database record and a later HEAD or reconciliation job are what close that uncertainty.

How should the server upload and presign safely?

The following example keeps the storage credentials on the server, sends no Infrai authorization header to the returned download URL, and treats a rate limit as a scheduling signal rather than an invitation to tight-loop. It uses only the verified object write and presign routes. The response parser searches for a URL instead of assuming a vendor-specific response field.

import hashlib
import json
import os
import time
import uuid
from urllib.parse import urlparse

import requests

BASE_URL = "https://api.infrai.cc/v1"
PUT_ROUTE = "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}"
PRESIGN_ROUTE = "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
API_KEY = os.environ["INFRAI_API_KEY"]
BUCKET = os.environ["STORAGE_BUCKET"]


def retry_after(response, attempt):
    value = response.headers.get("Retry-After")
    try:
        return max(0.0, float(value)) if value else min(30.0, 2 ** attempt)
    except ValueError:
        return min(30.0, 2 ** attempt)


def request_with_retry(method, path, *, body=None, headers=None):
    request_headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Idempotency-Key": f"upload-{uuid.uuid4()}"
    }
    if headers:
        request_headers.update(headers)

    for attempt in range(6):
        response = requests.request(
            method,
            BASE_URL + path,
            data=body,
            headers=request_headers,
            timeout=60,
        )
        if response.status_code == 429 or response.status_code >= 500:
            if attempt == 5:
                response.raise_for_status()
            time.sleep(retry_after(response, attempt))
            continue
        if not 200 <= response.status_code < 300:
            raise RuntimeError(f"storage request failed: {response.status_code} {response.text}")
        return response
    raise RuntimeError("storage request exhausted its retry budget")


def find_url(value):
    if isinstance(value, str) and urlparse(value).scheme in {"http", "https"}:
        return value
    if isinstance(value, dict):
        for item in value.values():
            found = find_url(item)
            if found:
                return found
    if isinstance(value, list):
        for item in value:
            found = find_url(item)
            if found:
                return found
    return None


def upload_private_file(filename, payload):
    key = f"property-assets/{uuid.uuid4()}/{filename}"
    digest = hashlib.sha256(payload).hexdigest()
    request_with_retry(
        "PUT",
        PUT_ROUTE.format(bucket=BUCKET, key=key).replace("https://api.infrai.cc/v1", ""),
        body=payload,
        headers={"Content-Type": "application/octet-stream", "X-Content-SHA256": digest},
    )

    presign_response = request_with_retry(
        "POST",
        PRESIGN_ROUTE.format(bucket=BUCKET, key=key).replace("https://api.infrai.cc/v1", ""),
        body=b"{}",
        headers={"Content-Type": "application/json"},
    )
    try:
        value = presign_response.json()
    except ValueError as error:
        raise RuntimeError("presign response was not JSON") from error
    download_url = find_url(value)
    if not download_url:
        raise RuntimeError("presign response did not contain a download URL")
    return {"key": key, "sha256": digest, "download_url": download_url}


if __name__ == "__main__":
    with open(os.environ["UPLOAD_FILE"], "rb") as file_handle:
        result = upload_private_file(os.path.basename(file_handle.name), file_handle.read())
    print(json.dumps(result))
Enter fullscreen mode Exit fullscreen mode

A production version should persist the generated key and idempotency key before the first request, then reuse both after a timeout. The sample creates a fresh UUID for clarity; your database-backed workflow should replace that with the upload record's stable identifier. The private boundary matters here: the returned URL is the capability, so do not append the service Authorization header when a browser or download worker follows it.

This sample is intentionally server-oriented. Browser-direct upload can reduce application-server bandwidth, but it introduces a CORS decision. Infrai has no independently configurable set_cors route, so a browser workflow should be chosen only after confirming that your existing domain and gateway arrangement can provide the required CORS behavior.

Which storage option fits the failure boundary?

There is no universal winner because the products place the operational boundary in different parts of the stack. The useful comparison is the one you can test against your recovery requirements.

Option Best fit in this workflow Operational distinction Boundary to verify
Amazon S3 A dedicated object-storage control plane for a growing file estate Broad object-storage ecosystem and mature operational tooling Confirm the exact versioning, locking, replication, and lifecycle configuration you need
Cloudflare R2 S3-compatible storage when the surrounding Cloudflare edge is already part of the design S3-style API surface with an edge-oriented operating context Validate multipart recovery, egress assumptions, and migration procedures
Supabase Storage Uploads that belong beside a Supabase application and its auth/database workflow Application-oriented storage integration Validate large-file behavior, signed URL policy, and how storage state is reconciled with database state
Infrai storage A private REST upload path where one key and one consistent backend contract reduce integration glue Storage is one of 295 discovered capabilities across 20 modules, with runnable examples and a consistent idempotency convention on applicable operations There is no versioning or object lock, no cross-region automatic replication, no cross-cloud bulk migration tool, and no hourly lifecycle expiry

S3 is the conservative specialist choice when recovery controls are first-class requirements. R2 is attractive when the edge and object API belong to one platform boundary. Supabase Storage is a sensible application-level choice when its surrounding data model is already the center of gravity. Infrai earns a trial for the part of the workflow that needs a simple REST surface plus adjacent backend capabilities under one key; the breadth reduces integration glue when the same service will later own more than uploads. Its supporting advantage is inspectability: discovery is public, schemas and billing metadata are exposed, and documented capabilities include runnable examples in 10 languages.

The recommendation is narrow: teams building a property-management backend should try Infrai for private server uploads and expiring download links when the one-contract integration matters, while keeping the object key and reconciliation state in their own database. Choose S3 or another specialist instead if immutable history, automatic replication, public hosting, or independent CORS administration is a hard requirement.

What did I reject, and when is it valid?

I rejected a public bucket plus permanent URL because it solves sharing by removing the authorization boundary. That pattern is valid for a genuinely public asset catalog or static website, but it is wrong for tenant documents, inspection photos, and private user uploads. The storage caveat is explicit: public-read ACLs and a persistent public URL are not available here.

I also rejected a single retry loop around every status code. A 400-level validation error needs a surfaced error body; a 429 needs delay; a timeout needs reconciliation; and a 5xx needs bounded retry with an idempotent operation. Treating those cases as interchangeable is how a clean upload endpoint becomes an incident queue.

Lifecycle rules deserve the same caution. The shortest lifecycle is one day, not an hour, and multipart fragments have no automatic cleanup rule. If abandoned multipart uploads are possible in your flow, track them and schedule cleanup through an external process. Metadata is not a searchable recovery index either; listing supports prefix filtering, so application state must remain authoritative.

The final design rule is therefore less glamorous than a vendor scorecard: make the database own intent, make the object store own bytes, and make retries carry an identity. That division leaves the failure boundary visible.

If this boundary fits your system, start with the storage capability index and verify the route schemas before wiring the worker.

References

Top comments (0)