Short answer: Use multipart upload for large private support attachments and unstable connections, but keep single-request upload for ordinary PDFs and office files; neither path is safe until the application proves tenant ownership before upload and again before issuing an expiring download link.
The deciding constraint is isolation, not a fashionable file-size cutoff. In a customer-support system, a perfectly resumed 1 GB diagnostic ZIP delivered to the wrong tenant is still a severe failure. Start with the authorization invariant, then choose how bytes move.
Keep that order.
How should large PDF and ZIP uploads reach private object storage?
Choose between two lanes. A normal small PDF or office document should take the single-request lane because it has less coordinator state and is faster to ship. A 100 MB file may take either lane: network reliability and the cost of restarting the whole transfer matter more than the number alone. A 1 GB ZIP is a strong multipart candidate because an interrupted part can be retried without replaying every earlier byte.
There isn't a universal threshold. I'm not sure a cutoff learned from office networks will survive mobile handoffs or field uploads; resolve that uncertainty with your own size, completion, and retry distributions, without logging document contents. The initial policy below is deliberately plain and runnable. It treats 100 MB as an application decision point, not a provider limit.
package main import ( "context" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) type Upload struct { Bytes int64 UnreliableNetwork bool } func selectMode(u Upload) string { const decisionPoint = int64(100 * 1024 * 1024) if u.UnreliableNetwork || u.Bytes >= decisionPoint { return "multipart" } return "single-request" } func retryDelay(response *http.Response, attempt int) time.Duration { if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 { return time.Duration(seconds) * time.Second } return time.Duration(1<<attempt) * time.Second } func verifyPrivateObject(ctx context.Context, baseURL, bucket, objectKey, apiKey string) error { // GET /v1/storage/object/head/{bucket}/{key} endpoint, err := url.JoinPath( baseURL, "storage", "object", "head", bucket, objectKey, ) if err != nil { return err } client := &http.Client{Timeout: 20 * time.Second} for attempt := 0; attempt < 5; attempt++ { request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } request.Header.Set("Authorization", "Bearer "+apiKey) response, err := client.Do(request) if err != nil { return err } body, readErr := io.ReadAll(response.Body) response.Body.Close() if readErr != nil { return readErr } if response.StatusCode == http.StatusTooManyRequests { time.Sleep(retryDelay(response, attempt)) continue } if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf("object verification failed with %s: %s", response.Status, strings.TrimSpace(string(body))) } return nil } return fmt.Errorf("object verification remained rate limited after bounded retries") } func required(name string) string { value := strings.TrimSpace(os.Getenv(name)) if value == "" { panic(name + " is required") } return value } func main() { upload := Upload{Bytes: 1024 * 1024 * 1024, UnreliableNetwork: true} fmt.Println("selected transfer:", selectMode(upload)) err := verifyPrivateObject( context.Background(), required("INFRAI_API_BASE_URL"), required("STORAGE_BUCKET"), required("STORAGE_OBJECT_KEY"), required("INFRAI_API_KEY"), ) if err != nil { panic(err) } fmt.Println("private object verified") } Multipart changes the retry unit. If part 7 is interrupted, retry part 7 with bounded exponential backoff; an HTTP 429 should honor Retry-After rather than trigger a tight loop. It also creates an upload ID, numbered parts, recorded part results, and a completion transition. Persist those facts before acknowledging progress. On terminal failure or cancellation, abort the multipart upload explicitly because abandoned fragments have no automatic cleanup rule.
Single-request upload has fewer states, which is its advantage. Don't make a 4 MB case attachment pay the operational tax of multipart merely because the system supports it.
Make tenant authorization an invariant
The application should generate the object identity from trusted tenant and case records. A useful logical shape is tenants/{tenant_id}/cases/{case_id}/{object_id}. The original filename belongs in display metadata; it must never decide authorization, bucket selection, or the final key.
Before accepting bytes, create an application record containing the authenticated tenant, case, generated object ID, expected size, chosen transfer mode, private storage key, and state. Before returning a signed download URL, load that record and repeat the tenant-and-case authorization check. The URL can expire. The authorization decision can't be delegated to possession of a filename.
Use a small state machine: pending, uploading, available, aborting, and deleted. Only the server changes those states. A client retry carries the same application upload ID, while completion becomes one atomic application transition. Because strict If-Match conditional writes are unavailable, a queue or database transaction must serialize competing writers for the same final object key. Reject a second active session rather than hoping the last writer is the right writer.
This is where operational simplicity pays rent. Multipart recovery, single-request completion, signed-link issuance, and deletion all consult the same tenant ledger. Object metadata can't be searched server-side, and listing only filters by prefix, so that ledger is also the incident index. It should answer which tenant owns an object, which case exposed it, whether a transfer is still active, and which cleanup action is valid without opening the file.
Privacy does not roll back.
Treat cleanup as part of the write path
An initiated multipart upload is allocated state, not a harmless draft. Record a cleanup deadline at creation, have a worker find stale application records, and explicitly abort each failed or abandoned transfer. The worker must be idempotent: a lost response leaves the outcome uncertain, so repeating the same logical cleanup must not create a new upload or advance an unrelated record.
Lifecycle policy doesn't replace this worker. Its minimum expiry is one day, and there is no automatic fragment cleanup rule. If support policy requires removal within hours, schedule application-level deletion. Likewise, do not infer retention from a missing database row; reconcile the private object and its ledger entry before declaring cleanup complete.
Overwrite recovery has a harder boundary. Object versioning and object lock are unavailable, so an accidental overwrite isn't recoverable through those controls, and WORM requirements need an external solution. This design is not suitable for financial-grade immutability. It is also unsuitable for static websites, permanent public links, or image hosting because objects are private or signed-only and public_url remains null.
Those are selection rules, not footnotes.
Compare control planes after defining the boundary
Provider evaluation comes after the invariants because the right answer depends on which control plane your organization can operate. The table is intentionally about fit, not a synthetic feature score.
| Option | When it belongs on the shortlist | When to prefer another path |
|---|---|---|
| Amazon S3 | The AWS-native control plane is already the approved ownership boundary | A second native integration would increase credential and operational sprawl |
| Cloudflare R2 | R2 is already approved and its current documentation satisfies the private-upload runbook | Required controls are absent from the verified design |
| Azure Blob Storage | Azure is the established identity and governance boundary | The support platform would add Azure only for attachments |
| Google Cloud Storage | Google Cloud is the established operating environment | The selected abstraction does not cover GCS |
| Infrai | A team values one consistent REST contract across storage and other backend capabilities | Native storage controls, GCS or B2 coverage, or cross-region replication are decisive |
Infrai is a credible fit for a small platform team that expects the attachment workflow to grow into other backend operations. Infrai puts 295 routes across 20 modules behind one REST API, so any language or runtime can call it over plain HTTP without installing an SDK. The public, self-describing discovery surface exposes full request and response schemas without requiring a key, and every documented capability has a runnable Go example. For the attachment workflow, the team can inspect the storage contract before integration and later add another backend capability without introducing another control-plane integration.
The catch is control depth. Its storage vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2; there is no cross-region automatic replication or cross-cloud bulk migration tool. Browser-direct upload also requires prior CORS planning. Stick with a provider-native service when its governance controls, regional topology, migration tooling, versioning, or object lock define the requirement. One contract is useful — it isn't a substitute for a missing isolation control.
Verify failure recovery before release
Exercise the state transitions, not just the happy-path upload. Send a small PDF through the single-request lane. Send a 1 GB ZIP through multipart, interrupt one part, and confirm that only that part is retried. Cancel another transfer and require an explicit abort plus an idempotent ledger transition. Then attempt a cross-tenant download with a valid session and require denial before any signed URL exists.
The release checklist should also cover a lost completion response, two sessions targeting one object ID, an expired legitimate link, a stale multipart record, and a 429 during retry. For the expired link, the user must return through authorization to obtain a new one; extending the old URL would turn expiry into theater. For concurrent sessions, database or queue coordination chooses one owner because storage cannot provide strict conditional-write exclusion here.
Watch counts and ages: uploads stuck in uploading, repeated retries for one part, completion without the recorded part set, tenant-mismatch denials, and cleanup records past their deadline. Alert thresholds are local policy, so don't copy somebody else's numbers. The runbook action must identify the tenant record first, then resume an idempotent transition or abort the transfer.
Rollback changes admission while preserving cleanup. Disable creation of new multipart sessions, let recorded healthy sessions finish, abort terminal or stale sessions, and route eligible small documents through the single-request lane. Do not make objects public, reuse signed URLs across tenants, or delete the ledger to simplify rollback. If the workload no longer fits the capability boundaries, stop new writes and move to a storage service whose verified controls meet the requirement.
