# 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 stable fields: a `Code` (S3-style like `NoSuchKey`, or native/admin codes like
`not_found`), a human `Message`, the `StatusCode`, and a `RequestID`. Native/admin JSON errors additionally preserve
optional RFC 9457 `Type`, `Title`, `Detail`, and `Instance` members plus an inspection-safe `Extensions` map/object for
unknown fields; `Detail` is the human fallback when `Message` is absent. S3 XML errors remain separate and do not gain
these native/admin fields. Secrets are never included in an error message; the SDKs redact credentials and query strings
before wrapping a transport error. If a problem body is malformed, bodyless, or omits both `code` and `type`, all three
SDKs use the status-derived machine code `http_<status>` while treating the HTTP status as authoritative.

## 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`.

::: code-group

```ts [Node]
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 [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 [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/code                     | Meaning                                  | Go                               | Node                         | Java                       |
| ------------------------------- | ---------------------------------------- | -------------------------------- | ---------------------------- | -------------------------- |
| 401                             | bad/missing/expired token, 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 / `already_exists`          | bucket already exists                    | `IsAlreadyExists` / `IsConflict` | `isNativeConflict`           | `e.isConflict()`           |
| 409 / `idempotency_conflict`    | idempotency key reused differently       | `IsIdempotencyConflict`          | inspect `code`               | `ErpErrors`                |
| 409 / `idempotency_in_progress` | first idempotent operation still running | `IsIdempotencyInProgress`        | inspect `code`               | `ErpErrors`                |
| 412 / `precondition_failed`     | conditional precondition not met         | `IsPreconditionFailed`           | `isNativePreconditionFailed` | `e.isPreconditionFailed()` |
| 412 / `retention_blocked`       | retention window blocks mutation         | `IsRetentionBlocked`             | inspect `code`               | `ErpErrors`                |
| 412 / `legal_hold_blocked`      | legal hold blocks mutation               | `IsLegalHoldBlocked`             | inspect `code`               | `ErpErrors`                |
| 507                             | tenant storage quota exceeded            | `IsQuotaExceeded`                | `isNativeQuotaExceeded`      | `e.isQuotaExceeded()`      |

::: code-group

```ts [Node]
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 [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 [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](/guide/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:

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

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

::: code-group

```ts [Node]
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 [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 [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.

Node S3, native, and admin clients also accept `timeoutMs` and `signal` constructor options. `timeoutMs` is a bounded
per-attempt limit (maximum 30 seconds); a timeout can be retried when the operation is replay-safe. `signal` is a
caller-owned `AbortSignal` for the whole client and stops the in-flight attempt and any retry when aborted.

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](/guide/conditional-writes), 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.

::: tip 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](/sdks/java-native) 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 [Node]
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](/guide/conditional-writes) page.

## Related

- [Conditional writes and idempotency](/guide/conditional-writes) for create-only writes and the idempotency key.
- [Checksums](/guide/checksums) for the `BadDigest` failure path.
- [Multipart uploads](/guide/multipart-uploads) for which multipart calls retry.
- [S3 operations reference](/reference/s3-operations) for the full operation matrix.
