Errors and retries
Every Lockwell SDK surface raises a structured error carrying a stable machine-readable code, the HTTP status, and a request id you can correlate with a server-side audit row. Each surface has its own error type and a set of Is* helpers so you branch on the failure without parsing strings.
Error types per surface
| Surface | Go type | Node class | Java exception |
|---|---|---|---|
| S3 data plane | *lockwellsdk.APIError | APIError | ApiException |
| Native data plane | *lockwellnative.NativeError | NativeError | NativeException |
| Admin API | *lockwelladmin.AdminError | AdminError | AdminException |
Each error exposes the same four fields: a Code (S3-style like NoSuchKey, or native/admin codes like not_found), a human Message, the StatusCode, and a RequestID. Secrets are never included in an error message; the SDKs redact credentials and query strings before wrapping a transport error.
The Is* helpers
Branch on the helper, not on the raw status, so your code reads cleanly and survives a code change on the server.
S3 client
The S3 client ships one helper, IsNotFound, which is true for NoSuchKey, NoSuchBucket, NoSuchUpload, NotFound, or a bare 404. For everything else, inspect the error's Code and StatusCode.
import { isNotFound } from "@kelphect/sdk";
try {
await s3.headObject("reports", "missing.txt");
} catch (err) {
if (isNotFound(err)) {
// create it, or treat as absent
} else if (err.statusCode === 412) {
// a conditional precondition failed
} else throw err;
}_, err := s3.HeadObject(ctx, "reports", "missing.txt")
if lockwellsdk.IsNotFound(err) {
// create it, or treat as absent
}
var api *lockwellsdk.APIError
if errors.As(err, &api) && api.StatusCode == http.StatusPreconditionFailed {
// a conditional precondition failed
}import com.lockwell.sdk.ApiException;
try {
s3.headObject("reports", "missing.txt");
} catch (ApiException e) {
if (e.isNotFound()) {
// create it, or treat as absent
} else if (e.statusCode() == 412) {
// a conditional precondition failed
} else throw e;
}Native client
The native error has a helper for each status it can return:
| Status | Meaning | Go | Node | Java |
|---|---|---|---|---|
| 401 | bad/missing/expired token, or a revoked key | IsUnauthorized | isNativeUnauthorized | e.isUnauthorized() |
| 403 | access-key scope or bucket-policy denial | IsForbidden | isNativeForbidden | e.isForbidden() |
| 404 | no such bucket or key | IsNotFound | isNativeNotFound | e.isNotFound() |
| 409 | bucket already exists | IsAlreadyExists / IsConflict | isNativeConflict | e.isConflict() |
| 412 | conditional precondition not met | IsPreconditionFailed | isNativePreconditionFailed | e.isPreconditionFailed() |
| 507 | tenant storage quota exceeded | IsQuotaExceeded | isNativeQuotaExceeded | e.isQuotaExceeded() |
import { isNativePreconditionFailed, isNativeQuotaExceeded } from "@kelphect/sdk";
try {
await native.putObject("reports", "once.txt", body, { ifNoneMatch: "*" });
} catch (err) {
if (isNativePreconditionFailed(err)) {
// already exists
} else if (isNativeQuotaExceeded(err)) {
// tenant is over quota
} else throw err;
}_, err := native.PutObject(ctx, lockwellnative.PutObjectInput{
Bucket: "reports", Key: "once.txt", Body: body, IfNoneMatch: "*",
})
switch {
case lockwellnative.IsPreconditionFailed(err):
// already exists
case lockwellnative.IsQuotaExceeded(err):
// tenant is over quota
case err != nil:
return err
}import com.lockwell.sdk.nativeapi.NativeException;
try {
native.putObject("reports", "once.txt", body, new PutOptions().ifAbsent());
} catch (NativeException e) {
if (e.isPreconditionFailed()) {
// already exists
} else if (e.isQuotaExceeded()) {
// tenant is over quota
} else throw e;
}Admin client
The admin error exposes IsNotFound, IsForbidden, and IsPreconditionFailed (Go); the Node AdminError ships isAdminNotFound; the Java AdminException exposes isNotFound, isForbidden, isUnauthorized, and isRetentionBlocked (the 412 retention-blocked case). See tenancy and auth for the admin surface.
Status mapping
A given failure maps to one status across all surfaces, so a guard means the same thing everywhere:
| Status | Cause |
|---|---|
| 400 | malformed request, or a checksum mismatch (BadDigest) on a write |
| 401 | missing/invalid/expired credentials or token, or a revoked key |
| 403 | scope or bucket-policy denial, or a delete blocked by retention/legal hold |
| 404 | no such bucket, key, version, or upload |
| 409 | a conflicting create (bucket already exists) |
| 412 | a conditional If-Match / If-None-Match precondition was not met |
| 429 | rate limited |
| 5xx | a server-side or transient failure |
| 507 | the tenant storage quota was exceeded |
The retry policy
The S3 client retries safe and idempotent requests on transient failures. A policy has four knobs:
| Field | Meaning |
|---|---|
MaxAttempts | total attempts including the first; a value of 1 disables retries |
BaseBackoff | the delay before the second attempt; it doubles each attempt |
MaxBackoff | the cap on the exponential delay |
Jitter | the fraction (0..1) of the delay added as uniform random jitter |
The two ready-made policies:
- Default: up to 3 attempts, 100ms base backoff doubling to a 2s cap, with full jitter.
- Disabled: every request is attempted exactly once.
The backoff before attempt n+1 is min(MaxBackoff, BaseBackoff * 2^(n-1)), plus uniform jitter in [0, Jitter*delay]. Jitter keeps a fleet of clients from synchronizing their retries after a shared blip.
import { Client, RetryPolicy } from "@kelphect/sdk";
// The Node S3 client defaults to one attempt; opt in with a policy:
const s3 = new Client({
endpoint: "https://objects.example.com",
accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID,
secretKey: process.env.LOCKWELL_SECRET_KEY,
retry: RetryPolicy.default(), // or { maxAttempts: 5 }, or RetryPolicy.disabled()
});// The Go S3 client retries by default; tune or disable it:
s3, err := lockwellsdk.New(endpoint, creds,
lockwellsdk.WithRetryPolicy(lockwellsdk.DefaultRetryPolicy()))
// Turn retries off:
s3, err = lockwellsdk.New(endpoint, creds,
lockwellsdk.WithRetryPolicy(lockwellsdk.DisabledRetryPolicy()))import com.lockwell.sdk.RetryPolicy;
// The Java S3 client defaults to one attempt; enable on the builder:
var s3 = LockwellClient.builder()
.endpoint("https://objects.example.com")
.credentials(creds)
.retryPolicy(RetryPolicy.defaults()) // or RetryPolicy.of(...), or .disabled()
.build();The Go S3 client retries by default. The Node and Java S3 clients default to a single attempt for backward compatibility; pass a policy to opt in.
Which requests retry
A request is replayed only when it is safe to replay:
GET,HEAD, andDELETEare idempotent by HTTP semantics, so they always retry under the policy.- A buffered-body
PUTorPOSTretries only when it carries an idempotency key, so the server collapses a duplicate effect. - A streaming-body upload is never retried; its source is already consumed.
Attach an idempotency key to a write you want the client to retry. It is what lets a buffered PutObject or
POST replay safely after a 5xx or a transport error. :::
A response retries on a 5xx or a 429. A 4xx other than 429 is a client error and is never retried. A transport-level error (connection refused, reset, timeout) is retried, because the SDK only ever reaches the retry path for a request it already decided is safe to replay. Each attempt is re-signed with a fresh timestamp, since SigV4 signatures are time-bound.
Retries on the native client
The native clients do not take a transient-retry policy. They have their own automatic resilience: the bearer token is refreshed proactively before it expires, and a single 401 triggers exactly one token re-mint and one replay. Token acquisition is single-flight, so a burst of concurrent calls mints at most one token. A streaming PUT or part upload mints a fresh token up front (a streaming body cannot be replayed) rather than relying on a 401 retry. Wrap your own retry loop around a native call if you want to retry transient 5xx responses, and pair writes with an idempotency key so a replay is safe.
Idempotency
Idempotency is how a write becomes safe to retry. Attach an idempotency key to a PutObject or CompleteMultipartUpload and a retry carrying the same key replays the stored result instead of writing twice. On the S3 client the key is sent as the signed X-Lockwell-Idempotency-Key header, so it cannot be stripped or altered in transit. On the native client the key is the Idempotency-Key header, paired with a checksum so the server can confirm a replay is the same payload (the streaming body is never buffered to compare).
await s3.putObject("billing", "invoices/2026-001.json", body, {
idempotencyKey: "invoice-2026-001",
});The full conditional-write and idempotency model, including create-only and overwrite-only writes, is on the conditional writes page.
Related
- Conditional writes and idempotency for create-only writes and the idempotency key.
- Checksums for the
BadDigestfailure path. - Multipart uploads for which multipart calls retry.
- S3 operations reference for the full operation matrix.