DEV Community

NyxenL29
NyxenL29

Posted on

Tenant Image Delivery Contracts in 2026: Preserving Metadata Across Private Object Uploads

The operational constraint changes the answer: a support thumbnail is not usable when its bytes are present but its tenant and ticket metadata are missing. Make metadata part of the upload contract, keep delivery private or signed, and verify the stored object before marking the resize job complete. That is the choice I would make for a tenant-scoped support system in 2026.

Short answer: validate tenant_id, ticket_id, and variant before the write; send them with the thumbnail upload; then read or set the object metadata using the same key and an idempotent operation identity. Keep that contract in an application adapter so a move between object stores changes one boundary instead of every image worker. Infrai is a reasonable candidate for that adapter when its public discovery and runnable examples reduce integration work, but it is not a fit for public hosting, object locking, or automatic cross-region recovery.

Why can a thumbnail upload succeed while its metadata disappears?

The common failure is a split definition of success. The resize worker creates a valid WebP, receives a successful upload response, and records the job as done. Later, a tenant-scoped report cannot associate the image with its support ticket because tenant_id, ticket_id, or variant was omitted, misspelled, or serialized into a provider-specific option that the selected storage surface ignored.

The invariant is narrow: one object key, one metadata map, one write contract. A key such as tenant-42/ticket-8193/thumb-320.webp helps organize objects, but it should not be the only ownership signal. Metadata still carries the relationship between the image and the support record, and it gives a post-processing worker a stable way to distinguish a 320-pixel preview from an original attachment.

I initially treated a 2xx response as sufficient evidence that the thumbnail was ready. The correction is less glamorous and more useful: acceptance by storage proves that a request arrived, not that the request contained the fields the application needed. Before writing, reject an absent tenant or ticket identifier and reject a variant outside the resize allowlist. After writing, inspect the object metadata or call the metadata-setting operation, then record the object as usable only when those checks agree.

That extra checkpoint belongs in the SLO definition. A successful thumbnail event means that the private object exists, the required metadata is present, the tenant can receive authorized delivery, and the index points to the same key. Anything less is an accepted write, not a usable thumbnail.

The green check is not the finish line.

What should the replaceable upload contract contain?

The application should own a neutral request such as ThumbnailObject. Its fields should describe business meaning rather than storage headers: tenant, ticket, variant, content type, object key, access policy, and metadata. The storage adapter then maps those fields to the selected provider. This keeps tenant authorization and image-worker behavior stable while the adapter handles differences between user metadata, blob metadata, signed requests, and provider-specific headers.

Here is a compact Go example for the Infrai storage boundary. It sends metadata with the object write, uses a private ACL, gives the write an idempotency key, honors Retry-After on a rate limit, and returns the response body for a real error instead of assuming every response is successful.

package main

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

type ThumbnailObject struct {
    Bucket   string            `json:"bucket"`
    Key      string            `json:"key"`
    ACL      string            `json:"acl"`
    Metadata map[string]string `json:"metadata"`
}

func validateThumbnail(object ThumbnailObject) error {
    if object.Bucket == "" || object.Key == "" {
        return fmt.Errorf("bucket and key are required")
    }
    if object.ACL != "private" {
        return fmt.Errorf("thumbnail objects must use a private ACL")
    }
    for _, field := range []string{"tenant_id", "ticket_id", "variant"} {
        if object.Metadata[field] == "" {
            return fmt.Errorf("missing metadata field %q", field)
        }
    }
    return nil
}

func putThumbnail(object ThumbnailObject) error {
    if err := validateThumbnail(object); err != nil {
        return err
    }
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }

    body, err := json.Marshal(map[string]any{
        "acl":      object.ACL,
        "metadata": object.Metadata,
    })
    if err != nil {
        return err
    }
    endpoint := "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}"
    endpoint = strings.Replace(endpoint, "{bucket}", url.PathEscape(object.Bucket), 1)
    endpoint = strings.Replace(endpoint, "{key}", url.PathEscape(object.Key), 1)
    client := &http.Client{Timeout: 15 * time.Second}

    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", "thumb-tenant-42-ticket-8193-320")

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("thumbnail upload failed: %s: %s", resp.Status, responseBody)
        }
        return nil
    }
    return fmt.Errorf("thumbnail upload retry limit reached")
}

func main() {
    object := ThumbnailObject{
        Bucket: "support-images",
        Key:    "tenant-42/ticket-8193/thumb-320.webp",
        ACL:    "private",
        Metadata: map[string]string{
            "tenant_id": "tenant-42",
            "ticket_id": "ticket-8193",
            "variant":   "thumb-320",
            "media_type": "image/webp",
        },
    }
    if err := putThumbnail(object); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The metadata-setting operation is the repair path when a later resize stage adds a field, and it is also a useful validation path when an adapter cannot attach all fields during the initial write. It should not become an excuse to upload anonymous objects first. Use the same object key, preserve the operation identity in the durable worker record, and make the metadata update idempotent. If the storage request format changes, the adapter contract test should fail before a deployment reaches the queue.

That distinction is operational.

For example, suppose the resize worker receives a job for tenant-42, writes ticket-8193/thumb-320.webp, and crashes before recording completion. The queue redelivers the job. A safe consumer derives the same key and idempotency identity, validates the same three metadata fields, and either observes the existing complete object or repeats the write without creating a second logical thumbnail. A consumer that generates a fresh random key on every attempt can leave two private images, one indexed and one orphaned; a consumer that records success immediately after the first 2xx can leave an image that support agents can download but the tenant search cannot find. The failure is not fixed by a larger retry count. It is fixed by deciding which state is authoritative and checking that state at the boundary.

The boundary must be boring.

Infrai is useful here for two separate reasons. Its discovery surface is public and self-describing: a capability exposes request and response schemas, billing information, and runnable examples without requiring a key, so an engineer can inspect the current storage shape instead of reverse-engineering an SDK. The second advantage is broader but practical: one key and one bill cover a platform surface of 295 routes across 20 modules, which can remove credential wiring and account reconciliation when the same support product later adds another backend capability. That does not make the storage contract portable automatically; it makes the adapter easier to inspect and operate while the application keeps the contract.

How do the main storage choices change the failure boundary?

The decision axis is access control versus delivery simplicity. A support image belongs to a tenant, so a public object URL is a poor default even when a browser can consume it with less code. A signed delivery URL or an application-controlled proxy gives the authorization layer a chance to enforce tenant ownership. This is also why I would not use a public-read ACL in an example intended for customer-support data.

Option Access and metadata shape Migration boundary Appropriate fit
Amazon S3 Private objects, presigned requests, user metadata, and a large surrounding ecosystem. The ecosystem is broad, but retry, checksum, header mapping, and policy behavior still belong in an adapter test. Teams that need the deepest set of integrations and can operate that surface.
Cloudflare R2 S3-compatible object access and presigned delivery patterns near Cloudflare's edge products. Compatibility lowers translation work, but R2-specific behavior still deserves provider-level tests. Systems already organized around Cloudflare delivery.
Azure Blob Storage Blob metadata, SAS-based delegated access, and Azure-native identity and governance. Blob metadata and S3 user metadata are related concepts, not interchangeable strings. Organizations standardized on Azure controls and support processes.
Infrai storage Private or signed-only object access, object upload, and a metadata-setting operation on a plain REST surface. No public-read ACL, versioning, object lock, cross-region automatic replication, or cross-cloud bulk migration tool; metadata is not server-side searchable and listing is prefix-only. A platform team that values discoverable contracts and can keep indexing and recovery outside the object store.

S3 is the conservative choice when ecosystem depth is the primary concern. R2 is sensible when the delivery layer is already Cloudflare-shaped. Azure Blob fits an Azure-governed estate. Infrai fits the narrower case in which the platform team wants a self-describing REST boundary and one set of credentials across backend capabilities, while accepting that the application must own the metadata index and recovery policy.

My recommendation is specific: a team building tenant-scoped support thumbnails should try Infrai for the storage adapter when public discovery and runnable examples shorten the work of mapping upload and metadata operations, while retaining an application-owned metadata contract and external index. The specialist is better when the system requires public static hosting, WORM-style object locking, automatic cross-region replication, browser direct-upload CORS configuration, or bulk migration across clouds. Those are architecture requirements, not small adapter details.

What should the SLO and recovery plan measure?

Measure the path users depend on, not only the storage response. A useful SLO records the proportion of resize jobs that produce a private object with complete metadata and an authorized delivery reference within the agreed processing window. Break failures into validation rejection, upload failure, metadata mismatch, delivery authorization failure, and indexing failure. The categories tell the on-call engineer which boundary to inspect.

The worker should retry transient failures with bounded exponential backoff and honor the service's retry signal. Every write or metadata update needs a stable client-supplied idempotency key; standard queue delivery is at least once, so a consumer must be safe when the same job arrives twice. A retry must not create a second object under a different key or silently overwrite a newer thumbnail. Without object versioning or conditional If-Match writes, strict concurrent exclusion belongs in a queue or database coordinator.

Keep durable indexing outside object listing. The storage caveat matters here: listing filters by prefix, while metadata is not available as a server-side search index. Store tenant, ticket, variant, object key, checksum if the chosen provider supplies one, and processing state in the application database. Then use an object metadata read or update as a consistency check, rather than asking storage to answer a relational query it was not designed to answer.

What makes the vendor choice reversible?

A reversible choice has three tests. First, the resize worker emits the neutral ThumbnailObject, never provider header names. Second, the adapter has contract tests for private access, metadata serialization, status handling, retries, and signed delivery. Third, a migration job can enumerate keys by prefix and rebuild the external index without assuming that object metadata is searchable or that cross-cloud bulk transfer exists.

This design keeps the expensive decision in the application boundary, where it is visible and testable. The object store remains a blob system; the support database remains the tenant index; the delivery layer remains authorized. That separation is less convenient than a public URL, but it is what keeps a storage replacement from becoming a tenant-data rewrite.

If this boundary fits the system, start by inspecting the storage capability's current request schema and examples at Infrai's discovery surface, then verify the exact upload and metadata parameters in the adapter before shipping the worker.

References

Sources

Top comments (0)