Conditional writes & idempotency
A conditional write gates a write on the object's current state, so two writers racing the same key cannot silently clobber each other. An idempotency key makes a retried write safe to replay without writing twice.
This page covers both, where each lives across the clients, and the safe-retry patterns to reach for. The headline distinction:
- Conditional writes (
If-None-Match/If-Match) are a native-client feature. The nativeputObjecttakes them directly. The S3PutObjectdoes not, so use the native client or a conditional copy for an existing object. - Idempotency keys work on both clients.
Create-only: If-None-Match: "*"
If-None-Match: "*" writes only when the key is absent. If something is already there, the write fails with a 412 precondition error and nothing is overwritten. This is the atomic "create, do not clobber" primitive: two callers racing to create the same key, exactly one wins. It is a native-client write, so it always shows below the surface toggle.
import { isNativePreconditionFailed } from "@kelphect/sdk";
try {
await native.putObject("reports", "q1/summary.txt", body, { ifNoneMatch: "*" });
// we created it
} catch (err) {
if (isNativePreconditionFailed(err)) {
// someone else created it first
} else throw err;
}_, err := native.PutObject(ctx, lockwellnative.PutObjectInput{
Bucket: "reports", Key: "q1/summary.txt", Body: body,
IfNoneMatch: "*",
})
if lockwellnative.IsPreconditionFailed(err) {
// someone else created it first
} else if err != nil {
return err
}import com.lockwell.sdk.nativeapi.NativeException;
import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions;
try {
native.putObject("reports", "q1/summary.txt", body,
new PutOptions().ifAbsent()); // If-None-Match: *
} catch (NativeException e) {
if (e.statusCode() == 412) {
// someone else created it first
} else throw e;
}Overwrite-only: If-Match: "<etag>"
If-Match: "<etag>" writes only when the current object's ETag matches the value you pass. If the object changed since you last read it (its ETag differs), the write fails with 412 and your update is rejected. This is optimistic concurrency: read, compute a new value from what you read, then write back conditioned on the ETag you started from. A mismatch means someone else wrote in between, so re-read and retry. This too is a native-client write.
// Read, mutate, write-back guarded by the ETag we read:
const cur = await native.getObject("config", "settings.json");
const next = patch(cur.body);
try {
await native.putObject("config", "settings.json", next, { ifMatch: cur.etag });
} catch (err) {
if (isNativePreconditionFailed(err)) {
// lost the race; re-read and retry
} else throw err;
}cur, err := native.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "config", Key: "settings.json"})
if err != nil {
return err
}
etag := cur.ETag
body, _ := io.ReadAll(cur)
cur.Close()
_, err = native.PutObject(ctx, lockwellnative.PutObjectInput{
Bucket: "config", Key: "settings.json", Body: bytes.NewReader(patch(body)),
IfMatch: etag,
})
if lockwellnative.IsPreconditionFailed(err) {
// lost the race; re-read and retry
}var cur = native.getObject("config", "settings.json");
byte[] body = cur.body().readAllBytes();
String etag = cur.etag();
cur.close();
native.putObject("config", "settings.json", patch(body),
new PutOptions().ifMatch(etag));
// A 412 NativeException means the ETag moved; re-read and retry.The ETag is the object's content identity. headObject or any read returns it, and so does the PutObject result, so you can chain writes without an extra round-trip.
Conditional copy (the S3 path to conditional writes)
Because the S3 PutObject does not take If-None-Match / If-Match, the S3 client reaches conditional behavior through CopyObject. The native copy adds destination preconditions evaluated atomically at commit:
requireAbsentcopies only when the destination is absent (create-only).requireMatchEtagcopies only when the destination's ETag matches (overwrite-only).
// Create-only via copy: never clobber an existing snapshot.
await native.copyObject("snapshots", "latest.json", {
sourceBucket: "live",
sourceKey: "state.json",
requireAbsent: true, // 412 if snapshots/latest.json already exists
});_, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{
Bucket: "snapshots", Key: "latest.json",
SourceBucket: "live", SourceKey: "state.json",
RequireAbsent: true, // 412 if snapshots/latest.json already exists
})native.copyObject("snapshots", "latest.json", "live", "state.json",
new CopyOptions().ifAbsent()); // 412 if snapshots/latest.json already existsCopy-source conditionals (gate on the source instead of the destination) are on both clients. See copying objects.
Idempotency keys
An idempotency key makes a write replay-safe: a retry that carries the same key and the same payload returns the original stored result instead of writing a second time. Reach for it when a network blip might make you re-send a PutObject you are not sure landed.
It works on both clients, but the native client has one extra requirement.
Native: pair the key with a body checksum
The native putObject streams the body and never buffers it, so it cannot hash the payload to prove a replay is the same bytes. You supply that proof: pair the idempotency key with a checksums entry. The server verifies the digest before committing any bytes, and uses the key plus the verified digest to collapse duplicate writes.
A native idempotent putObject needs a body checksum. Without a checksums entry, the streaming write has
no integrity proof and the server cannot confirm a retry is the same payload. :::
import { sha256ChecksumBase64 } from "@kelphect/sdk";
const body = "invoice-payload";
await native.putObject("billing", "invoices/2026-001.json", body, {
idempotencyKey: "invoice-2026-001",
checksums: { sha256: await sha256ChecksumBase64(body) },
});import (
"crypto/sha256"
"encoding/base64"
)
sum := sha256.Sum256([]byte("invoice-payload"))
_, err := native.PutObject(ctx, lockwellnative.PutObjectInput{
Bucket: "billing", Key: "invoices/2026-001.json",
Body: strings.NewReader("invoice-payload"),
IdempotencyKey: "invoice-2026-001",
Checksums: map[string]string{"sha256": base64.StdEncoding.EncodeToString(sum[:])},
})import java.security.MessageDigest;
import java.util.Base64;
byte[] body = "invoice-payload".getBytes();
String sha256 = Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(body));
native.putObject("billing", "invoices/2026-001.json", body,
new PutOptions()
.idempotencyKey("invoice-2026-001")
.checksum("sha256", sha256));::::
Supported algorithms include sha256, sha1, crc32, and crc32c. See checksums for the full set.
S3: the buffered PutObject
The S3 PutObject buffers the body and signs its exact SHA-256, so an idempotency key needs no extra checksum: the signed body hash is the integrity proof. Pass WithIdempotencyKey / idempotencyKey and the SDK signs the header so it cannot be stripped or altered in transit.
await s3.putObject("billing", "invoices/2026-001.json", Buffer.from(body), {
idempotencyKey: "invoice-2026-001",
});_, err := s3.PutObject(ctx, "billing", "invoices/2026-001.json", body,
lockwellsdk.WithIdempotencyKey("invoice-2026-001"),
)s3.putObject("billing", "invoices/2026-001.json", body,
new LockwellClient.PutOptions().idempotencyKey("invoice-2026-001"));The S3 streaming upload (putObjectStream) does not accept an idempotency key: its trailing checksum is not known when the write is reserved. Use the buffered PutObject when you need idempotency, or multipart upload for a large object (complete-multipart accepts an idempotency key).
How idempotency interacts with retries
The native client refreshes its token before expiry and re-mints once on a 401, but it does not auto-replay a streamed write body. Carry an idempotency key (with its checksum) so a retry you issue is collapsed server-side. Retry tuning is on Errors & retries.
The S3 client's automatic retry policy is idempotency-aware. It always retries safe methods (GET, HEAD, DELETE), and it retries a PutObject / POST only when the request carries an idempotency key and has a re-readable buffered body. A streaming body is never auto-retried (the reader is already consumed). This is exactly why an idempotency key matters: it is what lets the client safely replay a write after a transport error or a 5xx.
// Default policy: up to 3 attempts with backoff + jitter. A keyed PutObject is
// eligible for automatic retry; without the key it is attempted once.
s3, _ := lockwellsdk.New(endpoint, creds,
lockwellsdk.WithRetryPolicy(lockwellsdk.DefaultRetryPolicy()))Choosing the right guard
| You want to... | Use |
|---|---|
| Create a key only if it does not exist | native putObject with ifNoneMatch: "*", or a copy with requireAbsent |
| Overwrite only if unchanged since you read it | native putObject with ifMatch: "<etag>", or a copy with requireMatchEtag |
| Make a retried write replay safely | an idempotency key (native: pair with a checksum) |
| Copy only if the source still matches | copy-source ifMatch / ifNoneMatch (both clients) |
Next steps
- Upload & download: the write these conditions gate.
- Copying objects: destination and source conditionals on copy.
- Checksums: the body-integrity digests native idempotency needs.
- Errors & retries: the retry policy that replays keyed writes.