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 status helpers and exact-code helpers for 409/412 subtypes:

Status/codeMeaningGoNodeJava
401bad/missing/expired token, revoked keyIsUnauthorizedisNativeUnauthorizede.isUnauthorized()
403access-key scope or bucket-policy denialIsForbiddenisNativeForbiddene.isForbidden()
404no such bucket or keyIsNotFoundisNativeNotFounde.isNotFound()
409 / already_existsbucket already existsIsAlreadyExists / IsConflictisNativeConflicte.isConflict()
409 / idempotency_conflictidempotency key reused differentlyIsIdempotencyConflictinspect codeErpErrors
409 / idempotency_in_progressfirst idempotent operation still runningIsIdempotencyInProgressinspect codeErpErrors
412 / precondition_failedconditional precondition not metIsPreconditionFailedisNativePreconditionFailede.isPreconditionFailed()
412 / retention_blockedretention window blocks mutationIsRetentionBlockedinspect codeErpErrors
412 / legal_hold_blockedlegal hold blocks mutationIsLegalHoldBlockedinspect codeErpErrors
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 {
    nativeClient.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;
}

Java ERP classification

Java ERP integrations can call ErpErrors.classify(Throwable) to map either AdminException or NativeException to a stable Category, RetryDecision, RFC 9457-style problemType, audit reason, and request id. The classification does not copy raw exception messages, so signed URLs, secrets, sensitive object keys, tenant names, and customer names do not leak into ERP problem responses.

The ERP categories are NOT_FOUND, ALREADY_EXISTS, FORBIDDEN, UNAUTHORIZED, TENANT_DISABLED, KEY_REVOKED, KEY_EXPIRED, QUOTA_EXCEEDED, RATE_LIMITED, RETENTION_BLOCKED, LEGAL_HOLD_BLOCKED, PRECONDITION_FAILED, IDEMPOTENCY_CONFLICT, IDEMPOTENCY_IN_PROGRESS, VALIDATION_ERROR, TRANSIENT_UPSTREAM, and UNKNOWN. The classifier keys on exact JSON codes such as key_revoked, tenant_disabled, quota_exceeded, retention_blocked, legal_hold_blocked, idempotency_conflict, and idempotency_in_progress. Retrying writes after RATE_LIMITED, TRANSIENT_UPSTREAM, or IDEMPOTENCY_IN_PROGRESS requires the same idempotency key plus a body-binding checksum; otherwise surface the failure and reconcile the ERP row or operator action.

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

On the JSON native and Admin API surfaces, a given failure code maps to one status. The S3 XML compatibility surface keeps S3-native statuses where compatibility requires them, so exact JSON codes and SDK guards are the source of truth when one status has several meanings:

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
404no such bucket, key, version, or upload
409a conflicting create or in-progress idempotency key
412a conditional precondition failed, or JSON retention/legal-hold denial
429rate limited
5xxa server-side or transient failure
507the tenant storage quota was exceeded

The retry policy

The S3 clients, plus the Java native client, retry 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.

The Java native client defaults to RetryPolicy.defaults(). Pass retryPolicy(RetryPolicy.disabled()) on LockwellNativeClient.builder() when a service owns the retry loop itself.

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.
  • S3 streaming uploads are never retried; their source is already consumed. Java native streaming uploads can retry only when the request is keyed and the Supplier<InputStream> can open a fresh stream.

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. A Retry-After header on 429 or 5xx raises the delay when it is longer than the local backoff; Java accepts delta-seconds and HTTP-date forms.

Each S3 retry attempt is re-signed with a fresh timestamp, since SigV4 signatures are time-bound.

Retries on the native client

All native clients refresh their bearer token before expiry, 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.

The Java native client also retries transient transport errors, 429, and 5xx responses through RetryPolicy.defaults() by default. GET, HEAD, and DELETE replay automatically. PUT and POST replay only when the request carries Idempotency-Key, which the Java PutOptions.idempotencyKey(...) helper sets for object writes. Pass RetryPolicy.disabled() to attempt each request once.

The Go and Node native clients keep only the token-refresh retry. Wrap your own retry loop around a Go or Node native call if you want to retry transient 5xx responses, and pair writes with an idempotency key so a replay is safe.

See Java native client for JVM production defaults.

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