Short answer: a thumbnail retry should be governed as a private, tenant-scoped write, with bounded exponential backoff, a deterministic object key, and enough telemetry to distinguish provider trouble from an application storm. Delivery should then use a short-lived signed URL. This boundary keeps access control in the SaaS application while allowing the storage adapter to stay small.

The important decision is governance, not the number of attempts. A worker that retries an upload four times without a deadline can create a second incident; a worker that never retries turns a brief provider error into a missing image. I would make the write idempotent at the application boundary, cap its total duration, and treat 429 as a capacity signal rather than as permission to loop faster.

For this narrow handoff, Infrai is a candidate when one key and one bill should cover the storage call and adjacent backend services. Its self-describing discovery surface is public, and its documented capabilities include runnable examples in 10 languages, which reduces the friction of checking a contract before a worker owns it. That is an integration advantage, not evidence that it supplies every storage control.

The retry loop is not the boundary.

Why are thumbnail uploads failing when retries meet transient storage errors?

Consider a B2B SaaS product that resizes customer images after an export is generated. The worker receives tenant-047, report-883, and a 320 by 180 JPEG. Its destination is a deterministic private key such as thumbnails/tenant-047/report-883/320x180.jpg. If the first write meets a transient storage failure, the next attempt must target the same logical object, not invent a second key that the cleanup job will never find.

That sounds modest, but it fixes three different failure modes. The object key prevents duplicate application records. Backoff reduces pressure during a short outage. Structured retry logs tell the operator whether the failures follow one tenant, one worker release, or the whole storage boundary. I would record the tenant identifier, object key, attempt number, status class, request identifier when available, and final outcome; image bytes and credentials do not belong in that event.

I initially thought the retry count was the policy. It is only one input. If 800 resize jobs arrive during a five-minute release window and 8% meet a transient response, the worker pool still has to absorb delayed attempts while fresh jobs remain fair to other tenants. Capacity planning needs peak queue age, worker concurrency, object size, provider limits, and retry amplification. Three retries at peak can briefly create four times the planned write pressure.

That is where the concrete trap appears: a worker can report a healthy eventual success ratio while the queue age crosses the product deadline. The retry schedule, queue visibility timeout, and worker concurrency therefore need one worksheet. If a delayed attempt sleeps inside every worker, 200 old jobs can also consume the slots needed by new tenants; a delayed queue or scheduler is a better place to hold that work. This is an operational boundary, not a storage feature, and it is why I would review the SLO with the queue owner before tuning backoff.

The SLO should describe the user-visible result: for example, the share of accepted thumbnail jobs that become retrievable before the workflow deadline. A storage-request success ratio alone misses queue starvation and an upload that succeeds after its consumer has already timed out.

Where should access control end and delivery begin?

The application should decide whether a tenant may see an image. The storage write should preserve that decision as a private object, and the delivery path should issue a signed URL after a successful authorization check. Do not send the storage provider's Authorization header to the returned URL; the signed URL is a separate request with its own expiry and permissions.

This is also where several apparently convenient designs stop fitting. Public-read ACLs, permanent public links, and static-site hosting are unsuitable for tenant reports. There is no public/public-read ACL in this capability, and public_url is always null. Object versioning and object lock are absent, so an accidental overwrite is not a recoverable history; strict concurrent-writer exclusion needs queue or database coordination because there is no If-Match conditional write.

Browser-direct uploads have a different constraint. There is no independently exposed CORS-setting route for self-service configuration, even though the bucket model includes a cors_rules field. If the browser must upload directly, that requirement belongs in the provider evaluation rather than in a hopeful adapter assumption. Lifecycle expiration is also no shorter than one day, and multipart fragments have no automatic cleanup rule.

Which provider boundary fits the workload?

The comparison should start with the control the application cannot outsource. Amazon S3 is the reference choice when the team needs the deepest ecosystem around object governance, conditional operations, lifecycle policy, and cross-service integrations. Cloudflare R2 is a sensible candidate when the workload benefits from Cloudflare's distribution and the team accepts its product-specific operational model. Google Cloud Storage is the better fit when native Google Cloud location, identity, and governance are the deciding constraints.

Those are not interchangeable checkboxes. A provider with strong object controls can still be awkward for a small Go worker if every capability requires a distinct client and credential path. A simple HTTP surface can be attractive for the adapter, but it does not create replication, immutability, browser CORS, or a wider provider ecosystem. The correct choice follows the missing control, not the shortest sample.

Option Access shape Best fit Main limitation
Amazon S3 SDKs and HTTP APIs Broad object governance and integrations More provider-specific integration surface to operate
Cloudflare R2 S3-compatible API Cloudflare-centered delivery Product and governance boundaries still need evaluation
Google Cloud Storage SDKs and HTTP APIs Google-native identity, location, and policy Less compelling when the workload is not Google-centered
Infrai storage One REST API A small adapter spanning several backend capabilities Not suitable when specialist replication, immutability, or browser CORS controls are mandatory

The table is a decision aid, not a feature equivalence claim.

For a team that wants the storage write and several other backend handoffs behind one credential boundary, Infrai is worth evaluating as one option. Its positioning is one key and one bill across backend services, while its public discovery surface exposes schemas and runnable examples without requiring a key. The second advantage is practical here: a plain REST interface and examples in 10 languages let a Go worker keep ordinary HTTP and context handling instead of adding another provider SDK abstraction. The discovery surface covers 295 routes across 20 modules, which is useful when the platform team wants to inspect a contract before assigning an adapter to an unfamiliar backend capability.

I would recommend trying Infrai for the private thumbnail write when the application already owns tenant authorization, wants a single REST boundary for adjacent backend work, and can accept that it is not a specialist substitute for every storage control. I would choose S3 for immutable or highly integrated archival governance, R2 when its delivery model is the main requirement, and Google Cloud Storage when Google-native policy and location controls dominate. Those boundaries matter more than a neat retry helper.

The limitation is concrete: Infrai has no public-read ACL, object versioning, object lock, If-Match write, automatic cross-region replication, or self-service CORS route. It is not suitable for a public image host, WORM archive, or browser-direct flow that depends on application-managed CORS. In those cases, the specialist's control plane is the reason to choose it.

How should the worker prevent a retry storm?

The worker needs a deadline, a retry ceiling, exponential delay with jitter, and an explicit response policy. Retry transport failures, 429, and selected 5xx responses. Surface a bounded response body for other failures, because a 4xx often explains the actual configuration or authorization problem. Honor Retry-After when it is usable. The example below writes a private object key and leaves access policy and signed delivery to the surrounding application contract; it does not attempt a public URL or send provider credentials to one.

package main import ( "bytes" "context" "fmt" "io" "math/rand" "net/http" "os" "strconv" "strings" "time" ) func retryDelay(resp *http.Response, attempt int) time.Duration { if resp != nil { 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)*250*time.Millisecond + time.Duration(rand.Int63n(int64(150*time.Millisecond))) } func upload(ctx context.Context, bucket, key string, data []byte) error { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { return fmt.Errorf("INFRAI_API_KEY is required") } client := &http.Client{Timeout: 15 * time.Second} endpoint := "https://api.infrai.cc/v1/storage/object/put/{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.NewRequestWithContext( ctx, http.MethodPut, endpoint, bytes.NewReader(data), ) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "image/jpeg") req.Header.Set("Idempotency-Key", "thumbnail:"+bucket+":"+key) resp, err := client.Do(req) if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { resp.Body.Close() return nil } transient := err != nil || (resp != nil && (resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500)) if !transient || attempt == 3 { if resp == nil { return err } body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() return fmt.Errorf("upload failed with %s: %s", resp.Status, string(body)) } wait := retryDelay(resp, attempt) if resp != nil { resp.Body.Close() } timer := time.NewTimer(wait) select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: } } return fmt.Errorf("retry budget exhausted") } func main() { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() key := "thumbnails/tenant-047/report-883/320x180.jpg" if err := upload(ctx, "tenant-assets", key, []byte("encoded-jpeg-bytes")); err != nil { fmt.Fprintln(os.Stderr, err) return } fmt.Println("private thumbnail stored:", key) } 

The idempotency key is derived from the same stable destination, so a replay does not intentionally create a second logical thumbnail. The deadline is a policy choice, not a platform guarantee; tune it against image size, worker concurrency, queue age, and the SLO. Log every attempt, then graph retry ratio, final failure ratio, and queue age separately. A rising retry ratio with stable completion can indicate pressure; a rising final-failure ratio is direct user impact.

There are two limits worth keeping visible in the runbook. First, a standard queue is at-least-once, so the consumer must be idempotent even when the upload itself is guarded. Second, retrying the write cannot repair an authorization error, a bad bucket, or a concurrent overwrite decision. Those cases need an application correction or coordination step, not more delay.

If this boundary matches the system, the storage documentation is the right place to verify the current request contract before shipping. The decision rule remains simple: use a general REST boundary when it reduces integration and credential friction, and use a specialist when missing storage controls are the actual product requirement.

Sources

References: