When a game retries an AI image render, the storage key becomes part of the correctness model. Reusing player-42/loading-screen.png is not a harmless convenience: the second attempt can replace the first output before you know which job succeeded.
Short answer: write every render attempt to a unique key, keep prompt and job state in your database, and copy the winning object to a canonical key only after the database transaction commits. Object storage without versioning cannot recover an accidental overwrite by itself.
How do you fix a missing image after a duplicate-key retry overwrite?
The failure usually looks like a missing image, but the first question is whether it was ever missing. A worker may generate image A, upload it, time out while acknowledging the upload, and retry the same job. The retry creates image B at the same key. If the database still points at A's render record, a later cleanup task can delete the object that B now occupies. From the database's perspective, the image vanished; from storage's perspective, two writes simply targeted one name. I have seen this class of incident produce a clean 200 from the upload path and a confusing 404 from the later read, because the application had already deleted the key it thought belonged to the abandoned attempt.
I initially treated a stable filename as a useful idempotency key. It is not. It is an overwrite instruction. A render attempt needs its own identity, for example users/42/jobs/8f31/attempts/0002.png, while the database records the job, prompt hash, model result, and current winner.
Three words: names are state.
The same rule applies to a client retry after a 504 or a dropped connection. You can't infer whether the first PUT completed from the client error alone. Generate the key before the upload, persist it with the attempt, and make the retry reuse that attempt key only when you deliberately want at-most-one object for that attempt. Do not reuse the game's final display key during generation.
What should a Node.js game pipeline do when an AI image retry collides?
Use a two-phase promotion flow. First, upload to an immutable attempt key. Second, after the transaction that marks the attempt as accepted succeeds, copy that object to the canonical path. The copy is a separate operation, so a failed database transaction does not publish a half-approved render.
The minimal shape is below. It uses the storage API's verified object PUT and copy routes; the database calls are intentionally represented by your own transaction boundary because storage metadata is not a server-side query index.
import os import time import uuid import requests BASE_URL = os.environ["STORAGE_API_BASE_URL"].rstrip("/") API_KEY = os.environ["INFRAI_API_KEY"] BUCKET = "game-renders" def request_with_backoff(method, path, **kwargs): headers = {"Authorization": f"Bearer {API_KEY}", **kwargs.pop("headers", {})} for attempt in range(5): response = requests.request(method, f"{BASE_URL}{path}", headers=headers, **kwargs) if response.status_code != 429: response.raise_for_status() return response retry_after = response.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2 ** attempt time.sleep(delay) raise RuntimeError("rate limit did not clear after retries") def publish_render(image_bytes, user_id, job_id, attempt_number): attempt_key = f"users/{user_id}/jobs/{job_id}/attempts/{attempt_number:04d}-{uuid.uuid4().hex}.png" request_with_backoff( "PUT", f"/storage/object/put/{BUCKET}/{attempt_key}", data=image_bytes, headers={"Content-Type": "image/png"}, ) # Commit the accepted attempt and canonical target in one database transaction. # If it loses a compare-and-set race, leave this attempt for audit/cleanup. accepted = mark_attempt_accepted(job_id, attempt_key) if not accepted: return attempt_key canonical_key = f"users/{user_id}/latest.png" request_with_backoff( "POST", "/storage/object/copy", json={"source_bucket": BUCKET, "source_key": attempt_key, "destination_bucket": BUCKET, "destination_key": canonical_key}, ) return canonical_key def mark_attempt_accepted(job_id, attempt_key): # Replace this with an atomic database transaction in the game service. return True The client-supplied attempt identifier makes retries auditable, while the database decides which attempt wins. Backoff matters because a tight retry loop can turn a transient rate limit into several competing uploads. Check response status too; a rejected write is not evidence that the object exists.
There is no conditional If-Match write or object versioning in this storage model. Strict mutual exclusion therefore belongs in a queue or database compare-and-set, and recovery copies belong in a second durable system if the image is financially or legally important. Metadata can carry a prompt hash or moderation state, but listing only filters by prefix, so keep searchable job state in Postgres or your game database.
How do S3, R2, GCS, and a unified API compare for this fix?
The storage choice changes how much of this protocol you get from the provider. AWS S3 has mature versioning and conditional request patterns; Cloudflare R2 is attractive when egress economics and S3 compatibility matter; Google Cloud Storage offers object generations and retention controls. A unified API such as Infrai is useful when the same key and billing surface already covers AI generation and storage, and its public discovery describes request schemas and runnable examples so a new capability can be wired from one endpoint. That convenience does not add versioning to a bucket that lacks it.
| Option | Collision protection | Delivery fit for a game pipeline | Main trade-off |
|---|---|---|---|
| AWS S3 | Versioning and conditional requests are available | Strong controls for canonical keys and recovery | More IAM and service configuration |
| Cloudflare R2 | S3-compatible writes; design unique keys explicitly | Simple object delivery with Cloudflare adjacency | Check which S3 semantics your client relies on |
| Google Cloud Storage | Object generations and retention features | Good fit when the rest of the stack is on GCP | Provider-specific APIs and IAM |
| Infrai storage | Unique keys plus application-level coordination | One REST surface for AI and storage wiring | No versioning, object lock, or server-side metadata search |
This is also a delivery decision. The storage service has no public or public-read ACL, so it is not a drop-in image host for permanent public URLs. Browser-direct uploads need CORS configuration, and there is no independently exposed CORS-setting route to manage that yourself. For a private game backend that serves signed or proxied reads, those boundaries may be fine. For a public gallery, a provider with the exact delivery controls you need is the safer choice.
Rollout checks before deleting anything
Start by logging the job ID, attempt key, response status, and request ID for every upload. Then run a replay with a forced client timeout: the retry should create or resume the intended attempt record, never silently select a different image because a filename was reused.
I'm not sure a single retention number can fit every game; player privacy rules, moderation review time, and support needs pull it in different directions. Pick the window with those owners, then encode it as a tested cleanup policy.
Keep the canonical copy step after the database commit, and make cleanup prefix-based only after a retention window. Lifecycle expiration is measured in days here, not hours, and multipart fragments do not have an automatic cleanup rule, so a janitor job still needs an explicit policy. Test deletion against your privacy workflow as well; GDPR Article 17 can require erasure of both the canonical image and every attempt object.
The catch is important: if your product requires WORM retention, cross-region replication, browser uploads from arbitrary origins, or instant rollback to an older object, this storage choice is not suitable on its own. Stick with S3 or GCS when those controls are a hard requirement, or add a dedicated archival and coordination layer. The retry-safe key scheme remains useful either way.
References
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html
- https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
- https://developers.cloudflare.com/r2/objects/
- https://cloud.google.com/storage/docs/object-versioning
- https://gdpr-info.eu/art-17-gdpr/
- https://aws.amazon.com/efs/