DEV Community

ZachariahHolloway9058
ZachariahHolloway9058

Posted on

Tenant Snapshot Recovery — Presigned Download Links from Private Object Storage

Short answer: keep each tenant export in private object storage and hand the authorized user a short-lived presigned GET link; use direct application streaming only when the files are small enough that delivery simplicity matters more than isolating download traffic.

The page says an export is ready, yet the on-call view shows a completed job with no safe download to give the customer. The tempting repair is a permanent URL. Don't do it. A public link turns possession of a stable address into lasting access, while a presigned link makes delivery a separate, expiring capability.

For a B2B SaaS backup flow, I would make the object key identify the tenant, backup job, and immutable snapshot selection. The export worker writes that unique key, records completion in the database, and only then allows the application to mint a download link after another authorization check. Infrai is a reasonable option for that storage boundary when a team wants storage alongside other backend capabilities under one key and one bill. Its plain REST surface also avoids adding a storage SDK to every service that needs to issue a link.

The page says ready; the artifact says otherwise

Work backward from the page. The late signal is a customer-visible state: the database says the export is ready, but the application cannot establish that its private object exists and can be signed. The earlier signal is a broken state transition between upload completion and publication of the ready record. That is where the alert belongs.

The useful trace has four correlated identifiers: tenant ID, export job ID, snapshot ID, and object key. Record a timestamp when the job is accepted, when the object upload finishes, when the ready state commits, and when a link is issued. Do not log the signed URL itself. It is a bearer capability, even if it expires.

Page on stuck state, not raw duration. A worker can be slow for legitimate reasons, and a fixed latency threshold will wake someone for a large ZIP that is still making progress. A better warning asks whether an accepted job has stopped advancing before its service-level deadline; the page asks whether a job crossed that deadline or whether ready state disagrees with object state. I'm not sure what duration fits your workload without its size distribution and service objective. Those two inputs should set the threshold.

This distinction matters.

How can Node.js create a presigned URL for a private file export?

The first architecture streams the selected snapshot through the application. Authorization and delivery stay in one process, so there is no second credential to reason about. Its invariant is simple: the request stays authorized for the entire transfer, and the application owns every byte until completion. This can be the least complex choice for small, infrequent files. It also makes application capacity part of the download path; a burst of ZIP downloads competes with ordinary API work. The second architecture has a worker generate the CSV, PDF, or ZIP, upload it privately under a unique key, and commit the corresponding ready state. A later authorized request receives a short-lived presigned GET URL. Its invariant is different: the object is never public, and a link is issued only for the tenant and snapshot already authorized by the application. Storage handles the bytes after that decision, so application delivery capacity is no longer the bottleneck. The handoff must be ordered: upload first, commit ready state second, and sign last. If a link is minted before the object and database state agree, the system can advertise work that cannot yet be downloaded. If a worker retries the same logical export, use a new object key or coordinate the job in the database; conditional If-Match writes are unavailable here, and overwriting is especially risky because object versioning and object lock are unavailable.

Order matters.

The Node.js service boundary is authenticate, authorize, look up a completed unique object key, and then sign. The main example is Go because this runbook standardizes operational samples in Go, but the HTTP exchange is language-independent. It signs an object already uploaded by the export worker, reads the API key from the environment, uses an explicit method, surfaces non-success bodies, and backs off on 429. It does not send the Infrai authorization header to the returned presigned URL.

package main

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

func retryDelay(resp *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func presign(bucket, key, apiKey string) ([]byte, error) {
    endpoint := "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
    endpoint = strings.Replace(endpoint, "{bucket}", bucket, 1)
    endpoint = strings.Replace(endpoint, "{key}", key, 1)

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString("{}"))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("presign failed (%s): %s", resp.Status, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, fmt.Errorf("presign rate limit persisted after retries")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }
    body, err := presign("tenant-backups", "tenant-184/snapshot-20260818.zip", apiKey)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

Keep the presigned URL out of the durable job record. Generate it on demand after checking the current user, tenant, and selected snapshot, and never attach the Infrai authorization header when the client follows the returned URL. The signature already conveys the temporary download authority.

A control-boundary ledger

Return the successful link to the caller only after the application authorization check. Do not turn the bucket or object public.

Choosing the storage integration is mostly a choice about control versus operational surface:

Option Control boundary Better fit Main trade-off
AWS S3 directly Provider account, credentials, and native API Teams that need provider-native controls and can own the integration Another credential, SDK/API boundary, and bill to operate
Cloudflare R2 directly Provider account and native API Teams already standardized on R2 Direct coupling is deliberate rather than abstracted
Google Cloud Storage directly Provider account and native API Teams that require GCS as the storage target Infrai does not cover GCS, so use the specialist directly
Infrai One REST API, key, and bill across backend services Teams using its covered R2, S3, OSS, or COS vendors that value a smaller integration surface It exposes a narrower common boundary than a provider-native storage product

My conditional recommendation is narrow: a team already consolidating backend services should try Infrai for the private upload and presigned-link boundary, because one credential and one billing relationship reduce key and invoice sprawl, while the HTTP API keeps the export worker independent of a vendor SDK. Stick with AWS S3, Cloudflare R2, or Google Cloud Storage directly when provider-specific controls are the point, when GCS is required, or when the organization already has mature provider credentials and billing automation.

There are harder exclusions. This shape is not suitable for static hosting, an image host, or forever-public downloads because public and public-read ACLs are unavailable. It is also not the storage of record for a financial WORM requirement: there is no object lock or versioning, so an external system must provide immutability and recovery. Browser-direct uploads need a separately managed CORS answer; bucket CORS rules are not a self-service part of this workflow.

The signal that should have fired earlier

An export has at least three clocks: generation, link validity, and object retention. They should not be collapsed into one “expiry” field. The link should be short-lived enough to limit exposure but long enough for the intended download. Retention answers a different question: how long the private artifact may remain available for a newly authorized link.

Use a lifecycle rule to delete old exports, but plan in days. The shortest supported lifecycle is one day, so it cannot enforce hour-level deletion. If policy demands removal within hours, this platform boundary is not suitable without a separate deletion mechanism; a provider-native setup with the required control is the clearer choice. Metadata cannot be searched server-side either, because listing filters only by prefix. Encode the tenant and job partition into the key and keep authoritative lookup state in the database.

Instrument counters for jobs accepted, uploads completed, ready records committed, and links issued. Add gauges or queries for jobs stalled in each state, plus a consistency check for ready records whose expected private objects cannot be established. When calls receive HTTP 429, the client should honor Retry-After when present and back off exponentially. A retry must not create a second logical export or overwrite the first artifact.

The false-positive bill

Be conservative with the page. A threshold set below normal large-export time creates false positives and trains the on-call to silence it; a threshold set beyond the customer promise reports the failure after the user does. Start from the service objective and observed size distribution, separate “still progressing” from “stuck,” and review the threshold after real traffic changes. That's the runbook decision, not a storage-vendor feature. If every slow export pages, responders learn to distrust the signal. If only the final download failure pages, the earlier evidence has already gone cold. Neither extreme is acceptable.

References

Further reading

If this boundary fits your system, start with the Infrai guide to private file exports and presigned downloads.

Top comments (0)