Skip to content

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

SurfaceGo typeNode classJava exception
S3 data plane*lockwellsdk.APIErrorAPIErrorApiException
Native data plane*lockwellnative.NativeErrorNativeErrorNativeException
Admin API*lockwelladmin.AdminErrorAdminErrorAdminException

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.

ts
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;
}
go
_, 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
}
java
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:

StatusMeaningGoNodeJava
401bad/missing/expired token, or a revoked keyIsUnauthorizedisNativeUnauthorizede.isUnauthorized()
403access-key scope or bucket-policy denialIsForbiddenisNativeForbiddene.isForbidden()
404no such bucket or keyIsNotFoundisNativeNotFounde.isNotFound()
409bucket already existsIsAlreadyExists / IsConflictisNativeConflicte.isConflict()
412conditional precondition not metIsPreconditionFailedisNativePreconditionFailede.isPreconditionFailed()
507tenant storage quota exceededIsQuotaExceededisNativeQuotaExceedede.isQuotaExceeded()
ts
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;
}
go
_, 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
}
java
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:

StatusCause
400malformed request, or a checksum mismatch (BadDigest) on a write
401missing/invalid/expired credentials or token, or a revoked key
403scope or bucket-policy denial, or a delete blocked by retention/legal hold
404no such bucket, key, version, or upload
409a conflicting create (bucket already exists)
412a conditional If-Match / If-None-Match precondition was not met
429rate limited
5xxa server-side or transient failure
507the tenant storage quota was exceeded

The retry policy

The S3 client retries safe and idempotent requests on transient failures. A policy has four knobs:

FieldMeaning
MaxAttemptstotal attempts including the first; a value of 1 disables retries
BaseBackoffthe delay before the second attempt; it doubles each attempt
MaxBackoffthe cap on the exponential delay
Jitterthe 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.

ts
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()
});
go
// 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()))
java
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, and DELETE are idempotent by HTTP semantics, so they always retry under the policy.
  • A buffered-body PUT or POST retries 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).

ts
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.

Released under the Apache-2.0 License. License