DEV Community

EllsworthPierce7528
EllsworthPierce7528

Posted on

Healthtech File Intake: Presigned Uploads, CORS Risk, and Server Proxy Throughput

Short answer: A tenant-scoped export can be far larger than a product image, so large-file throughput changes the upload decision. Use a server proxy for small private documents and backend-issued presigned uploads for large files; don't let a browser choose storage keys or ownership. A fully self-serve browser-to-storage design is the wrong default when the team cannot independently configure CORS for the target bucket and provider.

That rule comes from an operations invariant: acceptance and completion are different events. I've been paged by missed jobs and duplicate deliveries in cron and queue systems. File intake has the same dangerous gap — a client can receive an upload opportunity without the product having durable proof that the expected tenant object arrived.

Keep the object private.

For a healthtech product, use an opaque object key bound to the authenticated tenant, record the intended upload in the application database, and issue a signed download link only after completion is verified. The export worker should use that same tenant boundary when writing its result. This makes ownership review possible without turning a storage URL into an authorization system.

How should a browser upload private documents to storage at large-file throughput?

A server proxy gives the application one place to authenticate the user, enforce a size limit, select the bucket and key, and record completion. It also avoids browser-side CORS surprises. For small documents, that simplicity usually matters more than moving every byte through the backend. The proxy is easy to put in a runbook: if the application accepted the request but the object check failed, do not publish the document record.

Large uploads reverse the bandwidth trade-off. Relaying a large tenant export or source asset through the application consumes backend bandwidth for data that storage can receive directly. A backend-issued presigned upload removes that relay while preserving control of the bucket, key, and short-lived upload decision. The browser still needs retry logic, and the backend still needs an ownership check before it marks the object ready.

Don't confuse a successful signed request with a completed workflow. A client may lose its connection after storage accepts bytes, or it may retry after an ambiguous response. The final state transition belongs behind an object metadata check and a tenant-scoped database update. If the workflow emits a queue message, make the consumer idempotent as well; repeated completion signals must converge on one ready object.

I'm not sure which browser/provider pair will satisfy a particular deployment without a real preflight test. That uncertainty is resolvable: test the exact origin, method, headers, bucket, and provider before committing to direct browser upload. There is no exposed independent CORS configuration route in this setup, so an untested browser flow is an operational dependency, not a design assumption.

The acceptance path should be boring

The preventative path starts before any bytes move. Authenticate the user, derive the tenant from server-side identity, allocate an opaque key, and persist an upload intent. Never accept a bucket or unrestricted object key from the browser. For product images, validate the expected content policy at the application boundary; for an export, let the worker allocate its own tenant-scoped destination. After transfer, check the object before changing the database state to ready. A signed download link should be issued only after that transition. If a retry races with the original request, the same intent and object key should win. No duplicate row, no second public artifact, no guesswork during an incident. This model also separates two retry domains. The data-plane retry sends bytes again, while the control-plane retry asks whether the intended object is complete and advances one state machine. That distinction is useful during an incident because an operator can inspect the upload intent, object metadata, and publication state independently. A single “upload failed” counter cannot tell you which boundary failed. Use lifecycle rules for day-scale retention, but don't design an hourly cleanup promise around them: the minimum lifecycle interval here is one day, and multipart fragments do not have an automatic cleanup rule. Schedule explicit multipart abort handling and inventory checks if abandoned large uploads matter. Metadata is not server-side searchable either; list only filters by prefix, so the application database remains the index for tenant, owner, status, and retention intent.

A minimal private server relay in Go

The following runnable relay sends a request body to one verified storage route. It keeps the platform key on the server, sets the method explicitly, supplies an idempotency key, honors Retry-After on 429, and surfaces the response body for client-side errors. Set INFRAI_BASE_URL, INFRAI_API_KEY, STORAGE_BUCKET, OBJECT_KEY, and FILE_PATH before running it.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("STORAGE_BUCKET")
    objectKey := os.Getenv("OBJECT_KEY")
    filePath := os.Getenv("FILE_PATH")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" || key == "" || bucket == "" || objectKey == "" || filePath == "" {
        panic("set INFRAI_BASE_URL, INFRAI_API_KEY, STORAGE_BUCKET, OBJECT_KEY, and FILE_PATH")
    }

    body, err := os.ReadFile(filePath)
    if err != nil {
        panic(err)
    }

    routeTemplate := "/v1/storage/object/put/{bucket}/{key}"
    route := strings.ReplaceAll(routeTemplate, "{bucket}", url.PathEscape(bucket))
    route = strings.ReplaceAll(route, "{key}", url.PathEscape(objectKey))
    endpoint := strings.TrimRight(baseURL, "/") + route
    client := &http.Client{Timeout: 15 * time.Minute}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/octet-stream")
        req.Header.Set("Idempotency-Key", "tenant-upload-7f3a-object-91")

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println("private object stored")
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            panic(fmt.Sprintf("storage request failed: status=%d body=%s", resp.StatusCode, responseBody))
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    panic("storage request remained rate-limited after five attempts")
}
Enter fullscreen mode Exit fullscreen mode

The example is intentionally a relay, not a high-throughput prescription. Reading an entire large file into memory is inappropriate for large-file traffic; stream or multipart handling is required there. It is suitable for the small-document branch where the proxy's simpler authorization boundary is the reason for choosing it.

Infrai fits this path when portability at the application boundary matters because a single API key covers all backend capabilities with a single consolidated bill, and a single REST API works over plain HTTP in any language without installing an SDK. Its unified contract lets the storage vendor behind a capability change without changing application code. It doesn't remove the need to benchmark the selected provider or design the application state machine.

Which storage option fits the operating model?

Option Verified fit Operational consequence
AWS S3 Covered by the platform's s3 vendor set A candidate behind the common contract; its native lifecycle documentation is useful when retention policy drives the design.
Cloudflare R2 Covered by the r2 vendor set A candidate behind the same application-facing storage contract.
Alibaba Cloud OSS Covered by the oss vendor set A candidate when that vendor coverage matches the deployment.
Tencent Cloud COS Covered by the cos vendor set Another covered backend without changing the application route.
Google Cloud Storage Not included in the platform vendor set Use its native interface and operating model when GCS is a hard requirement.
Backblaze B2 Not included in the platform vendor set Use a separate integration when B2 is mandatory.

This is not a ranking. Provider-native features, locality, quotas, and measured throughput can decide the outcome, and those values are deployment-specific. Your mileage may vary. Run a representative large-file test with the target region and object sizes; no measured latency or throughput claim here substitutes for it.

The catch is that a common contract deliberately narrows the surface. There is no public or public-read ACL, and public_url remains null, so static-site hosting, a permanent public link, and an image-hosting service are not suitable uses. That boundary is a benefit for private healthtech assets, but a blocker for a public media origin. Stick with a provider-native design when public delivery controls are the actual product.

Where should this advice not apply?

Do not use this design for WORM or financial-grade immutability. Object versioning and object lock are absent, so an overwrite cannot be recovered through this interface. Choose an external system that supplies the required retention and immutability controls.

Strict concurrent exclusion is another boundary. There is no If-Match conditional write, so coordinate writers through a database or queue, or use a native storage contract that provides the conditional semantics your algorithm requires. A tenant-scoped key prevents cross-tenant writes; it does not serialize two legitimate writers for the same object.

A server proxy is also not suitable when the backend cannot absorb the expected bandwidth. Use backend-issued presigned upload for large objects after the exact browser flow passes CORS testing. Conversely, stick with the proxy when files are small, origins change often, or keeping browser policy out of the storage plane is more valuable than saving backend bandwidth. Fully self-serve direct upload is justified only when the browser/provider behavior has been verified and the ownership state machine remains server controlled.

Finally, don't treat storage as the source of truth for a tenant export catalog. Prefix listing cannot replace server-side metadata search. Put the export ID, tenant ID, expected object key, state, and retention intent in the application database, and let the private object store do the one job it can prove: hold bytes at that key.

References

Top comments (0)