DealDocumentScreening/src/contract_check/api/routes/README.md
2026-08-26 21:12:16 +03:00

696 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# API Routers
FastAPI-роутеры сервиса `contract_check.api`. Все пути — префикс `/api/v1`
(кроме инфра-эндпоинтов `/healthz`, `/readyz`, `/metrics`).
Роутеры регистрируются в приложении через `include_router`; теги соответствуют
атрибуту `tags=` каждого `APIRouter`.
## Содержание
- [Аутентификация](#аутентификация)
- [health](#health)
- [metrics](#metrics)
- [auth](#auth)
- [me](#me)
- [billing](#billing)
- [webhooks](#webhooks)
- [pay page](#pay-page)
- [documents](#documents)
- [reports](#reports)
- [b2b](#b2b)
- [Коды ошибок](#коды-ошибок)
---
## Аутентификация
Три независимые схемы, реализованные в [`api/deps.py`](../deps.py):
| Зависимость | Заголовок | Кому выдаётся | Где используется |
| ------------------- | ---------------------------------- | -------------------------------------- | ---------------------------------------- |
| `AuthDep` | `Authorization: Bearer <svc-token>`| Сервисный токен (адаптеры bot/web/cli) | `/auth/telegram/*` — вход от имени бота |
| `CurrentUserDep` | `Authorization: Bearer <user-jwt>` | JWT пользователя (Telegram-логин) | `/me`, `/documents`, `/reports`, `/b2b/keys*` |
| `ApiKeyAuthDep` | `X-API-Key: <raw-key>` | B2B-ключ внешнего клиента | `/analyze`, `/b2b/reports/*`, `/b2b/usage` |
- Сервисный токен и B2B-ключ хешируются (`hash_token`, `hash_api_key`),
в БД хранится только хеш; raw-значение возвращается один раз при создании.
- `ApiKeyAuthDep` дополнительно применяет token-bucket rate limit и
месячный квоты (`429` при превышении).
- `CurrentUserDep` проверяет, что пользователь ещё есть в таблице `users`
(stateless JWT + defence-in-depth).
---
## health
Файл: [`health.py`](health.py). Тег: `health`. Без аутентификации.
### `GET /healthz`
Liveness-проба. Возвращает `200` всегда.
```json
{ "status": "ok" }
```
### `GET /readyz`
Readiness-проба. Выполняет `SELECT 1` в БД.
```json
{ "status": "ok" }
```
Если БД недоступна:
```json
{ "status": "not_ready", "reason": "db: <error>" }
```
---
## metrics
Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации.
### `GET /metrics`
Exposition-эндпоинт Prometheus. Тело — текст в формате
`text/plain; version=0.0.4` (`prometheus_client.generate_latest`).
---
## auth
Файлы: [`auth/`](auth/) — `telegram.py`, `password.py`, `passkeys.py`, `magic_links.py`, `support.py`. Тег: `auth`.
Три источника идентичности сходятся к одному JWT:
### `POST /api/v1/auth/telegram/bot`
Бот (aiogram) обменивает проверенный `telegram_id` на JWT пользователя.
**Auth:** `AuthDep` (сервисный токен бота).
Тело — `TelegramBotAuthRequest`:
| Поле | Тип | Условие | Описание |
| ------------ | ---- | ------------ | ------------------------------------- |
| `telegram_id`| int | `> 0` | Verified Telegram user id из aiogram |
Ответ `200``AuthResponse` (см. ниже).
### `POST /api/v1/auth/telegram/web`
Callback Telegram Login Widget. **Auth:** нет (проверяется подпись Telegram).
Тело — `TelegramWebAuthRequest`:
| Поле | Тип | Условие |
| ----------- | --------- | ------- |
| `id` | int | `> 0` |
| `first_name`| str? | — |
| `last_name` | str? | — |
| `username` | str? | — |
| `photo_url` | str? | — |
| `auth_date` | int | — |
| `hash` | str | — |
`401` при ошибке проверки подписи. Ответ `200``AuthResponse`.
### `POST /api/v1/auth/telegram/miniapp`
Mini App initData. **Auth:** нет (проверяется подпись ` initData`).
Тело — `TelegramMiniAppAuthRequest`:
| Поле | Тип | Описание |
| ---------- | --- | ------------------------------------------ |
| `init_data`| str | Raw initData query string из `Telegram.WebApp` |
`401` при ошибке. Ответ `200``AuthResponse`.
#### `AuthResponse`
```json
{
"access_token": "<jwt>",
"token_type": "bearer",
"expires_in": 3600,
"user_id": "uuid",
"telegram_id": 123456789
}
```
`expires_in` = `jwt_access_ttl_minutes * 60` из настроек.
### `GET /api/v1/auth/me`
Интроспекция Bearer-JWT. **Auth:** `Authorization: Bearer <jwt>` в заголовке.
Ответ `200``TokenIntrospectResponse`:
| Поле | Тип |
| ------------ | --- |
| `sub` | str (UUID) |
| `telegram_id`| int |
| `type` | str |
| `exp` | int (unix) |
`401` — отсутствует/невалиден.
### `GET /api/v1/auth/me/permissions`
Заглушка под будущее RBAC.
```json
{ "permissions": ["upload", "read_reports", "read_me"] }
```
---
### WebUI auth (email + password)
Шесть эндпоинтов для SPA-фронта. Возвращают **JWT-пару**: короткий access
(default 24h) + длинный refresh (default 30d, ttl в Redis). Refresh-token
поддерживает отзыв через logout; access невалидируется только истечением TTL.
| Метод | Путь | Auth | Описание |
| ----- | --------------------------- | ------ | --------------------------------------- |
| POST | `/api/v1/auth/register` | — | Регистрация → 201 + JWT pair |
| POST | `/api/v1/auth/login` | — | Вход → 200 + JWT pair |
| POST | `/api/v1/auth/logout` | — | Отзыв refresh-токена (в теле) |
| GET | `/api/v1/auth/me` | Bearer | Текущий пользователь (расширенный ответ)|
| POST | `/api/v1/auth/forgot-password` | — | Запрос сброса (202, всегда одинаковый ответ) |
| POST | `/api/v1/auth/reset-password` | — | Сброс пароля по токену из письма |
#### `POST /api/v1/auth/register`
Тело — `RegisterRequest`:
| Поле | Тип | Условие |
| ---------- | ------- | --------------- |
| `email` | EmailStr | валидный email |
| `password` | str | `8..128` символов |
`409` если email занят. `201``TokenPairResponse`.
#### `POST /api/v1/auth/login`
Тело — `LoginRequest` (те же поля). `401` при неверных кредах.
#### `TokenPairResponse`
```json
{
"access_token": "<jwt>",
"refresh_token": "<jwt>",
"token_type": "bearer",
"expires_in": 86400,
"user": {
"id": "uuid",
"email": "user@example.com",
"telegram_id": null,
"credits_left": 0,
"is_active": true,
"created_at": "2026-08-12T..."
}
}
```
#### `POST /api/v1/auth/logout`
Тело — `LogoutRequest`:
| Поле | Тип |
| --------------- | --- |
| `refresh_token` | str |
Идемпотентен. Access-токен остаётся валидным до истечения своего TTL
(см. `JWT_ACCESS_TTL_MINUTES`); refresh уничтожается в Redis сразу.
#### `POST /api/v1/auth/forgot-password`
Тело — `ForgotPasswordRequest` (`email`). Всегда отвечает `202 OkResponse` с
телом `{"ok": true, "detail": "if the email exists, a reset link was sent"}`
— чтобы не раскрывать, какие адреса зарегистрированы. API:
1. Генерирует `secrets.token_urlsafe(32)`, хранит только SHA-256 от него
в `users.password_reset_token_hash` с TTL = `PASSWORD_RESET_TTL_MINUTES`.
2. Публикует `NotificationMessage(kind=password_reset)` в очередь
`notify.q` (RabbitMQ).
3. `worker-notify` достаёт сообщение и шлёт письмо через SMTP
(`SMTP_HOST`/`SMTP_PORT`/`SMTP_USE_TLS`/`SMTP_FROM`). При пустом
`SMTP_HOST` (dev) тело письма пишется в лог.
#### `POST /api/v1/auth/reset-password`
Тело — `ResetPasswordRequest`:
| Поле | Тип | Условие |
| ---------- | --- | --------------- |
| `token` | str | из письма |
| `password` | str | `8..128` символов |
`400` при невалидном/просроченном токене. Успех: пароль перезаписывается
(argon2id), `password_reset_token_hash` сбрасывается, **все активные
refresh-токены этого пользователя отзываются** в Redis (принудительный
re-login на всех устройствах).
#### `GET /api/v1/auth/me`
Расширение прежнего introspect-эндпоинта: теперь читает Bearer access JWT,
ищет пользователя в БД и возвращает полный профиль. Старые поля
(`sub`, `telegram_id`, `type`, `exp`) сохранены для совместимости; добавлены
`email`, `credits_left`, `is_active`, `created_at`.
### Passkeys (WebAuthn) + magic-link
Passwordless-методы веб-аутентификации. Оба выдают **один access JWT**
(без refresh): `passkeyAuthenticationResponseSchema` /
`magicLinkVerifyResponseSchema` на фронте.
| Метод | Путь | Auth | Описание |
| ----- | ---------------------------------------- | ------ | -------------------------------------------- |
| POST | `/api/v1/auth/passkeys/register/start` | Bearer | PublicKeyCredentialCreationOptions + challenge в Redis |
| POST | `/api/v1/auth/passkeys/register/finish` | Bearer | Проверка attestation → 201, ключ сохранён |
| POST | `/api/v1/auth/passkeys/authenticate/start` | — | PublicKeyCredentialRequestOptions (discoverable) |
| POST | `/api/v1/auth/passkeys/authenticate/finish` | — | Проверка assertion → access JWT |
| GET | `/api/v1/auth/passkeys` | Bearer | Список пасскеев пользователя |
| DEL | `/api/v1/auth/passkeys/{id}` | Bearer | Удалить пасскей (только свой) |
| POST | `/api/v1/auth/magic-link/request` | — | Одноразовая ссылка входа на email |
| POST | `/api/v1/auth/magic-link/verify` | — | Обмен токена из письма на access JWT |
Детали:
- Ceremonies — по два шага (start/finish). Challenge живёт в Redis
`PASSKEY_CHALLENGE_TTL_SECONDS` (default 120 c), одноразовый.
Регистрация: challenge ключуется `user_id`; аутентификация — самим
challenge (клиент возвращает его в `finish`-запросе).
- `authenticatorSelection`: `residentKey=required`, `userVerification=required`
→ discoverable-креды, `allowCredentials` пустой; пользователь
определяется на finish по `credential.id` из БД.
- RP-параметры: `PASSKEY_RP_ID` / `PASSKEY_RP_NAME` / `PASSKEY_RP_ORIGINS`
(comma-separated). Флаги `PASSKEY_ENABLED`, `MAGIC_LINK_ENABLED`
включают/выключают группы эндпоинтов (404 при выключении).
- Хранение: таблица `passkey_credentials` (credential_id/public_key —
base64url, sign_count — защита от клонирования, ротация на каждом входе).
- Magic-link повторяет паттерн forgot-password: SHA-256 хеш токена +
TTL (`MAGIC_LINK_TTL_MINUTES`, default 15) в `users`, письмо через
`notify.q` (`kind=magic_link`), ссылка `{WEB_APP_BASE_URL}/magic-link?token=...`.
Ответ request всегда одинаковый (не раскрывает зарегистрированные email),
verify одноразовый — хеш сбрасывается сразу.
#### `POST /api/v1/auth/passkeys/register/start`
Тело (опционально): `{ "device_name": "YubiKey 5" }`. Ответ —
`PasskeyRegistrationOptions` (camelCase, base64url-строки; соответствует
zod-схеме фронта): `challenge`, `rp {id, name, origin}`, `user {id, name,
displayName}`, `pubKeyCredParams[]`, `timeout`, `attestation="none"`,
`excludeCredentials[]`, `authenticatorSelection {residentKey, userVerification}`.
#### `POST /api/v1/auth/passkeys/register/finish`
Тело: `{ "credential": {...result of navigator.credentials.create()...}, "device_name"? }`
(base64url). Ответ `201``{ "verified": true, "credentialId": "<base64url>" }`.
`400` — нет/просрочен challenge, битый attestation; `409` — credential уже
зарегистрирован.
#### `POST /api/v1/auth/passkeys/authenticate/start|finish`
start — без тела, ответ `PasskeyAuthenticationOptions`: `challenge`,
`timeout`, `rpId`, `allowCredentials=[]`, `userVerification`.
finish — тело `{ "challenge": "<из start>", "credential": {...result of
navigator.credentials.get()...} }`; ответ — access JWT (см. ниже).
`400` — неизвестный/просроченный challenge или битая подпись; `401`
неизвестный credential; `403` — аккаунт отключён.
#### `GET /api/v1/auth/passkeys` / `DELETE /api/v1/auth/passkeys/{id}`
Список — массив `{ id, credentialID, credentialPublicKey, counter, userId,
deviceName, createdAt }`, новые первыми. Удаление чужого/несуществующего —
`404`, успех — `204 No Content` (пустое тело).
#### `POST /api/v1/auth/magic-link/request` / `verify`
request — тело `{ "email" }`, всегда `200 { success, message, expires_in }`
(секунды). verify — тело `{ "token" }`, ответ:
```json
{
"access_token": "<jwt>",
"token_type": "bearer",
"expires_in": 86400,
"user_id": "uuid",
"email": "user@example.com"
}
```
`400` — невалидный/просроченный токен (протухшие сбрасываются сразу).
---
## me
Файл: [`me.py`](me.py). Тег: `me`. **Auth:** `CurrentUserDep` (user JWT).
### `GET /api/v1/me`
Профиль пользователя и баланс кредитов.
```json
{ "telegram_id": 123456789, "credits_left": 7 }
```
### `GET /api/v1/me/profile` / `PATCH /api/v1/me/profile`
Пассивные пользовательские настройки (миграция 0011; строка создаётся лениво
на первом чтении, отсутствующие поля = дефолты). НИКОГДА не попадает в LLM-промпты.
PATCH — частичное обновление любого поля:
```json
{
"language": "ru",
"timezone": "Europe/Minsk",
"notif_prefs": { "report_ready": true, "security": true, "marketing": false },
"dashboard_prefs": { "severity_filter": "all", "per_page": 10, "density": "comfortable" }
}
```
`422` — неизвестный язык (`ru|be|en`), не-IANA таймзона, неизвестные ключи
prefs. Ответ обоих методов — полное `UserProfile`.
### `GET /api/v1/me/overview`
Дашборд одной строкой:
```json
{
"docs": { "total": 12, "done": 10, "failed": 1, "in_progress": 1 },
"credits_left": 7,
"plan": { "code": "pro", "name": "Pro", "quota": 20, "quota_used": 3, "period_end": "..." },
"billing_hold": false
}
```
`plan = null` без активной подписки; `billing_hold=true` — загрузки отвечают `402`.
---
## billing
Файл: [`billing.py`](billing.py). Тег: `billing`. **Auth:** `CurrentUserDep`,
кроме каталога. Все суммы — копейки (int). `YOOKASSA_ENABLED=false`
мутации отвечают `503`, `GET /billing/plans` читается.
| Метод | Путь | Описание |
| ----- | ---- | -------- |
| GET | `/api/v1/billing/plans` | каталог активных тарифов + `price_per_doc_kopecks` |
| POST | `/api/v1/billing/checkout` | топ-ап или подписка → `201 {invoice_id, confirmation_url}` |
| GET | `/api/v1/billing/invoices` | свои счета (`limit`/`offset`) |
| GET | `/api/v1/billing/invoices/{id}` | свой счёт (`404` чужой) |
| GET | `/api/v1/billing/subscription` | текущая подписка (active/past_due) |
| POST | `/api/v1/billing/subscriptions/autorenew` | `{enabled: bool}` на активную подписку |
| POST | `/api/v1/billing/refund` | авто-возврат по правилу D10 |
### `POST /api/v1/billing/checkout`
Тело — один из двух вариантов:
```json
{ "kind": "topup", "credits": 20 } // пакеты 1/5/20 × PRICE_PER_DOC_KOPECKS
{ "kind": "subscription", "plan_code": "pro" }
```
Создаёт `pending`-счёт + платёж ЮKassa (Idempotence-Key = invoice uuid).
`409` — уже есть активная подписка; `422` — неизвестный пакет/тариф;
`502` — провайдер недоступен (счёт отменяется).
### `POST /api/v1/billing/refund`
Тело `{ "invoice_id": "uuid" }`. Считает возврат: полный при возрасте
`REFUND_WINDOW_DAYS` (14) И использовании ≤ 20% купленного, иначе
пропорциональный `max(0, amount used × price_per_doc)`. Исполняет
провайдер-возврат, клавбэк оставшихся активов, при уходе баланса в минус —
`billing_hold`. Ответ:
```json
{ "invoice_id": "uuid", "refunded_kopecks": 99500, "kind": "proportional",
"reason": "used 15 of 20", "billing_hold": false }
```
`404` чужой/несуществующий счёт; `409` — уже возвращен / не возвращаем (zero).
---
## webhooks
Файл: [`webhooks.py`](webhooks.py). Тег: `webhooks`.
### `POST /api/v1/webhooks/yookassa`
**Auth:** Basic (`shopId:secretKey`, сверка constant-time). Уведомления
ЮKassa `payment.*` / `refund.*`. Push-payload НЕ доверяем: платёж всегда
перезапрашивается через REST, статус применяется идемпотентным стейт-машина
(`core/billing/fulfillment.py` — общий с worker-billing). Всегда `200`
(`ignored`/`unknown_invoice`/`disabled` в теле), чтобы ЮKassa не ретраила.
---
## pay page
Файл: [`../billing/pay_page.py`](../billing/pay_page.py). Тег: `billing`.
### `GET /pay/{invoice_id}?token=…`
Server-rendered HTML-статус счёта после редиректа с ЮKassa. `token`
короткоживущий HS256 JWT (`BILLING_RETURN_JWT_SECRET`, иначе `JWT_SECRET`;
TTL `BILLING_RETURN_TOKEN_TTL_MINUTES`). `403` — нет/просрочен/чужой токен.
Показывает статус (pending/succeeded/cancelled) и сумму; для pending —
кнопку на `confirmation_url`.
---
## documents
Файл: [`documents.py`](documents.py). Тег: `documents`.
**Auth:** `CurrentUserDep`.
### `POST /api/v1/documents`
Загрузить документ на анализ. Резервирует кредит, кладёт файл в MinIO,
создаёт строки `documents` + `jobs`, публикует `DocumentUploaded` в RabbitMQ.
Запрос — `multipart/form-data`:
| Поле | Тип | Описание |
| ----- | ----------- | ------------------------------------- |
| `file`| UploadFile | Расширения: `.pdf`, `.docx`, `.rtf`, `.txt`, `.csv`, `.png`, `.jpg`, `.jpeg`, `.tif`, `.tiff` (см. `core/extraction/formats.py`) |
Ответ `202` (результат [`upload_and_enqueue`](../services.py)):
```json
{
"document_id": "uuid",
"correlation_id": "uuid",
"credits_left": 6
}
```
Ошибки:
- `400` — нет имени / неподдерживаемый формат / пустой файл;
- `402` — нет кредитов (`no credits available`);
- `500` — сбой MinIO/БД/RabbitMQ (кредит возвращается).
---
## reports
Файл: [`reports.py`](reports.py). Тег: `reports`. **Auth:** `CurrentUserDep`.
### `GET /api/v1/reports/{document_id}`
Опрос статуса/результата анализа. Документ обязан принадлежать
аутентифицированному пользователю.
Параметр пути: `document_id` — UUID.
Пока анализ не готов (`status != "done"`):
```json
{
"document_id": "uuid",
"status": "queued | extracting | analyzing | failed",
"stage": "<current stage>"
}
```
Готовый отчёт (`status == "done"`):
```json
{
"document_id": "uuid",
"status": "done",
"filename": "contract.pdf",
"markdown": "<markdown-отчёт>",
"findings": { ... },
"model_used": "<llm model>",
"prompt_tokens": 1234,
"eval_tokens": 5678,
"latency_ms": 91011
}
```
`404` — отчёт не найден (или чужой).
---
## b2b
Файл: [`b2b.py`](b2b.py). Тег: `b2b`.
Анализ-эндпоинты используют **`X-API-Key`** (`ApiKeyAuthDep`).
Эндпоинты управления ключами используют **user JWT** (`CurrentUserDep`),
т.к. вызываются внутренними адаптерами от имени пользователя (`telegram_id`).
### `GET /api/v1/b2b/profile` / `PUT /api/v1/b2b/profile`
Настройки профиля владельца ключа — те же поля, что у
`PATCH /api/v1/me/profile` (язык, таймзона, prefs). **Auth:** `X-API-Key`.
PUT заменяет переданные поля целиком (не partial). Биллинг-эндпоинты
партнёрам не открываются (D4).
### `POST /api/v1/analyze`
Загрузить документ под B2B-ключом. **Auth:** `X-API-Key`.
Запрос — `multipart/form-data` (`file`), как в `POST /api/v1/documents`.
Резервирует кредит у владельца ключа, пишет `api_key_requests`,
инкрементит `api_keys.monthly_used`. Логирование usage — best-effort
(не валит загрузку при сбое записи).
Ответ `202` — как у `POST /api/v1/documents`.
### `GET /api/v1/b2b/reports/{document_id}`
Опрос отчёта, scoped к владельцу ключа. **Auth:** `X-API-Key`.
Параметр пути: `document_id` — UUID.
Формат ответа — идентичен `GET /api/v1/reports/{document_id}`.
`404` — чужой/несуществующий.
### `GET /api/v1/b2b/usage`
Текущее использование ключа. **Auth:** `X-API-Key`.
```json
{
"api_key_id": "uuid",
"rate_limit_rps": 3,
"monthly_quota": 1000,
"monthly_used": 17,
"requests_this_month": 17,
"resets_at": "2026-09-01T00:00:00+00:00"
}
```
### `POST /api/v1/b2b/keys`
Создать новый B2B-ключ. **Auth:** `CurrentUserDep`.
Тело — `CreateApiKeyRequest`:
| Поле | Тип | Default | Описание |
| ---------------- | ----- | ------- | ------------------------------ |
| `name` | str | — | Метка ключа |
| `rate_limit_rps` | int? | `3` | Должно быть `> 0` |
| `monthly_quota` | int? | `null` | Без лимита, если не задан |
Ответ `201`**raw-ключ возвращается только один раз**:
```json
{
"api_key": "sk_b2b_...",
"id": "uuid",
"name": "prod-webhooks",
"rate_limit_rps": 3,
"monthly_quota": null,
"monthly_used": 0,
"revoked": false,
"created_at": "2026-08-12T..."
}
```
`400``rate_limit_rps` не положителен; `500` — сбой БД.
### `GET /api/v1/b2b/keys`
Список ключей пользователя. **Auth:** `CurrentUserDep`.
Ответ — массив объектов формы `ApiKeyResponse`:
| Поле | Тип |
| ---------------- | -------- |
| `id` | UUID |
| `name` | str |
| `rate_limit_rps` | int |
| `monthly_quota` | int? |
| `monthly_used` | int |
| `revoked` | bool |
| `created_at` | ISO-str |
Сортировка — `created_at DESC`. Raw-ключи **не** возвращаются.
### `POST /api/v1/b2b/keys/{key_id}/revoke`
Отозвать ключ. **Auth:** `CurrentUserDep`. Параметр пути: `key_id` — UUID.
```json
{ "id": "uuid", "revoked": true }
```
`404` — ключ не найден, чужой или уже отозван.
### `GET /api/v1/b2b/keys/{key_id}/usage`
Помесячное использование конкретного ключа. **Auth:** `CurrentUserDep`.
```json
{
"key_id": "uuid",
"name": "prod-webhooks",
"rate_limit_rps": 3,
"monthly_quota": 1000,
"monthly_used": 17,
"resets_at": "2026-09-01T...",
"monthly_requests": [
{ "month": "2026-08-01T...", "requests": 17 }
]
}
```
`404` — ключ не найден или чужой.
---
## Коды ошибок
Общий формат ошибки FastAPI:
```json
{ "detail": "<сообщение>" }
```
| Код | Когда |
| --- | ----------------------------------------------------- |
| 400 | Неверный запрос (bad format / пустой файл / bad arg) |
| 401 | Нет/невалиден токен или API-ключ |
| 402 | `no credits available` (при резерве кредита) |
| 404 | Ресурс не найден или чужой |
| 429 | Rate limit / месячный квот B2B-ключа исчерпан |
| 500 | Сбой инфраструктуры (MinIO/PG/RabbitMQ) |
При `429` от rate limiter добавляется заголовок `Retry-After`.