# Failover: RPC перестал отвечать, берём следующий по кругу

Требование владельца: **если RPC перестал отвечать, клиент берёт следующий RPC из списка этой сети и так по кругу.**
Здесь описано, как именно это устроено в `rpc_pool_client`, шаг за шагом, и как повторить то же на любом
языке, имея только HTTP API пула.

Все рабочие запросы при этом идут **только через прокси** (`docs/RULES.md`). Напрямую клиент не ходит никогда:

> **Work traffic to these RPCs MUST go through your proxy. Never call them from the host's main IP.**

Коротко:

- Список RPC сети приходит от пула уже отсортированным по надёжности и держится в памяти (и на диске, на случай аварии пула).
- RPC стоят в кольце. Курсор указывает на текущий. По умолчанию (`sticky`) он стоит на месте, пока RPC отвечает.
- RPC не ответил: он получает cooldown (60 с, дальше вдвое дольше, потолок 900 с), курсор идёт к следующему, **тот же запрос повторяется там**.
- Кольцо пройдено, а ответа нет: список запрашивается заново (не чаще раза в 5 с) и идёт ещё один раунд (всего `max_rounds=2`). Потом `NoHealthyRpcError`.
- Отказ прокси и отказ RPC различаются: умерший прокси не портит репутацию RPC.
- Ответы вида «revert» и «nonce too low» не отказ: они возвращаются вызывающему как есть.

## 1. Кольцо

```
 список пула, лучшие первыми

  курсор
    |
    v
 +-------+    +-------+    +-------+    +-------+    +-------+
 | RPC 1 |--->| RPC 2 |--->| RPC 3 |--->| RPC 4 |--->| RPC 5 |--+
 +-------+    +-------+    +-------+    +-------+    +-------+  |
     ^                                                          |
     +------------------------ по кругу <----------------------+
```

Как ведёт себя курсор в режиме `sticky`:

```
вызов 1   курсор на RPC 1, RPC 1 отвечает.                          Курсор остаётся на RPC 1.
вызов 2   RPC 1 не отвечает: cooldown 60 с, жалоба в пул.
          Тот же запрос уходит на RPC 2, RPC 2 отвечает.             Курсор сдвинулся на RPC 2.
вызов 3   начинает с RPC 2. К RPC 1 курсор сам не возвращается:
          он идёт дальше по кругу, только когда падает текущий RPC.
...
все пять отказали (RPC 1 всё ещё в cooldown и пропускается)
          -> раунд 1 пуст -> список запрашивается у пула заново -> раунд 2
          -> если и он пуст: NoHealthyRpcError(attempts=[...])
```

Что происходит с одной попыткой (одним RPC через один прокси):

```
          fn(url, сессия, привязанная к текущему прокси), не дольше timeout_s
                                   |
              +--------------------+---------------------+
           успех                                    исключение
              |                                         |
              v                                         v classify
   cooldown RPC и прокси сброшены,            CALLER      -> raise, как есть (revert, 400, баг в fn)
   ответ вернули                              PROXY_FAIL  -> cooldown прокси, тот же RPC через следующий прокси
                                              IP_BLOCKED  -> тот же RPC через другой прокси (один раз)
                                              CONN_FAIL   -> cooldown RPC, жалоба; 3 разных RPC в рамках одного вызова (счётчик по прокси) -> проверка прокси
                                              RPC_FAIL    -> cooldown RPC, жалоба, следующий RPC
                                              UNSUPPORTED -> метка «RPC не умеет метод» на 600 с, следующий RPC
                                              RANGE       -> без cooldown и жалобы, следующий RPC (всего 3 отказа)
```

## 2. Алгоритм по шагам

1. **Список.** При первом вызове по сети клиент запрашивает `GET /v1/chains/{chain}/rpcs`. Пул уже отсортировал список
   по надёжности (`score`), в каждом RPC есть `caps` (что он умеет). Список живёт в памяти и считается свежим `refresh_s`
   (600 с). Фонового таймера нет: устаревший список обновляется в начале ближайшего вызова. Параллельные вызовы делят один запрос.
   Каждый удачный ответ пула пишется на диск (раздел 6).
2. **Подмножество под метод.** Для `eth_sendRawTransaction`, `eth_getLogs` и архивных чтений список сужается до RPC,
   которые это умеют (раздел 5). Подходящих нет совсем: `NoCapableRpcError`, тихого отката на неподходящие RPC нет.
3. **Кандидаты.** Это RPC по кольцу, начиная с курсора. Пропускаются: RPC в cooldown; RPC, которые уже признаны «не умеет этот метод»;
   RPC, не подходящие по шагу 2; RPC, уже отказавшиеся от диапазона `getLogs` в этом вызове. Для `getLogs` на широкий диапазон
   RPC с `caps.logs >= 10000` идут вперёд, порядок внутри групп сохраняется.
4. **Прокси.** Берётся текущий прокси. Если все прокси в cooldown, поднимается `ProxyError`. Прямого соединения нет.
5. **Попытка.** Запрос к первому кандидату через прокси, не дольше `timeout_s` (15 с). Запрос к API пула в этот лимит не входит (у него свои 10 с).
6. **Успех.** У RPC и у прокси сбрасываются счётчик отказов и cooldown. В режиме `round_robin` курсор переходит на RPC после того, который ответил. Ответ возвращается.
7. **Отказ RPC.** Ответ классифицируется (раздел 3). Если виноват RPC: он получает cooldown, курсор (в `sticky`) переходит к следующему,
   **тот же запрос повторяется на следующем кандидате**, а жалоба на отказавший RPC запоминается.
8. **Кандидаты кончились, ответа нет.** Если в это время какой-то прокси признан мёртвым, отказы через него отменяются и кандидаты
   берутся заново (раздел 4). Иначе раунд закончен: список запрашивается у пула заново и начинается следующий раунд
   (`max_rounds`, по умолчанию 2). Если список запрашивали меньше 5 с назад (`MIN_REFETCH_S`), следующий раунд идёт по тому же списку:
   когда все RPC в cooldown, раунд кончается сразу, и без этой паузы каждый вызов скрипта был бы запросом к пулу. RPC в cooldown в новом раунде пропускаются, поэтому второй раунд пробует прежде всего то, что пул
   добавил в список за это время. Курсор при замене списка остаётся на том же URL, если он ещё есть, иначе встаёт на лучший RPC.
9. **Ничего не ответило.** `NoHealthyRpcError`: в его `attempts` каждая попытка (RPC, прокси, вердикт, описание), адреса и
   логины прокси замаскированы. Если RPC отказывались от диапазона `getLogs`, вместо него `LogsRangeTooLargeError`.
   Если все RPC сети в этот момент в cooldown, пробовать нечего: `attempts` пуст, сообщение `no healthy RPC: nothing could be tried`.
   Подожди конца cooldown.
10. **Жалобы.** При любом выходе из вызова (ответ, ошибка вызывающего вроде revert или `400`, `ProxyError`, `LogsRangeTooLargeError`, `NoHealthyRpcError`)
    в пул уходит `POST /v1/report` по одной жалобе на каждый RPC, чей отказ остался в силе
    (не отменён как вина прокси, и RPC не ответил позже в этом же вызове). Отправка fire-and-forget, ошибка доставки только пишется в debug-лог.
11. **Запросы к API пула идут напрямую, все запросы к RPC через `proxies`.**

Состояние (cooldown, метки, курсор) живёт в памяти процесса и общего хранилища не имеет: другой процесс начинает с чистого листа.
Внутри процесса оно принадлежит хэндлу, то есть паре (ссылка на сеть, `archive`): `pool.chain(8453)` и `pool.chain("eip155:8453")` это разные хэндлы
с отдельными списками, cooldown и курсорами, хотя сеть одна. Поэтому в одном проекте ссылайся на сеть везде одинаково, и именно chain id или ключом, не алиасом:
алиас (`base`, `eth`) может стать неоднозначным, когда каталоги изменятся, и пул ответит на него `404 unknown_chain` (`docs/CHAINS.md`, раздел 2). Кольцо прокси при этом одно на весь `RpcPool`.

### sticky и round_robin

| Режим | Курсор | Когда использовать |
|---|---|---|
| `sticky` (по умолчанию) | стоит на RPC, пока он отвечает; идёт дальше только при отказе текущего | чтения, которые должны быть согласованы между собой: nonce, состояние, цепочка запросов к одной ноде |
| `round_robin` | после каждого удачного вызова переходит на следующий RPC после ответившего | много независимых запросов, нагрузку надо размазать по пулу |

Режим задаётся для пула целиком: `RpcPool(..., mode="round_robin")`. В `round_robin` отказ не двигает курсор: отказавший RPC просто в cooldown и пропускается.

### Cooldown

Одинаков для RPC и для прокси: `60 с * 2^(n-1)`, потолок 900 с, где `n` это число отказов подряд. Успех сбрасывает счётчик.

| Отказ подряд | 1 | 2 | 3 | 4 | 5 и дальше |
|---|---|---|---|---|---|
| Cooldown | 60 с | 120 с | 240 с | 480 с | 900 с |

Метка «RPC не умеет этот метод» ставится отдельно, на пару (RPC, метод), на 600 с, и cooldown RPC не затрагивает.

## 3. Что считается отказом, а что нет

### Отказ: действуем сами

Вердикт определяет `classify.py`. Порядок проверок важен: сначала revert, затем диапазон, затем «не умеет метод», затем отказ ноды.

| Что случилось | Вердикт | Что делает клиент | Жалоба в пул |
|---|---|---|---|
| Не удалось подключиться к прокси, таймаут прокси (`ClientProxyConnectionError`, `ProxyConnectionError`, `ProxyTimeoutError`) | `proxy_fail` | cooldown прокси, тот же RPC через следующий прокси | нет |
| Прокси ответил на CONNECT `407` или `403`; SOCKS-ошибка по вине прокси (коды 1, 2, 7, 8, 93, сбой аутентификации) | `proxy_fail` | то же | нет |
| Прокси ответил на CONNECT другим кодом (`502`, `503`, `504`...); SOCKS: сеть или хост недоступны, отказ, TTL (коды 3 до 6), неизвестный код | `conn_fail` | как отказ соединения с RPC (строка ниже): виноват адресат, но отказ идёт в счёт проверки прокси | да |
| Таймаут `timeout_s`, обрыв, сброс соединения, ошибка TLS, DNS | `conn_fail` | cooldown RPC, следующий RPC; на 3-м разном RPC через один прокси проверяется сам прокси | да: `timeout` или `conn` |
| Негодный URL RPC (`InvalidURL` и прочие `aiohttp.ClientError`) | `rpc_fail` | cooldown RPC, следующий RPC | да: `rpc_error` |
| HTTP `5xx`, `408` | `rpc_fail` | cooldown RPC, следующий RPC | да: `rpc_error` (`timeout` для 408) |
| HTTP `404`, `405`, `410`: адрес RPC исчез или не принимает POST | `rpc_fail` | cooldown RPC, следующий RPC | да: `rpc_error` |
| HTTP `401`, `403`, `429` (часто бан или лимит на IP прокси) | `ip_blocked` | тот же RPC один раз через другой прокси. Не помогло или прокси один: как `rpc_fail` | да, только после второго отказа: `http_403` или `http_429` |
| HTTP `407` от прокси на обычный http-запрос | `proxy_fail` | cooldown прокси, тот же RPC через следующий прокси | нет |
| Ответ не JSON; не объект JSON-RPC; нет ни `result`, ни `error`; `error` неправильной формы | `rpc_fail` | cooldown RPC, следующий RPC | да: `rpc_error` |
| JSON-RPC `-32005`, `-32603` или сообщение со словами `rate limit`, `capacity`, `header not found`, `missing trie node`, `try again` | `rpc_fail` | cooldown RPC, следующий RPC | да: `rpc_error` |
| `-32601`, `method not found`; «not allowed / supported / available», «disabled», «unsupported» рядом со словом `method` или с именем вызванного метода | `unsupported` | метка (RPC, метод) на 600 с, следующий RPC | нет, cooldown RPC нет |
| Диапазон `getLogs`: в сообщении `block range`, `range too large`, `range too wide`, `more than N results`, `exceeds maximum block range`, `query timeout` | `range` | следующий RPC, всего 3 отказа (первый и ещё 2); дальше `LogsRangeTooLargeError` | нет, cooldown RPC нет |

### Не отказ: возвращается вызывающему как есть

| Что случилось | Что получает вызывающий |
|---|---|
| `execution reverted` (код 3 или слово `revert` в сообщении, даже если в тексте причины есть «not allowed») | `RpcError` |
| `nonce too low`, `insufficient funds`, `replacement transaction underpriced`, `-32602 invalid params` и прочие ошибки, которых нет в таблице выше | `RpcError` |
| HTTP `400`, `413`, `422` и другие статусы, говорящие про сам запрос | `RpcHttpError` |
| Исключение в твоём `fn` у `run()` (баг), отмена задачи | оно же, без изменений |
| `eth_sendRawTransaction` и «транзакция уже есть»: `already known` (Geth), `AlreadyKnown` (Nethermind), `known transaction` (Besu, старый Geth), регистр любой | **успех**: клиент возвращает хэш, посчитанный локально из raw-транзакции. `unknown transaction type` сюда не относится, это `RpcError` |

Для `eth_sendRawTransaction` повтор той же подписанной транзакции на другом RPC безопасен: хэш у неё один. Если первый RPC принял
транзакцию, но ответ потерялся, второй может ответить `already known` или его вариантом из таблицы (это успех) или, если транзакция уже включена в блок,
`nonce too low` (это `RpcError`: проверь квитанцию по хэшу сам).

## 4. Прокси: ротация и «умер прокси не значит умер RPC»

Прокси взаимозаменяемы. Клиент держит их тоже по кольцу в режиме sticky: текущий прокси используется, пока работает.
Кольцо прокси одно на все сети пула.

- **Ошибка самого прокси** (`proxy_fail`). Прокси получает cooldown, берётся следующий, тот же RPC повторяется. RPC не виноват:
  ему не ставится cooldown, и жалоба на него не уходит.
- **401, 403, 429 от RPC** (`ip_blocked`). Это может быть бан именно IP этого прокси. Тот же RPC повторяется через другой прокси,
  один раз; тот прокси становится текущим. Не помогло, или прокси всего один: это отказ RPC (cooldown, следующий RPC, жалоба).
  Поэтому жалоба на 401/403/429 уходит, только если RPC отказал через два разных прокси (или через единственный).
- **Проверка прокси.** Если через один прокси за вызов отказали на уровне соединения (`conn_fail`) 3 разных RPC (`PROXY_CHECK_AFTER`),
  проверяется сам прокси. Проверка бывает и в конце прохода без ответа: когда через прокси отказали все RPC, которые вызов пробовал, а их
  меньше трёх (в сети два RPC и мёртвый прокси: без этого правила он не проверился бы никогда).
  - Проверка: `eth_chainId` через подозрительный прокси на `proxy_check_url`. По умолчанию это два лучших RPC Ethereum mainnet
    (`eip155:1`) из пула; их адреса запоминаются на `refresh_s`. Второй спрашивается, только если первый не ответил: прокси отвечает
    `502`/`504` (на `CONNECT` или на обычный запрос) и тогда, когда лежит сам проверочный RPC, а не прокси. Мёртвым прокси считается, только
    если не прошёл ни один. Время проверки (`timeout_s`) делится между ними, зависший первый оставляет время второму.
    С явным `proxy_check_url` проверочный RPC один.
  - «Прокси работает» значит, что пришёл любой ответ от проверочного RPC, даже ошибка JSON-RPC или не-JSON тело. Через https
    подходит любой HTTP-статус (прокси только туннелирует). Через обычный http статусы `407`, `502`, `503`, `504` говорит
    сам прокси, и это «не работает». Таймаут, ошибка соединения и ошибка прокси это тоже «не работает».
  - Если проверочного RPC нет (пул не назвал его за 3 с или за половину `timeout_s`, если это меньше; пул не знает сеть или список пуст),
    считаем «работает»: прокси без доказательств не осуждаем. После проверки, которую прокси не прошёл, адреса проверочных RPC
    запрашиваются у пула заново.
  - **Не прошёл:** прокси мёртв, он получает cooldown, а отказы RPC через него **отменяются**: cooldown RPC откатываются, жалобы не
    отправляются, в `sticky` курсор встаёт на прежнее место. Кандидаты берутся заново, уже через следующий прокси.
  - **Прошёл:** прокси исправен, отказы RPC остаются в силе, а счётчик отказов соединения по этому прокси в текущем вызове обнуляется
    (следующая проверка только после трёх новых).
- **Все прокси в cooldown:** `ProxyError`. Напрямую клиент **никогда** не идёт. Что делать, см. `docs/RULES.md`.
- Один мёртвый прокси, найденный вызовом по одной сети, виден параллельным вызовам по другим сетям пула: они тоже отменяют свои отказы через него.

## 5. Маршрутизация по возможностям

Пул измеряет, что умеет каждый RPC (`caps`): `send_tx` (принимает транзакции), `logs` (самый широкий диапазон `getLogs`, который прошёл:
0, 1000 или 10000) и `archive` (`none`, `recent`, `full`). Клиент сам выбирает подходящие. Неизвестное значение (`null`) не подходит.

| Вызов | Какие RPC подходят | Нет ни одного |
|---|---|---|
| `eth_sendRawTransaction` | `caps.send_tx` равно `true` | `NoCapableRpcError` |
| `eth_getLogs` | `caps.logs >= 1000`; для диапазона больше 1000 блоков сначала `caps.logs >= 10000` | `NoCapableRpcError` |
| `pool.chain(x, archive=True)` | `caps.archive` равно `recent` или `full` | `NoCapableRpcError` |
| `pool.chain(x, archive="full")` | `caps.archive` равно `full` | `NoCapableRpcError` |
| `eth_getBalance`, `eth_getCode`, `eth_getTransactionCount`, `eth_getStorageAt`, `eth_call` на блоке старше `head - 128` | как `archive=True`, включается автоматически | `NoCapableRpcError` |

Уточнения:

- `head` это наибольший `height` среди RPC в списке пула. Автоматическая архивная маршрутизация срабатывает только когда блок задан числом
  (hex-квантити или объект EIP-1898 с `blockNumber`). `latest`, `safe` и хэш блока не считаются «старыми».
- Требования складываются: `getLogs` на архивном хэндле требует и `logs`, и `archive`.
- Пустой список сети (пул ничего не отдаёт) даёт не `NoCapableRpcError`, а `NoHealthyRpcError` без попыток.

### getLogs по кускам

`await chain.get_logs_chunked(filter, chunk=1000)`:

- `fromBlock` и `toBlock` задаются числом (int или hex), `"earliest"` или `"latest"`. Отсутствующая граница это `"latest"`, он один раз
  превращается в число вызовом `eth_blockNumber`.
- Диапазон режется на отрезки по `chunk` блоков, они запрашиваются по очереди, каждый со своим failover. Логи возвращаются в порядке блоков.
- Если RPC отказались от отрезка как от слишком большого (`LogsRangeTooLargeError`), отрезок делится пополам и запрашивается снова,
  дальше используется уменьшенный размер. Отрезок из одного блока, от которого всё равно отказались, поднимает ошибку.
- Фильтр с `blockHash` называет один блок и отправляется как есть.
- Ты можешь и сам вызывать `call("eth_getLogs", [filter])`: без дробления клиент попробует ещё максимум 2 RPC и поднимет `LogsRangeTooLargeError`.

## 6. Пул недоступен: кеш на диске

Скрипты не должны падать вместе с пулом.

- Каждый удачный ответ пула клиент пишет в `cache_dir` (по умолчанию `~/.cache/rpc_pool_client`), в файл
  `<chain>__<transport>__<require>.json`. `<chain>` это строка, с которой ты вызвал `pool.chain(...)`, приведённая к безопасному имени
  файла (`8453`, `eip155_8453`; разные записи одной сети дают разные файлы). Запись атомарная (временный файл и переименование),
  каталог создаётся с правами `0700`.
- Пул не отвечает, таймаут (10 с), ответил любым статусом кроме `200`, `400`, `401`, `404` (`5xx`, в том числе `503 pool_stale`, `429`
  и так далее; `3xx` тоже: клиент не ходит по редиректам, чтобы ключ API не уехал на чужой адрес) или мусором в теле: берётся файл, в лог идёт предупреждение с возрастом списка, failover идёт по нему как обычно. Возраст кеша не ограничен.
- Пул ответил `200`, но **пустым** списком, а в файле кеша RPC есть: файл не перезаписывается, клиент берёт его (предупреждение в лог).
  Так же и в памяти: пустой ответ не заменяет непустой список. Пустой ответ используется, только когда лучше ничего нет (первый запуск).
- Пул **отказал** (`400`, `401`, `404`: неверный ключ, неизвестная сеть, плохой запрос): это ошибка вызывающего, не авария.
  Сразу `PoolApiError`, к файлу кеша не откатываемся, чтобы старый файл не прятал неверный ключ. Исключение: если список этой сети уже загружен
  в памяти процесса, клиент остаётся на нём (предупреждение в лог) и повторит запрос через `refresh_s`.
- Нет ни ответа, ни файла: `PoolApiError` (`pool api unreachable and no cache for ...`).
- Если обновление списка закончилось `PoolApiError`, а список в памяти уже есть, клиент остаётся на списке в памяти (в лог идёт
  предупреждение) и повторит попытку через `refresh_s`.
- Жалобы в упавший пул, конечно, не доходят. Это не ошибка: доставка best effort.

Чтобы кеш был, достаточно один раз вызвать сеть при живом пуле.

## 7. Исключения

Всё, что клиент поднимает намеренно, наследует `RpcPoolError`.

| Исключение | Когда | Что делать |
|---|---|---|
| `RpcError` (`.code`, `.message`, `.data`) | детерминированная ошибка JSON-RPC: revert, неверный nonce, нет средств | обработать как ответ ноды; другой RPC скажет то же |
| `RpcHttpError` (`.status`) | HTTP-статус про запрос: `400`, `413`, `422`... | исправить запрос |
| `LogsRangeTooLargeError` | 3 RPC отказались от диапазона `getLogs` | дробить диапазон или `get_logs_chunked` |
| `NoCapableRpcError` | ни у одного RPC сети нет нужной возможности | сменить метод или сеть, подождать следующую проверку пула; расширить требования тихо клиент не будет |
| `NoHealthyRpcError` (`.attempts`) | все кандидаты отказали за все раунды | залогировать `attempts`, повторить позже (после cooldown) |
| `ProxyError` | все прокси в cooldown | **остановиться**, никогда не идти напрямую (`docs/RULES.md`) |
| `PoolApiError` | пул отказал (`400`/`401`/`404`) или недоступен, а кеша нет | проверить `RPC_POOL_URL`, `RPC_POOL_API_KEY`, имя сети |
| `MalformedResponseError` | `get_logs_chunked` получил ответ, которым нельзя пользоваться: `eth_blockNumber` не число, `eth_getLogs` не список логов | редкость. В обычном `call` ответ, не похожий на JSON-RPC, это отказ RPC (`rpc_fail`), и вызов идёт на следующий RPC |
| `ValueError`, `TypeError` | неверные аргументы (нет `proxies`, плохой URL прокси, неизвестный режим) | исправить код |

## 8. Псевдокод для любого языка (нужен только HTTP API)

Чтобы повторить failover без Python, нужны два запроса к пулу и твой HTTP-клиент с поддержкой прокси:

- `GET {POOL}/v1/chains/{chain}/rpcs?transport=http` с заголовком `X-API-Key`. Ответ: `chain.key`, `policy`, `rpcs[]`, у каждого RPC `url`, `score`, `height`,
  `caps.send_tx`, `caps.logs`, `caps.archive`. По умолчанию до 200 RPC, `limit` до 1000.
- `POST {POOL}/v1/report` с телом `{"chain": ..., "url": ..., "error": "timeout|http_403|http_429|conn|rpc_error", "detail": ...}`, ответ `202`.
- Все запросы к самим RPC только через прокси. Если прокси нет, не запускайся (`docs/RULES.md`).

```
# ===== константы =====
TIMEOUT = 15 s                      # одна попытка
MAX_ROUNDS = 2
REFRESH = 600 s                     # возраст списка
MIN_REFETCH = 5 s                   # раунд запрашивает список заново не раньше, чем через это время после прошлого запроса
COOLDOWN(n) = min(60 s * 2^(n-1), 900 s)       # n = отказов подряд
UNSUPPORTED_TTL = 600 s
PROXY_CHECK_AFTER = 3               # разных RPC с отказом соединения через один прокси
MAX_RANGE_REFUSALS = 3

# ===== состояние на сеть, в памяти процесса =====
ring[]            # rpcs из ответа пула, лучшие первыми
cursor            # индекс в ring
cool{key}         # key = URL RPC или URL прокси  ->  {fails, until}
unsupported{(url, method)}  ->  until
proxies[]         # ТВОИ прокси; пустой список: отказаться работать

function ensure_list(chain):
    if ring loaded and age(ring) < REFRESH: return
    resp = GET pool list                                   # напрямую, X-API-Key, таймаут 10 с
    if resp.status == 200 and body valid and resp.rpcs is empty and (ring or cache file) has RPCs:
        keep them, log a warning, retry after REFRESH       # пустой ответ не затирает ни кеш, ни список в памяти
    else if resp.status == 200 and body valid:
        write cache file atomically (temp file, потом rename)
        ring = resp.rpcs                                   # cursor остаётся на том же URL, если он есть, иначе 0
        forget state of URLs that left the list
    else if resp.status in (400, 401, 404):                # твоя ошибка, не авария; к файлу кеша НЕ откатываемся
        if ring loaded: keep it, log a warning, retry after REFRESH   # список в памяти остаётся рабочим
        else: raise PoolApiError
    else:                                                  # сеть, таймаут, 5xx (и 503 pool_stale), 429, мусор
        if ring loaded: keep it and retry after REFRESH
        else if cache file exists: ring = read cache file
        else: raise PoolApiError

function capability_filter(method, params):                # AND всех применимых условий
    if method == "eth_sendRawTransaction":  need rpc.caps.send_tx == true
    if method == "eth_getLogs":             need rpc.caps.logs >= 1000
                                            # span > 1000 блоков: caps.logs >= 10000 пробовать первыми
    if archive wanted, or a state read (balance, code, nonce, storage, eth_call) asks for a block number
       < max(height in ring) - 128:         need rpc.caps.archive in ("recent", "full")
    # неизвестное (null) условие не выполняет

function call(chain, method, params):
    ensure_list(chain)
    fits = capability_filter(method, params)
    if ring is not empty and no rpc in ring satisfies fits: raise NoCapableRpc
    attempts = []; refused = {}; reports = {}              # reports: url -> (error, detail)
    conn_failed = {}                                       # прокси -> множество URL с отказом соединения (в рамках ЭТОГО вызова)

    try:
        for round in 1 .. MAX_ROUNDS:
            if round > 1 and age(ring) >= MIN_REFETCH: ring = fetch list again (see ensure_list, ignoring REFRESH)
            repeat:                                        # проход; повторяется, если найден мёртвый прокси
                candidates = ring starting at cursor, wrapping around, without:
                                cooling(rpc.url), unsupported[(rpc.url, method)] > now,
                                not fits(rpc), rpc.url in refused
                (желательные, например caps.logs >= 10000, вперёд; порядок внутри групп сохранить)
                for rpc in candidates:
                    r = attempt(rpc)
                    if r is SERVED: return r.result
                    if r is RESTART: continue repeat
                suspects = proxies in conn_failed with >= min(3, number of different RPCs tried) failed URLs
                restart = false
                for p in suspects:
                    if proxy_is_dead(p): cool_proxy(p); undo_failures_through(p); restart = true
                    else: conn_failed.pop(p)               # прокси работает: отказы RPC остаются в силе, счёт с нуля
                if restart: continue repeat
                break repeat
        if refused not empty: raise LogsRangeTooLarge
        raise NoHealthyRpc(attempts)
    finally:                    # при ЛЮБОМ выходе: ответ, ошибка вызывающего (CALLER), ProxyError, LogsRangeTooLarge, NoHealthyRpc
        send_reports(reports)   # то, что осталось в силе (не отменено как вина прокси)

function attempt(rpc):
    proxy = current_proxy()                                # raise ProxyError, если все в cooldown. НИКОГДА не напрямую
    other_tried = false
    loop:
        answer = http_post(rpc.url, via = proxy, timeout = TIMEOUT, json = {jsonrpc, id, method, params})
        if method == "eth_sendRawTransaction" and answer is a JSON-RPC error matching /already[ _]?known|\bknown transaction\b/i:
            answer = OK(result = keccak256(raw tx))        # транзакция уже в пуле ноды: успех, хэш считаем сами
        v = classify(answer)                               # таблица раздела 3
        if v not in (OK, CALLER): attempts.append((rpc.url, proxy, v))
        switch v:
            OK:
                reset(rpc.url); reset(proxy); delete reports[rpc.url]
                if mode == "round_robin": cursor = index(rpc) + 1, по кругу
                return SERVED(answer.result)
            CALLER:                                        # revert, nonce too low, HTTP 400...
                raise answer.error                         # как есть, без failover
            PROXY_FAIL:
                cool_proxy(proxy)                          # RPC не виноват: ни cooldown, ни жалобы
                if undo_failures_through(proxy): return RESTART
                proxy = next_proxy_after(proxy)            # raise ProxyError, если больше нет
                continue loop                              # тот же RPC
            IP_BLOCKED:                                    # 401, 403, 429
                if not other_tried and another proxy is not cooling:
                    other_tried = true; proxy = that proxy; continue loop
                fail_rpc(rpc, "http_403" or "http_429"); return NEXT
            CONN_FAIL:
                fail_rpc(rpc, "timeout" or "conn")
                conn_failed[proxy].add(rpc.url)
                if size(conn_failed[proxy]) >= PROXY_CHECK_AFTER:
                    if proxy_is_dead(proxy):
                        cool_proxy(proxy); undo_failures_through(proxy); return RESTART
                    conn_failed.pop(proxy)                 # прокси работает: счёт с нуля, отказы RPC остаются в силе
                return NEXT
            UNSUPPORTED:
                unsupported[(rpc.url, method)] = now + UNSUPPORTED_TTL; return NEXT
            RANGE:
                refused.add(rpc.url)
                if size(refused) >= MAX_RANGE_REFUSALS: raise LogsRangeTooLarge
                return NEXT
            RPC_FAIL:
                fail_rpc(rpc, "rpc_error"); return NEXT

function fail_rpc(rpc, error):
    cool[rpc.url].fails += 1
    cool[rpc.url].until = now + COOLDOWN(cool[rpc.url].fails)
    if mode == "sticky" and rpc is at cursor: cursor = (cursor + 1) mod len(ring)
    reports[rpc.url] = (error, short text without secrets)

function proxy_is_dead(proxy):
    targets = [proxy_check_url] or ring("eip155:1")[0:2].urls   # два лучших RPC Ethereum mainnet из пула
    if no targets: return false                            # не проверить: не осуждаем
    for target in targets:                                 # TIMEOUT делится между ними поровну от остатка
        answer = http_post(target, via = proxy, json = eth_chainId)
        if no answer (timeout, connection error, proxy error): continue          # может, лежит сам target
        if target is plain http and status in (407, 502, 503, 504): continue    # ответил сам прокси
        return false                                       # ответил целевой RPC, что бы он ни сказал
    return true                                            # не прошёл ни через один

function undo_failures_through(proxy):                     # эти отказы были виной мёртвого прокси
    for url in conn_failed.pop(proxy): restore cool[url] to its state before; delete reports[url]
    in sticky mode put cursor back where it was before the first of them
    return true if anything was undone

function send_reports(reports):                            # fire-and-forget, после вызова
    for url, (error, detail) in reports:
        POST pool /v1/report {chain: <chain.key из ответа пула>, url, error, detail[:500]}
```

Упрощения по сравнению с Python-клиентом: нет общей для параллельных вызовов отмены отказов через прокси, который нашёл мёртвым другой вызов;
нет общего ожидания одного запроса списка при параллельных вызовах; нет общего кольца прокси для нескольких сетей. Для однопоточного скрипта этого достаточно.

`get_logs_chunked` поверх `call`:

```
function get_logs_chunked(chain, filter, chunk = 1000):
    if filter has blockHash: return call(chain, "eth_getLogs", [filter])
    low  = resolve(filter.fromBlock)         # число, "earliest" = 0; пусто или "latest": один раз eth_blockNumber
    high = resolve(filter.toBlock)
    size = chunk;  logs = []
    while low <= high:
        end = min(low + size - 1, high)
        try:    part = call(chain, "eth_getLogs", [filter with fromBlock = hex(low), toBlock = hex(end)])
        except LogsRangeTooLarge:
            if end == low: raise
            size = (end - low + 1) / 2;  continue          # половина, и дальше столько же
        logs += part;  low = end + 1
    return logs
```

## 9. Примеры

Везде ниже `PROXIES` это список прокси проекта, `RPC_POOL_URL` и `RPC_POOL_API_KEY` лежат в окружении.

### Python: `call`

```python
import asyncio

from rpc_pool_client import NoHealthyRpcError, RpcError, RpcPool

PROXIES = ["socks5://user:pass@proxy-a.example.com:1080", "http://user:pass@proxy-b.example.com:3128"]


async def main() -> None:
    # api_url и api_key по умолчанию берутся из RPC_POOL_URL и RPC_POOL_API_KEY
    async with RpcPool(proxies=PROXIES) as pool:
        base = pool.chain(8453)  # chain id (или ключ "eip155:8453"); алиасы в скриптах не используем
        try:
            head = await base.call("eth_blockNumber")
        except RpcError as exc:
            # ответ ноды (revert, неверные параметры): другой RPC скажет то же самое
            print("node says no:", exc.code, exc.message)
        except NoHealthyRpcError as exc:
            # прошли все RPC сети во всех раундах, никто не ответил
            for attempt in exc.attempts:
                print(attempt.url, attempt.proxy, attempt.verdict, attempt.detail)
        else:
            print(int(str(head), 16))
        # ProxyError здесь не ловим: все прокси в cooldown, значит работа стоит (docs/RULES.md)


asyncio.run(main())
```

Транзакция, логи, архив (фрагмент внутри `async with RpcPool(...) as pool:`):

```python
tx_hash = await base.call("eth_sendRawTransaction", [raw_tx])  # raw_tx: подписанная транзакция (hex-строка)

logs = await base.get_logs_chunked(
    {"address": token, "topics": [transfer_topic], "fromBlock": 40_000_000, "toBlock": "latest"},
    chunk=1000,
)

old_balance = await pool.chain(1, archive=True).call("eth_getBalance", [address, "0x10"])
```

### Python: web3.py

Нужен extra `web3` (`uv add --editable "<путь>/packages/rpc_pool_client[web3]"`). Провайдер не держит соединений: сессиями владеет пул.

```python
from web3 import AsyncWeb3

from rpc_pool_client import RpcPool

async with RpcPool(proxies=PROXIES) as pool:
    w3 = AsyncWeb3(pool.chain(8453).web3_provider())
    block = await w3.eth.block_number
    balance = await w3.eth.get_balance(address)  # address: checksum-адрес
```

Каждый запрос web3 это `call()` со всем failover. `revert` приходит как `ContractLogicError`, остальные исключения клиента
(`NoHealthyRpcError`, `ProxyError`...) пролетают как есть. Батчи (`w3.batch_requests()`) выполняются по одному запросу, каждый со своим failover.

### Python: `run` для REST-SDK (Aptos, Tron, Cosmos REST...)

`run(fn)` это базовый примитив: `fn(url, session)` получает URL очередного RPC сети и `aiohttp`-сессию, уже привязанную к текущему прокси,
а failover по кругу делает движок. `call`, `get_logs_chunked` и web3-адаптер построены поверх него.

```python
import aiohttp

from rpc_pool_client import RpcHttpError, RpcPool


def ledger_url(url: str) -> str:
    url = url.rstrip("/")
    return url if url.endswith("/v1") else f"{url}/v1"


async def ledger_info(url: str, session: aiohttp.ClientSession) -> object:
    async with session.get(ledger_url(url)) as response:
        if not 200 <= response.status < 300:
            # по статусу движок решит, чья это вина: RPC, прокси или твой запрос
            raise RpcHttpError(response.status, response.reason or "")
        return await response.json()


async with RpcPool(proxies=PROXIES) as pool:
    info = await pool.chain("aptos:mainnet").run(ledger_info)
```

Правила для `fn`:

- Используй только переданную `session`. Свой `ClientSession()` и любой другой HTTP-клиент обходят прокси: это нарушение правила №1.
- `fn` может вызываться несколько раз, на разных RPC. Она должна быть повторяемой.
- Для не-2xx поднимай `RpcHttpError(status)` до разбора тела. `response.raise_for_status()` тоже работает, но тогда `400`, `413`, `422` считаются виной RPC, а не запроса, и запрос уходит дальше по кругу.
- `call` подходит любому JSON-RPC по HTTP (Solana, Starknet, Sui...), не только EVM. `run` нужен, когда протокол другой.

### curl и bash-цикл через прокси

Список (напрямую, это свой сервер) и один рабочий запрос (только через прокси: `--noproxy '' -x`):

```bash
curl -fsS -H "X-API-Key: $RPC_POOL_API_KEY" "$RPC_POOL_URL/v1/chains/8453/rpcs?limit=20" \
  | jq '.rpcs[] | {url, score, caps}'

curl -sS --max-time 15 --noproxy '' -x "$WORK_PROXY" -H 'content-type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' "$RPC_URL"
```

**`--noproxy ''` обязателен в каждом `curl` с `-x`.** Если в окружении задана `NO_PROXY` (или `no_proxy`), `curl` игнорирует `-x` для подходящих
хостов и идёт к ним **напрямую** (проверено на curl 8.7.1: команда-антипример `NO_PROXY='*' curl -x http://127.0.0.1:1 http://127.0.0.1:2/` обращается к порту 2, а не к прокси; так не делай).
Пустой `--noproxy ''` отключает этот список. Python-клиент от `NO_PROXY` не зависит: переменные окружения с прокси он не читает (`trust_env` у `aiohttp` выключен).

Failover по кругу на bash (нужны `curl` и `jq`; работает на bash 3.2 из macOS). Прокси один: если он мёртв, скрипт останавливается и **не** уходит напрямую.

```bash
#!/usr/bin/env bash
# Failover around the ring of one chain with curl. Work traffic ONLY through $WORK_PROXY (-x).
set -euo pipefail

POOL="${RPC_POOL_URL:?set RPC_POOL_URL, e.g. https://bomzrpc.online}"
KEY="${RPC_POOL_API_KEY:?set RPC_POOL_API_KEY}"
PROXY="${WORK_PROXY:?set WORK_PROXY, e.g. socks5h://user:pass@proxy-a.example.com:1080}"  # no proxy, no work
CHAIN="${1:-8453}"
PAYLOAD='{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
BODY="$(mktemp)"
trap 'rm -f "$BODY"' EXIT
pending=()   # connection-level reports, held back until the proxy is known to be alive

pool_get() { curl -fsS --max-time 10 -H "X-API-Key: $KEY" "$POOL$1"; }  # the pool: direct, it is our own server

report() {  # $1 url, $2 error
  curl -sS --max-time 5 -o /dev/null -X POST -H "X-API-Key: $KEY" -H 'content-type: application/json' \
    --data "$(jq -n --arg c "$CHAIN" --arg u "$1" --arg e "$2" '{chain:$c, url:$u, error:$e}')" \
    "$POOL/v1/report" || true
}

flush_reports() {  # the proxy is alive, so the held-back failures were the RPCs' fault
  local p
  if [ "${#pending[@]}" -gt 0 ]; then
    for p in "${pending[@]}"; do report "${p%:*}" "${p##*:}"; done
  fi
}

answer() { flush_reports; cat "$BODY"; exit 0; }  # an answer came through the proxy: it works

proxy_carries() {  # does the proxy carry anything at all? eth_chainId to the best Ethereum RPC of the pool
  # simplified: any HTTP answer counts as "alive"; with a plain-http target a 407/502/503/504 may come from the proxy itself
  local target
  target="$(pool_get '/v1/chains/eip155:1/rpcs?limit=1' | jq -r '.rpcs[0].url // empty')" || return 0
  [ -n "$target" ] || return 0              # nothing to check through: do not condemn the proxy
  curl -sS -o /dev/null --max-time 15 --noproxy '' -x "$PROXY" -H 'content-type: application/json' \
    --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' "$target"
}

stop_dead_proxy() { echo "the proxy carries nothing: STOP (never go direct)" >&2; exit 3; }

# 1. the list, best first
RPCS=()
while IFS= read -r url; do RPCS+=("$url"); done < <(
  pool_get "/v1/chains/$CHAIN/rpcs?limit=20" | jq -r '.rpcs[].url')
[ "${#RPCS[@]}" -gt 0 ] || { echo "the pool lists no RPC for $CHAIN" >&2; exit 1; }

# 2. around the ring: the next RPC when the current one does not answer
conn_fails=0
for url in "${RPCS[@]}"; do
  rc=0
  code="$(curl -sS -o "$BODY" -w '%{http_code}' --max-time 15 --noproxy '' -x "$PROXY" \
    -H 'content-type: application/json' --data "$PAYLOAD" "$url" 2>/dev/null)" || rc=$?
  host="${url#*://}"; host="${host%%/*}"    # log the host only: the path may hold a key
  case "$rc:$code" in
    0:2??)
      if jq -e 'has("result")' "$BODY" >/dev/null 2>&1; then answer; fi
      case "$(jq -r '.error.code // empty' "$BODY" 2>/dev/null || true)" in
        -32601)           echo "$host does not serve the method" >&2 ;;   # skip it, no report
        -32005|-32603|"") echo "node failure at $host" >&2; report "$url" rpc_error ;;
        *)                answer ;;  # the node's own answer (revert, bad params): not a failure
      esac ;;
    0:400|0:413|0:422)    answer ;;  # about the request itself: give it back as it is
    0:401|0:403)          echo "HTTP $code at $host" >&2; report "$url" http_403 ;;
    0:429)                echo "HTTP 429 at $host" >&2; report "$url" http_429 ;;
    0:*)                  echo "HTTP $code at $host" >&2; report "$url" rpc_error ;;
    *)                    echo "no connection to $host (curl exit $rc)" >&2
                          pending+=("$url:$([ "$rc" = 28 ] && echo timeout || echo conn)")
                          conn_fails=$((conn_fails + 1))
                          if [ "$conn_fails" -ge 3 ]; then
                            proxy_carries || stop_dead_proxy
                            conn_fails=0   # the proxy works: these failures stand, count afresh
                          fi ;;
  esac
done

# 3. nobody answered: blame the RPCs only if the proxy is fine
enough=3; [ "${#RPCS[@]}" -ge 3 ] || enough="${#RPCS[@]}"   # min(3, RPCs tried)
if [ "$conn_fails" -ge "$enough" ]; then proxy_carries || stop_dead_proxy; fi
flush_reports
echo "no RPC answered for $CHAIN" >&2
exit 1
```

Это упрощённая версия одного прохода: нет cooldown между запусками, нет второго раунда и ротации прокси. Для долгоживущего скрипта
держи словарь «URL -> время, до которого пропускаем», обновляй список раз в 10 минут и заводи несколько прокси: именно это делает клиент.
Проверка прокси здесь тоже упрощена: любой HTTP-ответ проверочного RPC считается «прокси жив». У настоящего клиента при обычном http-адресе
проверочного RPC статусы `407`, `502`, `503`, `504` означают, что ответил сам прокси, то есть «мёртв», и прокси осуждается, только если
не прошёл и второй проверочный RPC (раздел 4).

## 10. Параметры по умолчанию

| Параметр | По умолчанию | Как поменять |
|---|---|---|
| `timeout_s` | 15 с на одну попытку и на одну проверку прокси | `RpcPool(timeout_s=...)` |
| запрос к API пула | 10 с | не настраивается |
| cooldown RPC и прокси | 60 с, удваивается, потолок 900 с, успех сбрасывает | не настраивается |
| метка «не умеет метод» | 600 с, на пару (RPC, метод) | не настраивается |
| `max_rounds` | 2 | `RpcPool(max_rounds=...)` |
| повторный запрос списка между раундами | не чаще раза в 5 с (`MIN_REFETCH_S`) | не настраивается |
| `refresh_s` | 600 с: возраст списка и адреса проверочного RPC | `RpcPool(refresh_s=...)` |
| `mode` | `"sticky"` | `RpcPool(mode="round_robin")` |
| `cache_dir` | `~/.cache/rpc_pool_client` | `RpcPool(cache_dir=Path(...))` |
| проверка прокси | после 3 разных RPC с отказом соединения через него; проверочные RPC: два лучших `eip155:1` из пула, второй только если первый не прошёл | `RpcPool(proxy_check_url=...)` (тогда один) |
| отказы по диапазону `getLogs` | не больше 2 лишних RPC (3 отказа всего) | не настраивается |
| `api_url`, `api_key` | переменные `RPC_POOL_URL`, `RPC_POOL_API_KEY`, если аргументы не переданы | аргументы `RpcPool(api_url=..., api_key=...)` |

Клиент работает только с HTTP-RPC (`transport=http`). WebSocket-эндпоинты пула клиентом не используются.

## 11. Отладка

Каждая попытка пишется в логгер `rpc_pool_client.engine` на уровне DEBUG (URL и прокси замаскированы):

```python
import logging

logging.basicConfig(level=logging.WARNING)
logging.getLogger("rpc_pool_client").setLevel(logging.DEBUG)
```

Строки вида `https://rpc.example.org via socks5://***@proxy-a.example.com:1080: conn_fail (TimeoutError: no answer in 15 s)` показывают вердикт
по каждой попытке. Предупреждения (`pool api unavailable ... using the cached list`, `proxy ... failed its check`) пишутся
на уровне WARNING.
