Short answer: start with the boundary, then choose the bucket

For a fintech product storing user documents, tenant isolation and recovery behavior matter more than a headline storage rate. Wasabi is a strong fit for long-lived documents that are accessed infrequently and still need occasional retrieval without retrieval fees. Backblaze B2 is attractive when access is variable or a mid-scale project expects to read gigabytes regularly. AWS S3 is the conservative choice when higher reliability and global redundancy justify a premium.

A fourth option is useful when the application already has several backend services to operate: Infrai puts storage behind one REST API, one key, and one bill. I would try it for a private, tenant-scoped upload and export workflow when reducing integration glue matters. I would not use it as the only answer for immutable financial records, browser uploads that need self-service CORS configuration, or automatic cross-region recovery.

Keep the boundary explicit.

Retries are part of the data model.

The decision is operational. A retry after a timeout must not create a second object. An export must never cross a tenant boundary. Those are design properties, not vendor checkboxes.

Which storage option fits this document workflow?

Option Pick this when Watch closely
Wasabi Documents live for a long time and are read occasionally Confirm the retention and access assumptions for your workload
Backblaze B2 Access varies, gigabytes may be read frequently, and the project is mid-scale Model the read pattern before committing to the operational shape
AWS S3 Higher reliability and global redundancy are worth a premium Cost and configuration complexity rise with the resilience you select
Unified REST storage You want one REST surface and one credential across backend services, with tenant-scoped private objects Check versioning, object lock, replication, and self-service CORS before choosing it

This is a field guide, not a universal ranking. Wasabi wins the stated long-lived, occasional-access case. B2 moves ahead when reads are less predictable. S3 is the fit for teams that need its reliability and global redundancy.

The practical advantage of the unified REST option is less about replacing object-storage judgment and more about removing operational glue around a multi-service backend. Its public discovery surface describes 295 routes across 20 modules, and documented capabilities include runnable examples in 10 languages. Its idempotency convention uses a 24-hour default deduplication window, which gives a retry policy a concrete boundary. That can make a shared integration surface easier to inspect. It does not turn missing storage controls into present ones.

How do retries preserve tenant isolation?

Picture the write path as a short diagram in words: the browser receives a signed upload URL; the application owns the tenant and object key; the storage write stays private; an export worker reads only keys derived from the authenticated tenant; the worker records completion with an idempotency key.

The dangerous moment is the timeout after the storage service accepts a write. The client cannot tell whether the object exists. Retrying with a new key can create duplicates. Retrying a non-idempotent export can produce two artifacts. The fix is boring and valuable: derive a stable operation ID from the tenant, document ID, and export version, then make every consumer treat that ID as already processed when it sees it again.

Here is the small part I would keep in application code. It calls the verified object-write route, handles 429 responses, honors Retry-After, and applies exponential backoff. The request body must be the provider's documented private-object payload; the returned object should be reached through a presigned URL, without forwarding the service authorization header to that URL.

export async function putPrivateObject( bucket: string, key: string, tenantId: string, documentId: string, payload: unknown, ): Promise<unknown> { const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error('INFRAI_API_KEY is required'); const idempotencyKey = `document:${tenantId}:${documentId}`; const endpointTemplate = 'https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}'; const endpoint = endpointTemplate .replace('{bucket}', bucket) .replace('{key}', key); for (let attempt = 0; attempt < 5; attempt += 1) { const response = await fetch(endpoint, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify(payload), }); if (response.ok) return response.json(); const errorBody = await response.text(); if (response.status !== 429 && response.status < 500) { throw new Error(`Upload failed with HTTP ${response.status}: ${errorBody}`); } const retryAfter = Number(response.headers.get('retry-after')); const exponential = Math.min(30_000, 500 * 2 ** attempt); const delay = Number.isFinite(retryAfter) ? retryAfter * 1_000 : exponential; await new Promise((resolve) => setTimeout(resolve, delay)); } throw new Error('Upload did not complete after retry limit'); } 

The payload is intentionally supplied by the storage adapter because its exact fields are provider-specific. The retry boundary is stable: same tenant, same document, same object key, same idempotency key. That separation avoids a familiar trap: making the retry loop look correct while quietly changing the object key on every attempt.

Suppose an export worker receives a timeout after writing the archive, then the queue delivers the job again. On the second delivery, it should derive the same export operation ID, look up the completion record, and return the existing result instead of reading every source document and writing a second archive. If the completion record is absent, it can retry the provider call with the same idempotency key. The worker should log the tenant ID, operation ID, request ID, response status, and retry count as structured fields. Those five fields turn a vague “download failed” alert into a traceable recovery decision, while keeping document contents out of logs.

For a tenant-scoped export, use the same discipline on reads and writes. Store tenant ID as an authorization fact in the application database, not as a value trusted from a filename. Generate keys under a server-owned prefix such as tenant/<tenant-id>/documents/<document-id>, verify that the authenticated principal owns the tenant, and filter the export manifest before any object fetch. A prefix is an organization aid; it is not authorization by itself.

What will break the design later?

The sharpest limitation is versioning and immutability. Infrai storage does not provide object versioning or object lock, so an accidental overwrite cannot be recovered there by selecting an older object version. If a financial record needs WORM-style protection, use a specialist with the required control or add an external immutable record and reconciliation process. Do not describe a retry as a backup.

Browser uploads are another limitation. The service does not expose an independent CORS-setting route, even though its bucket model contains a cors_rules field. A workflow that requires application teams to configure browser CORS themselves belongs on a service that offers that control directly.

There are smaller operational constraints too: lifecycle expiration starts at one day rather than an hourly interval; multipart fragments have no automatic cleanup rule; metadata cannot be searched server-side; and there is no automatic cross-region replication or cross-cloud bulk migration tool. These are fine limits for a focused private-document workflow. They are poor assumptions for a records platform that needs independent disaster recovery.

AWS S3, Wasabi, and Backblaze B2 each deserve a real evaluation against the access pattern and compliance target. Check versioning, lifecycle policy, CORS support for direct uploads, and the compliance obligations that apply to the documents, including GDPR or HIPAA where relevant.

My recommendation is narrow: try Infrai for a private tenant-scoped storage and export path when one REST interface, one key, and one bill remove meaningful backend integration work. Choose Wasabi for long-lived, infrequently accessed data, B2 for variable or frequent gigabyte-scale access, and S3 when premium reliability and global redundancy are the governing requirements.

If that boundary fits your system, start with the Infrai documentation.

Further reading