# Обработка ошибок

> Обрабатывайте ошибки Merchant API единообразно: формат ответа, группы HTTP-статусов, машинные коды, лимиты запросов и правила повторов.

API использует стандартные HTTP-коды. Успешные ответы возвращают тело ресурса напрямую. Ошибки соответствуют [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details for HTTP APIs (`application/problem+json`) и добавляют поля сверх стандарта (extension members, RFC 9457 §3.2): поле `code` верхнего уровня для обработки в коде и поле `field` верхнего уровня (snake_case) при ошибке валидации по одному полю. Когда не проходят сразу несколько полей, `field` заменяется массивом `errors[]` с детализацией по каждому из них.

:::warning
Переключайтесь по `code` для программной обработки и локализации — никогда не по тексту `title` или `detail`. Строковые поля могут переформулироваться между релизами; `code` — стабильный контракт.
:::

```json
{
  "type": "https://paymos.io/docs/errors/codes#insufficient_balance",
  "title": "Conflict",
  "status": 409,
  "detail": "Merchant balance is insufficient.",
  "code": "insufficient_balance"
}
```

Любая ошибка соответствует [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details for HTTP APIs, отдаётся как `Content-Type: application/problem+json`.

### Одиночная ошибка (большинство ответов)

```json
{
  "type": "https://paymos.io/docs/errors/codes#insufficient_balance",
  "title": "Conflict",
  "status": 409,
  "detail": "Merchant balance is insufficient.",
  "code": "insufficient_balance"
}
```

### Несколько ошибок валидации

Возвращается, только когда валидацию не проходят сразу несколько полей. Код верхнего уровня (`code`) всегда `validation_failed`; детализация по полям — в `errors[]`:

```json
{
  "type": "https://paymos.io/docs/errors/codes#validation_failed",
  "title": "Bad Request",
  "status": 400,
  "detail": "Validation failed.",
  "code": "validation_failed",
  "errors": [
    { "code": "field_required", "field": "currency", "message": "Currency is required." },
    { "code": "field_must_be_positive", "field": "amount", "message": "Amount must be > 0." }
  ]
}
```

### Описание полей

| Поле | Тип | RFC | Описание |
|------|-----|-----|----------|
| `type` | string (URI) | RFC 9457 §3.1.1 | Прямая ссылка на строку документации с описанием конкретного `code`. После публикации не меняется. |
| `title` | string | RFC 9457 §3.1.3 | Краткий заголовок HTTP (`"Bad Request"`, `"Conflict"`, …). |
| `status` | integer | RFC 9457 §3.1.2 | HTTP-статус ответа (совпадает с кодом ответа). |
| `detail` | string | RFC 9457 §3.1.4 | Англоязычное объяснение конкретного случая. |
| `code` | string | extension (§3.2) | Стабильный машинно-читаемый идентификатор — см. [Коды ошибок](/ru/docs/errors/codes.md). **Ветвите логику интеграции по нему** (switch). |
| `field` | string | extension (§3.2) | Имя поля так, как оно приходит по сети (snake_case), для ошибок валидации по одному полю. Если ошибка не привязана к полю, ключа в JSON нет вообще — не `null`, а отсутствие ключа. Присутствует, только когда `errors[]` отсутствует. |
| `errors` | array | extension (§3.2) | Присутствует, только когда валидацию не проходят сразу несколько полей. Каждая запись: `{ code, field, message }`. |

### Зачем структурированные коды (а не только `message`)

Текст в `message` и `detail` может изменяться между версиями, локализоваться или дополняться контекстом. **Стабильный контракт — это `code`.** После публикации код никогда не меняет семантику. Используйте `code` для логики обработки и локализации.

```javascript
import { RateLimitError, ValidationError } from '@paymos/sdk';

try {
  await paymos.withdrawals.create(request);
} catch (error) {
  if (error instanceof ValidationError) {
    for (const item of error.errors) highlight(item.field, item.message);
  } else if (error instanceof RateLimitError) {
    scheduleRetry(error.retryAfterSeconds);
  }
  throw error;
}
```

```rust
use paymos::{ApiErrorKind, Error};

match paymos.withdrawals().create(&request).await {
    Ok(withdrawal) => process(withdrawal),
    Err(Error::Api(error)) if error.kind == ApiErrorKind::Validation => {
        for item in &error.errors {
            highlight(item.field.as_deref(), &item.message);
        }
    }
    Err(Error::Api(error)) if error.kind == ApiErrorKind::RateLimit => {
        schedule_retry(error.retry_after);
    }
    Err(error) => return Err(error),
}
```

## Типы ошибок

Каждый статус приходит в [общем формате ответа об ошибке](/ru/docs/errors.md#field-reference) с полем `title` и машиночитаемым `code`. Один статус может покрывать несколько сценариев — ветвитесь по `code`, а не по статусу.

| HTTP-код | `title` | Описание |
|----------|---------|----------|
| `400` | `Bad Request` | Ошибка валидации входных данных или неверное тело запроса. Ошибки по одному полю несут `code` + `field` на верхнем уровне; ошибки по нескольким полям заполняют `errors[]`. |
| `401` | `Unauthorized` | Учётные данные HMAC отсутствуют, неверны, устарели или имеют неправильный формат. Конкретная причина — в `code` (`invalid_credentials`, `timestamp_expired`, `authorization_malformed`, …). |
| `403` | `Forbidden` | Ключ приняли, а операцию не разрешили: из-за типа ключа, окружения или прав доступа — либо из-за состояния аккаунта или проекта. Конкретная причина — в `code` (`payout_key_required`, `whitelist_required`, `merchant_suspended` и т.п.). |
| `404` | `Not Found` | Ресурс не существует, недоступен вызывающей стороне (чужой ресурс возвращает `not_found`, а не `forbidden`), либо ни один эндпоинт не обрабатывает этот путь (`route_not_found`). |
| `405` | `Method Not Allowed` | HTTP-метод недопустим для этого пути. `code` = `method_not_allowed`. |
| `409` | `Conflict` | Запрос конфликтует с текущим состоянием — недопустимый переход, ресурс уже существует или его блокирует проверка. Конкретная причина — в `code` (`insufficient_balance`, `withdrawal_quota_exceeded`, `invoice_cannot_be_cancelled` и т.д.). |
| `410` | `Gone` | Ресурс больше нельзя использовать (например, истёкший или отменённый инвойс). |
| `413` | `Payload Too Large` | Тело запроса превышает лимит размера (1 MiB). `code` = `payload_too_large`. |
| `415` | `Unsupported Media Type` | `Content-Type` не `application/json`. `code` = `unsupported_media_type`. |
| `429` | `Too Many Requests` | Превышен лимит запросов для мерчанта. Перед повтором прочитайте заголовок `Retry-After` (секунды). |
| `500` | `Internal Server Error` | Сбой на нашей стороне. Повторите запрос с экспоненциальной задержкой. |
| `503` | `Service Unavailable` | Временная проблема (`exchange_rate_unavailable`, `acceptance_disabled`, `outbound_frozen` и т.п.). Повторите с экспоненциальной задержкой. |

Для логики клиента используйте именно `code`, а не HTTP-статус — несколько разных бизнес-сценариев делят один HTTP-код, и машинный `code` в ответе их различает.

## Лимиты запросов

API по умолчанию допускает **30 запросов в секунду** на мерчанта, а на `POST /v1/invoices` действует более строгий лимит — 5 запросов в секунду. Лимит считается на мерчанта, в окне в одну секунду: все ваши ключи расходуют общий бюджет. Оба лимита настраиваются индивидуально для мерчанта. При превышении вернётся `429 Too Many Requests` с заголовком `Retry-After: 1`.

```json
{
  "type": "https://paymos.io/docs/errors/codes#rate_limited",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Rate limit exceeded (30 req/sec). Retry after 1 second.",
  "code": "rate_limited"
}
```
