Replacing an avatar safely when two uploads race for the same key
Object storage has no compare-and-swap, so a same-key overwrite can't arbitrate a race. The immutable-key plus guarded-pointer pattern, and an idempotency trap to avoid.
The safe way to replace a user’s avatar is not to overwrite it. Write the new picture to a fresh, content-derived key, then flip a pointer row in your database with an update guarded by a revision number, then prune the old object once nothing references it. Infrai’s PUT /v1/storage/object/put/{bucket}/{key} carries no If-Match or expected-etag field, so the storage layer has no way to reject a stale write — the arbitration has to happen a layer up, in a system that can do a conditional update.
Everything below is measured against a private Infrai bucket, because the failure modes here are specific and a couple of them are genuinely surprising.
What a same-key overwrite does
Nothing clever. The bytes are replaced, the etag changes, last_modified moves, and the previous version is gone — there’s no version id anywhere in the response because the API doesn’t expose object versioning.
export INFRAI_API_KEY=your_infrai_api_key
python3 -c "import base64,json; print(json.dumps({'data_base64': base64.b64encode(b'avatar-v1').decode(), 'content_type': 'image/png'}))" > v1.json
curl -sS -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kbg-avatarswap-0726/avatars/u_9001/current.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @v1.json
{
"ok": true,
"data": {
"bucket_id": "bkt_35718cfe04dc40a699e86c",
"key": "avatars/u_9001/current.png",
"size_bytes": 9,
"etag": "0fef55c260eed80ab1c3eb24f48b81c6",
"content_type": "image/png",
"created_at": "2026-07-26T05:08:49.086227Z"
}
}
The etag is your only witness that a write took effect. Hold onto it.
The idempotency trap
This one costs people an afternoon. object/put accepts an idempotency_key field in the body, and the schema notes that the SDK auto-derives one from bucket, key and content when you omit it. Send your own — say, avatar-u9001, which looks like a sensible stable choice — and the second call with different bytes returns the first object: same etag, same created_at, and idempotent_replay still reported as false in the response metadata.
python3 -c "import base64,json; print(json.dumps({'data_base64': base64.b64encode(b'avatar-v2-different').decode(), 'content_type': 'image/png', 'idempotency_key': 'avatar-u9001'}))" > v2.json
curl -sS -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kbg-avatarswap-0726/avatars/u_9001/current.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @v2.json
curl -sS "https://api.infrai.cc/v1/storage/object/get/kbg-avatarswap-0726/avatars/u_9001/current.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The read comes back with the v1 bytes. Your user uploaded a new picture, your API said ok: true, and nothing changed — a silent failure, which is the worst kind.
The HTTP header behaves differently and better. Send Idempotency-Key: hdr-avatar-1 on the first write and the same header with different bytes on the second, and you get an HTTP 409 instead of a lie:
curl -sS -o /dev/null -w "%{http_code}\n" -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kbg-avatarswap-0726/avatars/u_9001/current.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: hdr-avatar-1" \
--data-binary @v2.json
So: a per-user idempotency key is a trap, a per-attempt one is fine. If you want retry safety on avatar writes, derive the key from the content hash — which is exactly what you’re about to use as the object key anyway.
Immutable keys, then a guarded pointer
Hash the incoming bytes, write to avatars/u_9001/<hash>.png, and make the database the single arbiter of which hash is current:
CREATE TABLE avatars (
user_id text PRIMARY KEY,
object_key text NOT NULL,
revision bigint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- The guard: a slower request can never demote a newer one.
UPDATE avatars
SET object_key = $2, revision = $3, updated_at = now()
WHERE user_id = $1
AND revision < $3;
| Strategy | Race outcome | Rollback | Cleanup burden |
|---|---|---|---|
| Overwrite the same key | Non-deterministic; no way to detect the loser | None — bytes are gone | Zero |
| Immutable key + guarded pointer row | Deterministic; the higher revision wins | Repoint the row | Prune orphans |
| Bucket versioning (S3, not available here) | Storage layer arbitrates via conditional write | Restore a version id | Lifecycle on noncurrent versions |
Two concurrent replaces now both succeed at the storage layer — they write different keys, so they don’t collide at all — and the database decides which one the product shows. The loser’s object is orphaned, not destructive, and a prune job collects it later.
import { createHash } from "node:crypto";
const API = "https://api.infrai.cc";
const BUCKET = "kbg-avatarswap-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
/** @param {Buffer} bytes @param {string} userId @param {number} revision */
export async function replaceAvatar(bytes, userId, revision, db) {
if (bytes.length > 5 * 1024 * 1024) throw new Error("avatar over 5 MB");
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
const objectKey = `avatars/${userId}/${hash}.png`;
const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${objectKey}`, {
method: "PUT",
headers: {
authorization: `Bearer ${KEY}`,
"content-type": "application/json",
"Idempotency-Key": `avatar-${userId}-${hash}`,
},
body: JSON.stringify({
data_base64: bytes.toString("base64"),
content_type: "image/png",
metadata: { "user-id": userId, "avatar-rev": String(revision) },
}),
});
if (!res.ok) throw new Error(`put failed: ${res.status} ${await res.text()}`);
const previous = await db.oneOrNone("SELECT object_key FROM avatars WHERE user_id = $1", [userId]);
const won = await db.result(
"UPDATE avatars SET object_key = $2, revision = $3, updated_at = now() WHERE user_id = $1 AND revision < $3",
[userId, objectKey, revision],
);
return { objectKey, applied: won.rowCount === 1, orphaned: won.rowCount === 1 ? previous?.object_key : objectKey };
}
Notice the metadata keys. user-id works; user_id does not — an underscore in a metadata key breaks the vendor’s signature computation and the write comes back as a 503 rather than a 400. A 503 reads like an outage, which is why this one usually costs an afternoon before anyone suspects the key name. Hyphenated keys round-trip cleanly and show up in GET /v1/storage/object/head/{bucket}/{key}:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kbg-avatarswap-0726/avatars/u_9001/meta.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns "metadata": {"avatar-rev": "7", "user-id": "9001"} alongside the size and etag. One more thing worth flagging from the schema: POST /v1/storage/object/set_metadata/{bucket}/{key} replaces the metadata map rather than merging into it, so a partial update wipes the keys you didn’t send.
Pruning the loser
Once the pointer has moved and any cached URL has expired, delete the orphan. Batch it — POST /v1/storage/object/delete_batch/{bucket} takes up to 1000 keys and is free:
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/kbg-avatarswap-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"keys":["avatars/u_9001/orphan-a.png","avatars/u_9001/orphan-b.png"]}'
Give it a lag of a day or so. A signed URL minted before the swap still resolves until its TTL runs out, and deleting the object underneath it turns a working image into a 404 mid-session.
Cost, and when something else fits better
Writes are $0.0001 per object/put, reads $0.0002 per object/get; head, list and batch delete are free and rate-limited, and new accounts start with $2 free credit. Figures verified 2026-07-26 — this API’s rates have trended down, so check rather than trust:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'].startswith('storage.object')])"
If you want the storage layer itself to arbitrate, S3 with bucket versioning plus conditional writes gives you real compare-and-swap semantics and Infrai doesn’t support that; use S3 when a stale overwrite is a correctness bug you can’t push into your own database. Cloudflare R2 is the cheaper choice if egress dominates your bill. The argument for doing it here is that the pointer table, the resize job and the failure alert are all reachable with the same key — one account, one invoice, one place to look when an avatar goes missing.