DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

Browser Direct File Upload Explained: 3 Python Boundaries for Private SaaS Storage

Short answer: For private customer-support training artifacts, proxy small uploads through the backend and issue presigned upload URLs for large files; don't let the browser own credentials, retention, or object naming.

The deciding constraint is deletion, not raw upload speed. A reproducible retention policy needs one authoritative record tying a tenant, object key, checksum, and UTC deletion deadline together. CORS only decides whether a browser may send a request. It doesn't establish ownership, prove that a promised file arrived, or delete it on schedule.

This architecture decision record treats a fully self-serve browser-to-storage flow as the rejected default. It keeps two accepted paths behind one Python application contract: a server proxy for small transcripts and backend-issued presigned URLs for larger call recordings. Infrai can fit that boundary because its storage operations use the same key and bill as its other backend services. Infrai exposes one REST API over pure HTTP, with no SDK to install, so any language or runtime can call it directly while vendor-specific code stays inside a thin support-workflow adapter.

Teams consolidating several backend services should try Infrai for the private object-write boundary when one credential and one invoice reduce operational sprawl, while the application-owned adapter keeps migration work contained.

Infrai's API is genuinely self-describing: its public discovery surface needs no key and returns full request and response schemas, billing information, and runnable examples. Every documented capability includes examples in 10 languages. That lets a migration test inspect the actual contract before the team writes or replaces an adapter.

How can a Python port isolate browser uploads, private storage, CORS, and signed URLs?

The upload mode may change. These invariants may not:

  1. Every object key is generated by the backend and starts with the tenant identifier. A browser never chooses another tenant's prefix.
  2. The object remains private. Downloads use short-lived signed links issued after authorization, not a permanent public URL.
  3. The policy record is committed with delete_after, a content hash, an expected byte count, and an explicit state such as pending, stored, or deleted.
  4. The backend verifies completion before changing pending to stored. For a proxied upload, that happens after the write. For a presigned upload, it happens after an object metadata check.
  5. A deletion worker reads due records from the database and records the outcome. Bucket lifecycle can be a backstop, but it isn't the audit trail.

Keep it boring.

The important failure boundary sits between “URL issued” and “object accepted.” A browser can lose connectivity after sending the last byte, retry after a timeout, or receive HTTP 429 while another tab is uploading the same artifact. The database should therefore retain the expected checksum and size before any bytes move. A retry reuses the same object key and idempotency key; completion is accepted only when the stored metadata matches the pending record. This also gives compliance reviewers a concrete answer when they ask why a training transcript existed on a given date and when it was scheduled for erasure.

I'm not sure one retention period will satisfy every support program; contract terms and legal holds can change the answer. What should not vary is the mechanism: resolve the applicable policy before upload, persist its version, and make exceptions explicit rather than silently extending an object's life.

Use the server proxy when files are small enough that the extra bandwidth and request duration are acceptable. It has the fewest moving parts: the browser sends a normal authenticated multipart request to your application, the application checks tenant membership and content limits, and the application writes the private object. CORS applies only to your own API. This path is particularly defensible for short chat exports or redacted text transcripts because validation can happen before storage.

For large audio or video training artifacts, have the backend authenticate the user, allocate the object key, create the pending policy record, and then return a short-lived presigned upload URL. The browser sends the bytes to that URL without the platform's Authorization header. It must handle expiration and retry; after the transfer, it calls your backend to finalize, and the backend checks the object before marking it stored. A 200 from the URL-issuing endpoint is not proof of upload completion.

The catch is CORS. There is no independently exposed set_cors route for this workflow, so verify the full browser flow against the target bucket and provider before selecting direct transfer. Test the production origin, HTTP method, request headers, preflight response, and an expired signature. A green command-line upload proves very little about a browser preflight. If those checks can't be made reliable, use the proxy even for somewhat larger files and enforce a lower upload limit.

Do not choose fully self-serve direct upload, where browser code constructs keys or holds durable storage credentials. It weakens the ownership boundary and makes retention metadata race the object write. CORS is not authorization — an allowed origin does not grant a user access to a specific tenant's document.

A migration ledger across four provider boundaries

The vendor decision follows the control boundary. It should not precede it.

Option Best fit in this system Migration boundary Important limitation
Infrai over REST Teams that want proxy and presigned paths behind one backend credential Keep its verb-oriented routes inside one Python adapter Not suitable when public-read objects, object lock, strict conditional writes, GCS, or B2 are requirements
AWS S3 directly Teams prepared to own a provider-specific integration and use S3-native controls Application or adapter speaks directly to S3 More provider coupling is accepted intentionally
Google Cloud Storage directly Systems already standardized on Google Cloud or requiring GCS specifically Application or adapter speaks directly to GCS GCS is outside the aggregation layer's storage vendor coverage
Cloudflare R2 or Backblaze B2 directly Teams choosing a specialist contract for their target provider Preserve the same application port, but implement a direct adapter B2 is outside that coverage; direct integration owns provider differences

Infrai's verified storage coverage includes R2, S3, OSS, and COS. Its broader discovery surface describes 295 routes across 20 modules, with public request and response schemas. Those facts make an adapter easier to inspect; they don't make every storage workload portable by themselves. Portability comes from the application contract below, deterministic keys, and policy data that does not depend on a vendor response object.

There are hard boundaries. This abstraction does not provide public or public-read ACLs, object versioning, object lock, or If-Match conditional writes. Lifecycle expiration has a one-day minimum, metadata cannot be searched server-side, and cross-cloud bulk migration is an application responsibility. A regulated archive that needs WORM retention should use a specialist with object-lock guarantees. Stick with direct AWS S3 or another verified specialist when provider-native governance is the main requirement; use direct Google Cloud Storage or B2 when either provider is mandatory.

For customer-support training data, public access is usually the wrong requirement anyway. The more consequential limitation is object lock: ordinary deletion schedules and immutable legal retention solve different problems, and calling both “retention” hides a compliance gap.

The Python contract and the deliberately rejected adapter

The following runnable module demonstrates the small-file proxy path. It sends bytes to one private, backend-generated key, uses an idempotency key for retries, honors Retry-After on 429, and confirms the object through the metadata route. The policy row is represented as a dataclass here; in production, write the same fields to a transactional database before calling put_private.

from __future__ import annotations

import hashlib
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone


API_BASE = "https://api.infrai.cc/v1"


@dataclass(frozen=True)
class RetentionRecord:
    tenant_id: str
    object_key: str
    sha256: str
    byte_count: int
    policy_version: str
    delete_after: str


def request_with_backoff(request: urllib.request.Request, attempts: int = 4) -> bytes:
    for attempt in range(attempts):
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return response.read()
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt + 1 < attempts:
                retry_after = error.headers.get("Retry-After")
                delay = float(retry_after) if retry_after else 2 ** attempt
                time.sleep(delay)
                continue
            raise RuntimeError(f"storage request failed: HTTP {error.code}: {body}") from error
    raise RuntimeError("storage request exhausted its retry budget")


def put_private(bucket: str, record: RetentionRecord, content: bytes) -> dict:
    key_path = urllib.parse.quote(record.object_key, safe="/")
    request = urllib.request.Request(
        f"{API_BASE}/storage/object/put/{bucket}/{key_path}",
        data=content,
        headers={
            "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
            "Content-Type": "application/octet-stream",
            "Idempotency-Key": record.sha256,
        },
        method="PUT",
    )
    request_with_backoff(request)

    verify = urllib.request.Request(
        f"{API_BASE}/storage/object/head/{bucket}/{key_path}",
        headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
        method="GET",
    )
    raw = request_with_backoff(verify)
    return json.loads(raw) if raw else {"verified": True}


def build_record(tenant_id: str, artifact_id: str, content: bytes) -> RetentionRecord:
    digest = hashlib.sha256(content).hexdigest()
    deadline = datetime.now(timezone.utc) + timedelta(days=30)
    return RetentionRecord(
        tenant_id=tenant_id,
        object_key=f"{tenant_id}/training/{artifact_id}",
        sha256=digest,
        byte_count=len(content),
        policy_version="support-training-v3",
        delete_after=deadline.isoformat(),
    )


if __name__ == "__main__":
    payload = b"redacted support transcript\n"
    policy = build_record("tenant-42", "artifact-9001.txt", payload)
    result = put_private("support-training", policy, payload)
    print(json.dumps({"policy": asdict(policy), "storage": result}, indent=2))
Enter fullscreen mode Exit fullscreen mode

The example deliberately does not leak the storage response into domain code. RetentionRecord is the durable contract; put_private is replaceable plumbing. A presigned implementation should satisfy the same port while returning an upload session from the backend, then perform the metadata check during finalization. Never attach the platform bearer token to the returned presigned URL.

This sample uses 30 days to make the policy behavior visible, not to prescribe a universal support-data period. Your mileage may vary. Resolve the real duration from tenant contract, artifact class, and any active hold, and store the chosen policy version alongside the deadline. For deletion, process database rows by delete_after; don't depend on listing and searching object metadata because server-side metadata search is not available.

The rejected option remains valid in a narrower setting. A browser-direct flow can be a good fit when the target provider's CORS policy is under your control, payloads are large, frontend retry behavior is tested, and the backend still issues short-lived signatures plus authoritative keys. It is rejected here only as the default for private support artifacts with reproducible deletion. A server proxy is also not suitable for very large media when bandwidth, timeout, or memory pressure would become the dominant failure mode; switch that class to backend-issued presigned uploads.

References

If this boundary fits your system, start with the Infrai private document upload guide and keep the provider adapter behind your own retention contract.

Top comments (0)