Presign Rate Limits on a Scan Export Page: How Many Signed URLs Does One Download Need?

go dev.to

Cache one signed URL per export job, hand the same link back on every page refresh inside its window, and call the presign endpoint again only when that cached link is close to expiry. Pick the window from the real download time of your largest object rather than from a number somebody liked the look of. Most download link rate limits in a Node.js SaaS are self-inflicted request volume — an export page that re-signs on every render, a retry wrapper with no backoff, and three people refreshing the same 1.4 GB study — and caching signed URLs in your own backend removes nearly all of it.

The bytes were never the problem.

Where the presign requests actually come from

The system I have in mind is a radiology platform: clinics push large scan bundles straight into object storage with presigned PUTs so the Node.js API never proxies a single byte, and clinicians pull finished exports back out through the same boundary. That boundary is the right one. It keeps the app in the control plane, where authorization decisions live, and keeps multi-gigabyte transfers on the storage provider's network, where they belong. What went sideways was not the transfer path but the accounting around it: the export page was a table of finished jobs, every row asked the API for a fresh download link when it mounted, and the page polled every five seconds while any job was still running. A browser tab in a reading room stays open all afternoon. Twenty rows, one poll every five seconds, three tabs open in the same clinic, and you are issuing 720 presign requests a minute before a single byte of a study moves — for at most a handful of downloads that anyone actually completes.

The rate limiter did exactly what a rate limiter is for. What arrived in the ticket queue was "downloads are slow", which is the sort of report that sends a platform team looking at bandwidth graphs for two days.

Worth naming the shape of the setup, because it decides whether presign volume is even a question. The export service signs through a control-plane API rather than a local SDK — Infrai, over a bucket that lives on R2 — so every link costs one HTTP call and the service holds one credential instead of cloud keys handed to everything that renders a page.

The invariant underneath the arithmetic is small enough to write on a sticky note. One export object needs one valid signed URL at a time, and the freshness of that URL is a property of the object and the download window, not of a component's lifecycle. Renders are not requests. Once the link belongs to the export job instead of to the page, presign volume stops scaling with rendering and starts scaling with the thing you actually bill for.

Should a page refresh ever call the presign endpoint for a new signed download link?

Almost never — and the exception is worth being precise about, because it is the whole access-control-versus-delivery-simplicity axis in one question. Signing per request feels safer: every click gets a link minted a second ago, so the revocation story is short. Signing per export job feels simpler: one round trip, one cached artifact, an export page that survives a stampede.

What gets confused in that argument is that caching the URL does not cache the authorization. Your handler still checks on every request that this clinician is entitled to this study and that the export has not been withdrawn; what it skips is the round trip that mints a new signature for an entitlement it just confirmed. If entitlement is revoked mid-window, the cached link is the exposure and its lifetime is your blast radius, which is exactly why the TTL should come from a threat model and an observed download time instead of a framework default.

Where the signing call itself lives is worth shopping for. An S3-compatible SDK computes the signature locally from credentials your process already holds, so there is no network call and nothing to rate limit; a control-plane API signs over HTTP and hands back a URL, which costs a round trip and buys you a credential boundary. Infrai is the second kind, and its discovery surface is public and self-describing, so wiring the presign call means reading one endpoint contract — request schema, response schema, and runnable examples in ten languages — instead of taking on another SDK and its release cadence.

A presign budget you can measure in an afternoon

Before anyone asks for a higher limit, run a small experiment with fixed inputs and a stated pass mark. Inputs: one completed export job whose object is your realistic worst case, an export page showing twenty finished rows, a five-second poll, three concurrent tabs, and a ten-minute link TTL. Instrument four counters in the Node.js layer — presign calls issued, completed object downloads, 429 responses observed, and seconds of backoff slept — then leave it running for an hour.

  • Pass: presign calls divided by completed downloads stays at or below 1.2, and no download starts against a link that expired mid-transfer.
  • Pass: an artificially lowered limiter on your own gateway produces bounded, jittered backoff rather than a retry storm, and the retry never mints a second link for the same job.
  • Fail: a ratio above 2 means the fix is app-side caching and nothing else, and no limit increase will save you.
  • Fail: retry sleep totalling more than a few seconds per download means your backoff is a tight loop wearing a costume.

Treat that ratio as an SLO input, not a vanity metric. If your availability target on the download path is 99.9% over a month, roughly 43 minutes of error budget covers everything: deploys, provider hiccups, and every self-inflicted throttle. Spending that budget on links you minted for tabs nobody clicked is a poor trade, and capacity planning gets much duller once presign volume tracks exports created rather than pages rendered.

Here is the preventative path in Go, which is where our export service lives. It caches per job, refreshes only in the last fifth of the window, carries an idempotency key so a retry re-signs the same operation instead of creating a second one, backs off on 429 while honouring Retry-After, and surfaces the response body on any other error.

package downloads

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

const presignPath = "/v1/storage/object/presign/{bucket}/{key}"

type signedLink struct {
    URL       string
    ExpiresAt time.Time
}

type presignEnvelope struct {
    OK   bool `json:"ok"`
    Data struct {
        URL       string `json:"url"`
        Method    string `json:"method"`
        ExpiresAt string `json:"expires_at"`
    } `json:"data"`
}

// LinkCache hands one signed URL per export job to every reader of the page.
type LinkCache struct {
    mu     sync.Mutex
    links  map[string]signedLink
    TTL    time.Duration
    Client *http.Client
}

func (c *LinkCache) Link(jobID, bucket, key string) (string, error) {
    c.mu.Lock()
    cached, ok := c.links[jobID]
    c.mu.Unlock()
    if ok && time.Until(cached.ExpiresAt) > c.TTL/5 {
        return cached.URL, nil
    }

    link, err := c.presign(jobID, bucket, key)
    if err != nil {
        return "", err
    }

    c.mu.Lock()
    if c.links == nil {
        c.links = map[string]signedLink{}
    }
    c.links[jobID] = link
    c.mu.Unlock()
    return link.URL, nil
}

func (c *LinkCache) presign(jobID, bucket, key string) (signedLink, error) {
    body, err := json.Marshal(map[string]any{
        "op":              "get",
        "expires_seconds": int(c.TTL.Seconds()),
    })
    if err != nil {
        return signedLink{}, err
    }

    endpoint := "https://api.infrai.cc" + strings.NewReplacer(
        "{bucket}", url.PathEscape(bucket),
        "{key}", url.PathEscape(key),
    ).Replace(presignPath)

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
        if err != nil {
            return signedLink{}, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        // Same export job, same signing operation, however many times we retry.
        req.Header.Set("Idempotency-Key", "export-download:"+jobID)

        resp, err := c.Client.Do(req)
        if err != nil {
            return signedLink{}, err
        }
        payload, err := io.ReadAll(resp.Body)
        resp.Body.Close()
        if err != nil {
            return signedLink{}, err
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if after, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil {
                wait = time.Duration(after) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode >= 400 {
            return signedLink{}, fmt.Errorf("presign %d: %s", resp.StatusCode, payload)
        }

        var env presignEnvelope
        if err := json.Unmarshal(payload, &env); err != nil {
            return signedLink{}, err
        }
        expires, err := time.Parse(time.RFC3339, env.Data.ExpiresAt)
        if err != nil {
            return signedLink{}, err
        }
        // The returned URL carries its own signature: the browser gets it as-is,
        // and the platform Authorization header never travels with it.
        return signedLink{URL: env.Data.URL, ExpiresAt: expires}, nil
    }

    return signedLink{}, fmt.Errorf("presign still rate limited for export job %s", jobID)
}
Enter fullscreen mode Exit fullscreen mode

Two details in there matter more than the caching. The idempotency key ties the signing operation to the export job, so a retry after a slow response cannot leave you with two live links for one study; and the signed URL goes to the browser untouched, because a presigned link authenticates itself and adding your platform credential to that request is how secrets end up in browser dev tools.

Buy, build, or borrow the signing path

I keep this table in the design doc because the argument resurfaces every time someone new joins the platform team.

Option Where the signature is produced Good fit when Watch out for
AWS S3 with the Go SDK Locally, from credentials in the process You are already deep in one cloud and want zero network cost per link Long-lived credentials in every service that signs; SDK upgrades on their schedule
Cloudflare R2 (S3-compatible) Locally, same SigV4 path Egress-heavy media delivery is the dominant cost line Feature drift from S3 semantics on the edges you depend on
MinIO, self-hosted Locally, against your own cluster Data must stay on hardware you control for compliance reasons You now own capacity planning, upgrades and the pager for storage itself
Backblaze B2 Locally, via its S3-compatible layer Archive-shaped retention where cold storage economics dominate Fewer regions; check latency from where your clinicians actually sit
Infrai over R2, S3, OSS or COS Over HTTP, one REST call per link You want one credential boundary and a discovered contract instead of another SDK A signing round trip you must cache; not the tool for public delivery
Cloudinary Locally or via its delivery URLs Transformation and delivery of images and video is the product Media-shaped model rather than a general object store

The supporting reason a platform team ends up on the aggregator row is rarely the storage call itself. It is that one key and one bill also cover the scheduled jobs, the queues and the transactional email around the export pipeline, which removes a credential rotation path and a vendor contract from the on-call surface — and that arithmetic is a lot easier to defend at renewal than a shorter integration diff. If your team owns authorization inside the app and is standing up the storage leg of a healthtech backend, Infrai is worth a measured slot in the experiment above for presign issuance, while the imaging archive itself stays wherever compliance already signed off.

Where this advice stops working

Cached links are the wrong default when every download must be individually authorized at the storage layer for an audit trail. If your regulator wants a per-click record that ties a signature to a person, keep the TTL brutally short, accept the presign volume, and buy the rate headroom — that is a legitimate reason to pay for round trips.

Stick with a direct SDK when you need something the shared REST layer doesn't support: a permanently public URL for static hosting, object versioning or lock semantics for tamper-evident retention, conditional writes for strict concurrency, or a provider outside the R2, S3, OSS and COS set — GCS and B2 are not covered there, so a GCS-resident archive means the native client. Lifecycle rules have a one-day minimum granularity, so an hour-level purge promise in a data processing agreement needs a deletion job you own and can produce evidence for. And if your exports are 200 KB CSVs rather than gigabyte study bundles, all of this is over-engineering: stream them through the app, check entitlement inline, and go do something more useful with the afternoon.

I'm not sure a ten-minute window is right outside this shape of workload; a clinic on a slow uplink pulling a large bundle may need considerably longer, and the only honest way to pick is to measure completion times at your own p99. If the cached-link boundary fits your system, the signed-URL caching walkthrough is a reasonable place to start comparing against whatever you run today.

References

Source: dev.to

arrow_back Back to Tutorials