> ## Documentation Index
> Fetch the complete documentation index at: https://docs.landedfees.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Error handling

> Patterns for handling 400, 402, 422, 429, and 500 responses in production callers.

## The rules

1. Every non-2xx response ships the standard [error envelope](/errors).
2. `code` is stable. Match on `code`, never on `error` (that string
   is for humans).
3. Every response carries `X-Request-Id`. Log it on every error path
   so support can trace the call.

## Pattern by status

### 400 (validation)

The request body failed schema validation. The `issues[]` array
lists every offending path. Do not retry; fix and re-post.

```python theme={null}
if r.status_code == 400:
    body = r.json()
    for issue in body.get("issues", []):
        log.error("validation", path=issue["path"], msg=issue["message"])
    raise ValidationError(body["error"])
```

### 401 (auth)

Missing or bad key, or the key was revoked. Do not retry. Rotate the
secret in your config and alert the on-call team.

### 402 (payment)

Two distinct causes, distinguished by `code`:

* `TIER_DOWNGRADED`: organization dropped below Growth. Surface a
  billing alert; the key is still valid once the org upgrades.
* `QUOTA_EXCEEDED`: monthly quota exhausted. Wait for the reset on
  the first of the next month or upgrade tier.

Neither should be retried by an automated client.

### 422 (semantic)

The request was well-formed but cannot be executed. Common cases:

* `MISSING_HS_CODE`: call `/v1/classify` first, then re-post.
* `UNSUPPORTED_DESTINATION`: the country is outside coverage.
  Surface to a human.
* `DOMESTIC_SHIPMENT`: origin equals destination. Filter these out
  upstream.

### 429 (rate limit)

Read `Retry-After`, sleep, retry once. If the retry also 429s, back
off exponentially. See [Rate limits](/rate-limits) for the full
pattern. The SDKs implement this by default.

### 500 (internal)

Retry with exponential backoff. Two attempts total is sufficient. If
both fail, log the `request_id` and page support. Never retry more
than three times; the engine has already retried its own downstream
calls before returning 500.

## A production-grade retry wrapper

```python theme={null}
import time, random
import requests
from landedfees import LandedFeesClient, ApiError

def with_retry(fn, *args, max_attempts=3, **kwargs):
    for attempt in range(max_attempts):
        try:
            return fn(*args, **kwargs)
        except ApiError as err:
            if err.status in (401, 402, 422) or err.status == 400:
                # Not retryable.
                raise
            if err.status == 429:
                sleep = (err.retry_after or 60) + random.uniform(0.1, 0.5)
            elif err.status >= 500:
                sleep = min(2 ** attempt, 60) + random.uniform(0.1, 0.5)
            else:
                raise
            if attempt == max_attempts - 1:
                raise
            time.sleep(sleep)
    raise RuntimeError("unreachable")
```

## Idempotency and retries

Every mutating endpoint (`calc`, `calc/bulk`, `classify`, `compare`)
accepts an `Idempotency-Key` header. Set it to a UUID v4 per logical
operation. Re-sending the same key within 24 hours returns the
original response, not a new calculation. This makes retries safe:

```ts theme={null}
import { randomUUID } from "node:crypto";

const idempotencyKey = randomUUID();
try {
  return await client.calc(payload, { idempotencyKey });
} catch (err) {
  // Safe to retry with the SAME idempotencyKey.
  return await client.calc(payload, { idempotencyKey });
}
```

Different bodies with the same key return `409 IDEMPOTENCY_KEY_REUSED`.
Do not reuse keys across distinct requests.

## Bulk partial-failure pattern

`/v1/calc/bulk` returns `200` even when some rows fail. Inspect
`aggregate.error_rows` and `errors[]`; do not treat `200` as blanket
success.

```python theme={null}
resp = client.calc_bulk(payload).json()
if resp["aggregate"]["error_rows"]:
    for e in resp["errors"]:
        log.warning("bulk_row_error", line=e["line_index"], reason=e["reason"])
```

## Timeouts

Recommend a 30-second client timeout on `/v1/calc` and `/v1/classify`,
60 seconds on `/v1/calc/bulk` and `/v1/compare`. The server enforces
its own upper bound, but you want the client to give up first so
your queue does not stall.
