# Errors

> The shapes an error takes, every request error code and task failure code, how a failed task differs from a rejected request, and what is safe to retry.

Two different things fail, and they look nothing alike. A **request error**
rejects the call before anything is queued, with a `4xx` or `5xx` right away.
A **task failure** happens after a `201`: the task runs and the work fails
seconds or minutes later.

```text
POST /image/generate
  │
  ├─ rejected   4xx / 5xx   { "code": "UNAUTHORIZED", "message": "..." }
  │                         nothing queued, held, or charged
  │
  └─ 201 { taskId } ──▶ task runs ──┬─▶ completed   items can still carry an error
                                     └─▶ failed      errors: [{ code, message? }]
```

Handle both.

## Request errors

The body is flat, with `code` at the top level in `SCREAMING_SNAKE_CASE`:

```json
{ "code": "INSUFFICIENT_BALANCE", "message": "Insufficient balance." }
```

Branch on `code`, never on `message`. `message` is optional: `TASK_NOT_FOUND`,
`API_KEY_NOT_FOUND`, `INVALID_TOKEN`, and `TOKEN_EXPIRED` have none.
`INSUFFICIENT_SCOPE` and `SCOPE_ESCALATION` add a `scopes` object.

| Status | Code                            | Means                                                                              | Do                                      |
| ------ | ------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------- |
| 400    | `VALIDATION_ERROR`              | A check that runs after the schema passed. See below                               | Fix what `message` names                |
| 400    | `API_KEY_LIMIT_REACHED`         | The account already has 100 live keys                                              | Delete a key first                      |
| 400    | `WEBHOOK_API_KEY_NOT_FOUND`     | `apiKeyIds` names a key you don't have                                             | Drop the unknown id                     |
| 400    | `DESTINATIONS_INVALID_PROVIDER` | A destination update changed `provider.id`                                         | Create a new destination                |
| 401    | `UNAUTHORIZED`                  | No usable bearer header, or an unknown or deleted key                              | Fix the credential                      |
| 401    | `INVALID_TOKEN`                 | A `pat_` that is malformed or not signed by Mynth                                  | Get a new token from your server        |
| 401    | `TOKEN_EXPIRED`                 | A `pat_` past its hour                                                             | Poll from the server, or use a webhook  |
| 403    | `INSUFFICIENT_SCOPE`            | Valid key, missing scope. Adds `scopes.required` and `scopes.current`              | Use a key with the scope, or add it     |
| 403    | `SCOPE_ESCALATION`              | An API key asked for `manage` or `keys`. Adds `scopes.requested`, `scopes.allowed` | Do it in the dashboard                  |
| 404    | `TASK_NOT_FOUND`                | Unknown task, another account's task, or a `pat_` for a different task             | Check the id and the credential         |
| 404    | `API_KEY_NOT_FOUND`             | Unknown key id                                                                     | Check the id                            |
| 404    | `DESTINATION_NOT_FOUND`         | Unknown destination id                                                             | Check the id                            |
| 404    | `WEBHOOK_NOT_FOUND`             | Unknown webhook id                                                                 | Check the id                            |
| 404    | `NOT_FOUND`                     | No route matches the path                                                          | Check the URL. There is no `/v1` prefix |
| 409    | `DESTINATION_NAME_TAKEN`        | You already have a destination with that name                                      | Pick another name                       |
| 413    | `VALIDATION_ERROR`              | `POST /image/upload` body over 100 MB                                              | Upload fewer or smaller files           |
| 422    | `INSUFFICIENT_BALANCE`          | Available balance is below the task's estimate                                     | Top up. Nothing was queued              |
| 429    | `SPENDING_LIMIT_EXCEEDED`       | The key's cap for the current day, week, or month is used up                       | Wait for the period, or raise the cap   |
| 500    | `INTERNAL_SERVER_ERROR`         | Our fault                                                                          | Retry, after checking `GET /tasks`      |
| 500    | `UNKNOWN_ERROR`                 | Storing or reading a destination secret failed                                     | Retry                                   |
| 502    | `DESTINATION_TEST_FAILED`       | Your storage rejected the test upload. `message` is the provider's                 | Fix the credentials or path             |

`VALIDATION_ERROR` covers the checks that run after the schema passed:

- `metadata` over 2048 bytes
- a `destination` name you don't own
- `inputs` or options the chosen model cannot take
- an upload with no files, more than 10 files, a file that is not JPEG, PNG,
  or WEBP, or a file under 1 KB or over 10 MB

A `429` other than `SPENDING_LIMIT_EXCEEDED` means back off and retry. There
is no `Retry-After` header. [Rate limits](https://mynth.io/docs/concepts/rate-limits.md) has the
rest.

### Schema rejections use a different shape

A body that fails the schema never reaches the handler, so it gets no `code`.
The validator answers `400` with its own shape, which depends on the endpoint:

| Endpoints                                         | `400` body                                                     |
| ------------------------------------------------- | -------------------------------------------------------------- |
| `/image/*`, `/video/*`                            | `{ "data": <your body>, "error": [issues], "success": false }` |
| `/tasks`, `/api-key`, `/webhook`, `/destinations` | `{ "success": false, "errors": [issues] }`                     |
| Any body that is not valid JSON                   | `Malformed JSON in request body` as plain text                 |

```json
{
  "data": { "model": "auto", "count": 1 },
  "error": [
    {
      "path": ["prompt"],
      "code": "required",
      "expected": "a string",
      "actual": "missing",
      "message": "prompt must be a string (was missing)"
    }
  ],
  "success": false
}
```

Read `path` and `message` on each issue. `count` outside 1 to 20, an unknown
model id, and a missing `prompt` all fail here. `data` echoes the request
body, so keep these responses out of logs you share.

> **Warning**
>
> Generation bodies drop fields they do not recognize instead of rejecting them. `{"promt": "..."}`
> fails on the missing `prompt`, but `{"prompt": "...", "widht": 512}` succeeds and ignores
> `widht`. Check the task's echoed `request` when a setting seems to have no effect.

## Task failures

An accepted task settles as `completed` or `failed`, and both can carry error
codes. Where the code lands depends on the task type:

| Task type                                                            | A failure appears in                            |
| -------------------------------------------------------------------- | ----------------------------------------------- |
| `image.generate`                                                     | `result.images[].error`, task stays `completed` |
| `video.generate`                                                     | `result.videos[].error`, task stays `completed` |
| `image.remove_background`, `image.rate`, `image.alt`, `image.review` | `errors[]`, task is `failed`                    |

So a refused prompt on `POST /image/generate` gives you `201` at creation and a
`completed` task whose only image failed. Checking `status === "completed"` is
not enough. Walk the array.

```json
{
  "status": "completed",
  "result": {
    "model": "black-forest-labs/flux.2-pro",
    "images": [
      {
        "status": "failed",
        "error": {
          "code": "RESTRICTED_CONTENT",
          "message": "The request was blocked by content moderation."
        }
      }
    ]
  }
}
```

An `image.generate` or `video.generate` task reaches `failed` only when the
work around generation broke: resolving the model, the inputs, or the size (a
`_4k` size on a model without 4k fails with `CAPABILITY_NOT_SUPPORTED`),
queueing the task, or the daily sweep that expires a task stuck on `pending`.

### Codes

| Code                       | Means                                                              | Retry helps                             |
| -------------------------- | ------------------------------------------------------------------ | --------------------------------------- |
| `RESTRICTED_CONTENT`       | The provider or the model refused the content. Mynth did not       | Mynth does not retry. You may resend    |
| `INVALID_PROMPT`           | The prompt was empty or too long for the model                     | No                                      |
| `INVALID_INPUT`            | An input image could not be fetched or read                        | No. Check the URL is publicly reachable |
| `CAPABILITY_NOT_SUPPORTED` | The model does not support an option you sent                      | No. Use another model or option         |
| `PROVIDERS_BUSY`           | No provider for that model has capacity right now                  | Yes, later                              |
| `RATE_LIMITED`             | Providers kept rate-limiting the request                           | Yes, shortly                            |
| `TIMEOUT`                  | The provider did not answer in time                                | Yes                                     |
| `PROVIDER_ERROR`           | The provider failed for another reason                             | Yes                                     |
| `FETCH_FAILED`             | `image.rate`, `image.alt`, or `image.review` could not fetch `url` | Only if the URL was flaky               |
| `RATING_FAILED`            | `image.rate` ran but produced nothing usable                       | Yes                                     |
| `ALT_GENERATION_FAILED`    | `image.alt` ran but produced nothing usable                        | Yes                                     |
| `REVIEW_FAILED`            | `image.review` ran but produced nothing usable                     | Yes                                     |
| `TASK_EXPIRED`             | Still `pending` 24 hours after its last update, then swept         | Yes. Something broke on our side        |
| `ENQUEUE_FAILED`           | Queueing failed right after creation. The create answered `500`    | Yes                                     |
| `UNKNOWN_ERROR`            | Nothing else matched                                               | Once                                    |

Before a retryable code reaches you, Mynth has already retried across
providers: up to four attempts per image and three per video. See
[tasks](https://mynth.io/docs/concepts/tasks.md#retries-and-timeouts). What `RESTRICTED_CONTENT`
means is on [prompts and images](https://mynth.io/docs/concepts/prompts.md).

Webhooks carry the same values: `.completed` events carry `result`, and
`.failed` events carry `errors`.

## What is safe to retry

- `400`, `401`, `403`, `404`, `409`, `413`, and `422` fail the same way every
  time. Fix the request.
- `429 SPENDING_LIMIT_EXCEEDED` clears when the key's period rolls over.
- `502 DESTINATION_TEST_FAILED` is your storage rejecting the upload. Fix the
  destination.
- `500` and connection failures are worth retrying, with one catch: **there are
  no idempotency keys.** Every create mints a new task and holds cost again,
  so resending a create that actually succeeded runs and charges it twice. If
  a create times out, check `GET /tasks` before sending it again.
- A create that fails while queueing marks its task `failed` with
  `ENQUEUE_FAILED`, releases the hold, and answers `500`. Retrying that one is
  safe.
- A settled task never runs again. Retrying a task failure means a new
  request. Failed items are not charged.

## In the SDK and CLI

The SDK throws `MynthAPIError` for a request error, with `status` and `code`,
and `TaskAsync*` errors while waiting on a task.
[SDK errors](https://mynth.io/docs/sdks/typescript/errors.md) covers both, including why a schema
rejection leaves `code` undefined.

The CLI prints the message to stderr and exits with a code scripts can branch
on: `2` usage and `VALIDATION_ERROR`, `3` auth, `4` balance or spending
limit, `5` `RESTRICTED_CONTENT`, `6` another `429`, `1` anything else.
[CLI commands](https://mynth.io/docs/sdks/cli/commands.md#exit-codes) has the table.

## Next steps

- [Tasks](https://mynth.io/docs/concepts/tasks.md): statuses, partial failure, and attempts.
- [Authentication](https://mynth.io/docs/api-reference/authentication.md): the auth codes in context.
- [Webhooks](https://mynth.io/docs/concepts/webhooks.md): the same codes, pushed to you.
