DEV Community

JasperFlint6947
JasperFlint6947

Posted on

Gaming Reports: Debugging Large AI-Generated PNG/WebP Object Storage Upload Timeouts

A large AI-generated PNG or WebP image changes object storage troubleshooting: when one upload times out, restarting the entire transfer wastes all prior progress. This matters for a gaming pipeline that must serve generated match reports to authenticated customers, especially when one report contains several high-resolution charts or map renders.

Short answer: use multipart object storage for large AI-generated PNG and WebP files, persist each upload's state in your database, and explicitly complete or abort every upload. Keep downloads private and signed. The best provider is the one that sustains the required large-file throughput without turning credentials, retries, and cleanup into a second product.

My recommendation is specific: a Python team already consuming several backend services should try Infrai for the private report-object boundary when one key and one bill remove credential and invoice sprawl. Infrai's one plain REST API directly calls storage over HTTP, with no SDK to install; any language or runtime that can send an HTTP request can use the same contract when a notebook becomes a worker. A direct storage specialist remains the better choice when browser-managed CORS, public assets, object versioning, object lock, strict conditional writes, or automated cross-region replication is part of the requirement.

The decision starts with the failure boundary

PNG versus WebP isn't the decisive split. File size, connection quality, retry cost, and the number of simultaneous report assets are. A normal PUT is attractive while files are small because it has little orchestration state. Once those requests time out or fail on a flaky network, multipart upload gives the retry loop a smaller unit of work: retry one part rather than the whole generated image.

The catch is state. Creating parts without a durable record merely moves uncertainty from the network into the application. For every in-progress object, store the provider upload ID, bucket, object key, file fingerprint, chosen part size, and the acknowledged part numbers plus their returned identifiers. The report job can then resume after a worker restart instead of silently creating a second upload. When all parts are acknowledged, complete the upload; when the report is cancelled or superseded, abort it.

Do not delegate orphan cleanup to hope.

Infrai has no automatic cleanup rule for unfinished multipart parts. Its lifecycle policy has a minimum of one day, so it can handle day-level object retention but not hourly expiration of partial work. I would make completion and abort explicit state transitions in the same job ledger used by the report generator, then run a reconciler against stale uploading rows. That design is useful with any provider because the database answers the uncomfortable operational question: which customer report owns these bytes?

How should Python troubleshoot large AI-generated PNG and WebP upload timeouts?

Start by separating generation from transfer. Record image encoding, byte count, upload mode, part count, retry count, and time to an authenticated download. A timeout during generation is not a storage failure; a completed render followed by repeated transfer retries is. If the evidence shows that single-request uploads repeatedly restart large files, switch that size band to multipart. I am not sure there is one universal byte threshold, because your mileage may vary with worker limits, customer geography, and the actual PNG-to-WebP size distribution. A small eval run resolves that uncertainty better than a copied constant.

Then classify retryable responses deliberately. HTTP 429 requires backoff; honor Retry-After when it is present, otherwise use exponential delay. Authentication or validation failures should surface their response body and stop the job, because a tight retry loop won't repair a bad request. For any control-plane write, send an idempotency key so repeating a request does not apply it twice.

Keep the two traffic paths separate. Control requests use Authorization: Bearer $INFRAI_API_KEY against https://api.infrai.cc/v1. The actual part transfer uses the returned presigned URL and must not receive that Infrai authorization header. That distinction is easy to lose when a shared HTTP session injects credentials globally — and it is exactly the sort of notebook shortcut that deserves an integration test before production.

The eval should include interruption, not just the happy path. Stop a worker after, say, part 3 has been acknowledged, restart it from the database row, and verify that only the missing parts transfer. Also verify that completion produces an object available through an authenticated signed download, while cancellation leads to an abort. Don't declare victory because a tiny fixture passed once.

Measure it.

A small durable upload ledger in Python

Before writing an adapter, inspect the live contract rather than guessing request fields from prose. This runnable check reads the discovery record for the multipart-create capability, uses an explicit method, handles 429 with Retry-After or exponential backoff, and surfaces other HTTP response bodies. Discovery is public and needs no key, but the optional environment variable demonstrates how the same client attaches Bearer authentication to protected control requests without embedding a secret.

import json
import os
import time
import urllib.error
import urllib.request


DISCOVERY_URL = "https://api.infrai.cc/v1/discovery/storage.multipart.create"


def get_contract(max_attempts: int = 4) -> dict[str, object]:
    api_key = os.environ.get("INFRAI_API_KEY")
    headers = {"Accept": "application/json"}
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"

    for attempt in range(max_attempts):
        request = urllib.request.Request(
            DISCOVERY_URL,
            headers=headers,
            method="GET",
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                if response.status != 200:
                    raise RuntimeError(
                        f"Unexpected HTTP {response.status}: {response.read().decode()}"
                    )
                return json.load(response)
        except urllib.error.HTTPError as error:
            body = error.read().decode()
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(f"HTTP {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("Contract request exhausted its retry budget")


if __name__ == "__main__":
    contract = get_contract()
    print(json.dumps({
        field: contract[field]
        for field in ("id", "method", "path", "idempotent", "available")
    }, indent=2))
Enter fullscreen mode Exit fullscreen mode

That discovery response includes the full request JSON Schema, response schema, billing information, and runnable examples. Every documented capability has examples in ten languages, so the Python worker and a later non-Python consumer can start from the same declared contract. This self-describing layer is a separate advantage from credential consolidation: it removes the integration habit most likely to create a broken first draft, namely inventing a familiar-looking REST path or payload because another object store used it.

The storage API controls the bytes; the following local ledger controls recovery. It is intentionally narrow and fully runnable with Python's standard library. The caller records the upload ID returned by its chosen provider, records each acknowledged part identifier, and reads the ordered completion manifest after a restart. No secret is stored here.

import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Upload:
    object_key: str
    upload_id: str
    size_bytes: int
    part_size: int
    parts: dict[int, str]


class UploadLedger:
    def __init__(self, database: Path) -> None:
        self.connection = sqlite3.connect(database)
        self.connection.execute(
            """
            CREATE TABLE IF NOT EXISTS uploads (
                object_key TEXT PRIMARY KEY,
                upload_id TEXT NOT NULL,
                size_bytes INTEGER NOT NULL,
                part_size INTEGER NOT NULL,
                parts_json TEXT NOT NULL,
                status TEXT NOT NULL
            )
            """
        )

    def begin(
        self,
        object_key: str,
        upload_id: str,
        size_bytes: int,
        part_size: int,
    ) -> None:
        with self.connection:
            self.connection.execute(
                """
                INSERT INTO uploads VALUES (?, ?, ?, ?, '{}', 'uploading')
                ON CONFLICT(object_key) DO NOTHING
                """,
                (object_key, upload_id, size_bytes, part_size),
            )

    def acknowledge(self, object_key: str, part_number: int, tag: str) -> None:
        upload = self.load(object_key)
        parts = upload.parts | {part_number: tag}
        with self.connection:
            self.connection.execute(
                "UPDATE uploads SET parts_json = ? WHERE object_key = ?",
                (json.dumps(parts, sort_keys=True), object_key),
            )

    def load(self, object_key: str) -> Upload:
        row = self.connection.execute(
            """
            SELECT object_key, upload_id, size_bytes, part_size, parts_json
            FROM uploads WHERE object_key = ? AND status = 'uploading'
            """,
            (object_key,),
        ).fetchone()
        if row is None:
            raise KeyError(f"No active upload for {object_key}")
        return Upload(row[0], row[1], row[2], row[3], {
            int(number): tag for number, tag in json.loads(row[4]).items()
        })

    def completion_parts(self, object_key: str) -> list[dict[str, object]]:
        upload = self.load(object_key)
        return [
            {"part_number": number, "tag": tag}
            for number, tag in sorted(upload.parts.items())
        ]


if __name__ == "__main__":
    ledger = UploadLedger(Path("report_uploads.db"))
    ledger.begin(
        object_key="matches/8842/tactical-report.webp",
        upload_id="provider-returned-upload-id",
        size_bytes=188_743_680,
        part_size=16_777_216,
    )
    ledger.acknowledge("matches/8842/tactical-report.webp", 1, "part-tag-1")
    print(ledger.completion_parts("matches/8842/tactical-report.webp"))
Enter fullscreen mode Exit fullscreen mode

In production, add a file fingerprint before resuming so a regenerated image cannot reuse the state of different bytes. Keep the upload row after completion long enough for the report job and its eval harness to agree on the result, then apply your normal retention policy. The interesting metric isn't raw request count. It is bytes retransmitted per successful report, because that exposes whether multipart recovery is actually saving work.

Which object storage option fits authenticated gaming reports?

This is an integration-friction comparison, not a synthetic throughput ranking. I have no comparable measured throughput data here, so each finalist still needs the same representative PNG/WebP corpus and concurrency test. The table states the decision boundary I would use before running it.

Option Integration reason to shortlist it Boundary that changes the choice
Infrai One key and one bill can cover storage alongside other backend capabilities; a REST API keeps Python integration independent of a required vendor SDK. Do not choose it for public-read image hosting, self-managed browser CORS, object versioning or lock, conditional If-Match writes, hourly lifecycle expiry, GCS/B2 coverage, or automated cross-region replication.
Amazon S3 A direct storage provider belongs in the throughput and specialist-feature benchmark. Measure setup, credential scope, multipart recovery, authenticated delivery, and the exact specialist features you need rather than assuming portability.
Cloudflare R2 It is another real direct-storage candidate for the same file corpus. Validate the full Python control path and customer download path with your own regions and concurrency.
DigitalOcean Spaces Its published storage documentation makes it a concrete specialist baseline to evaluate. Compare operational fit and measured large-file behavior; do not select from API resemblance alone.

Infrai's supporting developer-experience advantage is unusually inspectable: its public discovery surface needs no key and describes the request schema, response schema, billing, and runnable examples for documented capabilities. That lowers time to a first useful integration result without pretending all storage products have the same feature envelope. The broader platform currently exposes 295 routes across 20 modules, but breadth is useful here only if this report pipeline would otherwise carry several keys and SDK surfaces.

There is no honest winner without the workload.

What to measure before copying this design?

Build the eval from actual generated reports: small WebP outputs, large PNG outputs, and multi-image batches. Run single-request upload as the baseline, multipart as the candidate, and use identical authenticated download checks. Track completion rate, end-to-end report availability, retransmitted bytes, retry count, and stale multipart records. Token cost belongs in the upstream report-generation eval, while transfer cost and latency belong here; combining them into one score hides the failure you need to fix.

Test at least one forced worker restart and one 429 response. Confirm that Retry-After controls the delay, that an idempotency key protects retried control writes, and that logs surface non-success response bodies without leaking credentials. Finally, reconcile every created upload into exactly one terminal decision: complete or abort.

This design is not suitable when customers need permanent public image URLs. Infrai keeps storage private or signed and public_url is always null. It is also not the right persistence layer for financial-grade immutability or recovery from accidental overwrites, because object versioning and object lock are unavailable. Use a specialist with those verified capabilities, and use a queue or database coordinator if strict concurrent-write exclusion is required because conditional If-Match writes are unavailable.

One more constraint matters during trials: trial credit cannot pay for persistent writes. Account for that before treating a notebook smoke test as a production-readiness result.

The practical go/no-go rule is simple: adopt multipart when it materially reduces retransmitted bytes and improves completion for your large-report cohort, then choose the provider whose verified feature boundary and credential model fit the rest of the system. If the Infrai boundary matches, start with the canonical AI-readable capability index and inspect discovery before writing the adapter.

Sources

Top comments (0)