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

# Errors, retries, and warnings

> How the Kardinal SDKs turn API errors into typed exceptions, which calls they retry and when, and how to read the warnings returned on a successful call.

Both SDKs map every API error to the same exception classes, with the same names in Python and TypeScript. All of them inherit from `KardinalError` and can be imported from the package root.

## Error families

| Exception | Raised on | What to do |
| - | - | - |
| `KardinalAuthError` | `401` `NOT_AUTHENTICATED`: missing, invalid, or expired key | Check the key — see [Troubleshooting authentication](/sdks/configuration#troubleshooting-authentication) |
| `KardinalLimitError` | Any `403` `NOT_ALLOWED`: plan-size limit, optimization quota, or insufficient credits | Read `details` — see [Quotas and rate limits](#quotas-and-rate-limits) |
| `KardinalValidationError` | `400`: `INVALID_INPUT`, `ID_NOT_UNIQUE`, `KEYS_NOT_UNIQUE`, `INVALID_ID_REFERENCE`, `INVALID_VALUE`, `PRECONDITION_FAILED`, `NOT_IMPLEMENTED` | Fix the plan — see [Validation errors](#validation-errors) |
| `KardinalNotFoundError` | Any `404`, reads included | Check the plan `id`; right after `submit`, use `wait` instead of `get` |
| `KardinalRateLimitError` | `429`: temporary overload | Retried automatically, except for `submit` |
| `KardinalServerError` | `500` `INTERNAL_SERVER_ERROR`, or any other `5xx` | Retried automatically, except for `submit` |
| `KardinalConnectionError` | No HTTP response at all (DNS, connection, or read failure) | Retried automatically, except for `submit` |
| `KardinalTimeoutError` | The time limit given to `solutions.wait` elapsed | Wait longer, or track the plan without blocking |
| `KardinalOptimizationError` | `solutions.wait` has no solution to return: `NO_SOLUTION`, `PLAN_SUPERSEDED`, `OPTIMIZATION_STALLED` | See [Waiting for the solution](/sdks/plan-lifecycle#waiting-for-the-solution) |
| `UnknownFieldError` | A key no model declares, found before sending | Fix the field name — see [Client-side errors](#client-side-errors) |

A code the SDK doesn't know yet falls back to the family matching its HTTP status, so a new code added by the API doesn't go unnoticed. For the meaning of each code, see the `Error` schema in the [API Reference](/api-reference/plan/create-a-plan).

<CodeGroup>
  ```python Python theme={null}
  from kardinal import KardinalLimitError, KardinalValidationError

  try:
      plan = k.routing.plans.submit(plan_input)
  except KardinalValidationError as e:
      print(e.code)    # INVALID_VALUE, ID_NOT_UNIQUE, ...
      print(e.errors)  # every error of the response, not only the first
      raise
  except KardinalLimitError as e:
      print(e.details)  # which limit was hit
      raise
  ```

  ```ts TypeScript theme={null}
  import { KardinalLimitError, KardinalValidationError } from "@kardinal-ai/sdk";

  try {
    plan = await k.routing.plans.submit(planInput);
  } catch (e) {
    if (e instanceof KardinalValidationError) {
      console.log(e.code); // INVALID_VALUE, ID_NOT_UNIQUE, ...
      console.log(e.errors); // every error of the response, not only the first
    } else if (e instanceof KardinalLimitError) {
      console.log(e.details); // which limit was hit
    }
    throw e;
  }
  ```
</CodeGroup>

## Common error attributes

Every exception carries the same attributes, named in each language's case:

| Python | TypeScript | Content |
| - | - | - |
| `code` | `code` | The API's error code, such as `INVALID_VALUE` or `NOT_ALLOWED` |
| `status` | `status` | The HTTP status |
| `errors` | `errors` | Every error object of the response |
| `details` | `details` | The `properties.details` of the error `code` came from: the only way to tell apart errors that share a code |
| `retryable` | `retryable` | Whether retrying is safe and may succeed, computed by the SDK |
| `warnings` | `warnings` | Amendments applied to the payload before the error was found; usually empty |
| `request_id` | `requestId` | The `X-Request-Id` response header, when the server sends one; usually empty |

`retryable` is true for a `429`, and for a `5xx` or a connection error — except when a `submit` may have reached the server, since the plan may then already exist.

## Validation errors

A `400` means the API refused the plan as sent. `errors` holds every problem found, each with its `code` and, in `properties.path`, the field concerned: fix them all before submitting again rather than one per attempt. Most structural mistakes never reach the API: the SDKs catch them locally first (see [Validating locally before you spend credits](/sdks/building-plans#validating-locally-before-you-spend-credits)).

## Quotas and rate limits

**`403`: a limit was hit.** For a builder account, a `403` always means a limit, never a permission refusal: the SDKs raise `KardinalLimitError` for every one of them, and `details` says which.

* **Plan-size limit** (too many resources, orders, or stops, or more than 4 stops in an order without `successiveStops`): `errors` also holds one `INVALID_VALUE` entry per exceeded limit, naming the field and the allowed value. Shrink the plan.
* **Daily or monthly optimization quota**: wait, or ask for a higher quota.
* **Insufficient credits**: top up your credits in the Console.

See [Reaching a limit (403)](/reference/limits-and-quotas#reaching-a-limit-403) and [Pricing and credits](/reference/pricing-and-credits) for the values.

**`429`: a temporary overload, not a quota.** It comes with an empty body. The SDKs retry it on their own, honouring a `Retry-After` header if the API sends one, and backing off otherwise. See [Temporary overload (429)](/reference/limits-and-quotas#temporary-overload-429).

## What the SDKs retry

| Call | Retried after a network error, a `429`, or a `5xx` |
| - | - |
| `plans.get`, `plans.list`, `plans.get_status` / `getStatus`, `solutions.get`, `solutions.get_objectives` / `getObjectives` | Yes |
| `plans.update`, `plans.delete`, `plans.start`, `plans.stop` | Yes: replaying them is harmless |
| `solutions.wait` | Yes, on each call it makes |
| `plans.submit` | **Never** |

The number of retries is set by `max_retries` / `maxRetries` (3 by default), with a growing, randomized delay between attempts. `submit` is never retried because the API has no idempotency key: a retried `submit` could create the plan twice. After a failed `submit`, check with `plans.list` whether the plan exists before submitting again.

## Client-side errors

Some errors are raised before any request is sent:

* **`UnknownFieldError`** — `submit` refuses a plan containing a key no model declares, and lists each one with its path and the closest real field name. The API would drop such a key without an error: a misspelled `operationDurationn` would leave the stop with no service time, and the plan would still look valid.

  ```
  Unknown field(s) in the plan:
    plan.resources[0].maxWorkingDurationn       (did you mean maxWorkingDuration?)
    plan.orders[0].stops[0].operationDurationn  (did you mean operationDuration?)
  ```

  `update` sorts unknown keys by origin: a key the server sent, from a field added after your SDK version, is sent back untouched; a key you added is refused as a typo. To send a field the API supports but your SDK version doesn't know yet, name it explicitly: `allow_unknown=["aNewField"]` (Python) or `{ allowUnknown: ["aNewField"] }` (TypeScript). A name listed there is allowed anywhere in the plan, so prefer distinctive names.

* **Model validation** — a missing required field or a wrong type fails when the plan is built or parsed: `pydantic.ValidationError` in Python, `ZodError` in TypeScript.

* **Missing key** — `MissingApiKeyError` and `EmptyApiKeyError`, raised when creating the client — see [API key resolution](/sdks/configuration#api-key-resolution).

<Tabs>
  <Tab title="Python">
    On a plan this SDK didn't read from the API (built by hand, or loaded from a file), `update` can't tell where an unknown key came from: it raises an `UnknownFieldWarning` through Python's `warnings` module, only for a key within two edits of a real field, and sends the plan.
  </Tab>

  <Tab title="TypeScript">
    On a plan this client didn't read from the API in the same process, `update` can't tell where an unknown key came from: it reports a likely typo to the `onWarning` callback (`console.warn` by default) and sends the plan. The client remembers the keys the server sent for the last 64 plans it read.
  </Tab>
</Tabs>

## Warnings on successful calls

When the API accepts a plan but amends part of it — a `maxOptimizationDuration` clamped to your agency's cap, crow-fly distances forced in the sandbox, predictive traffic turned off — `submit` and `update` return the amendments in `warnings`:

<CodeGroup>
  ```python Python theme={null}
  plan = k.routing.plans.submit(plan_input)
  for warning in plan.warnings:
      print(warning.code, warning.properties)  # for example, maxOptimizationDuration clamped
  ```

  ```ts TypeScript theme={null}
  plan = await k.routing.plans.submit(planInput);
  for (const warning of plan.warnings) {
    console.log(warning.code, warning.properties); // for example, maxOptimizationDuration clamped
  }
  ```
</CodeGroup>

Read them from that return value: warnings aren't stored with the plan, so a later `get` never returns them, only the amended values. Solutions, objective lists, and the references returned by `start` and `stop` carry a `warnings` field too. The [`Warning` schema](/api-reference/plan/create-a-plan) in the API Reference lists what each one means.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.