Bitcoin-платежи: ошибки RPC и повтор запроса
В предыдущем уроке поставили ограниченный RPC-клиент между Core и сервером библиотеки. Теперь разберём ситуацию, в которой он не получил обычный результат. Сетевой timeout, отказ авторизации и ошибка метода требуют разных решений. Если свести их к одной кнопке «Повторить», приложение легко создаст новую операцию вместо восстановления прежней.
Наш наблюдатель преимущественно читает состояние. Однако при создании счёта отдельный процесс выделяет адрес, а позднее появится согласованный возврат. Поэтому сразу введём категории ошибок, пригодные для разных видов действий. Примеры описывают будущую модель; RPC-запросы и повторные операции сейчас не выполняются.
Разделяем три исхода
Первый исход — корректный ответ с результатом. Второй — достоверный отказ: Core вернул JSON-RPC error с тем же request id. Третий — неизвестный итог: соединение оборвалось, ответ повреждён или истёк срок ожидания. В последнем случае мы знаем только то, что клиент не получил пригодного доказательства результата.
Это особенно заметно для изменения состояния. Сервер мог обработать действие и отправить ответ, который потерялся. Повтор «на всякий случай» станет вторым действием. У чтения повтор обычно безопаснее, но и оно может показать состояние уже другого блока; значит, нужно сохранить контекст наблюдения, а не объединять несовместимые ответы.
Корректная ошибка JSON-RPC 2.0 не обязана сопровождаться HTTP 500. Как описано в интерфейсе Core, проверяем envelope и поле ошибки. Транспортный код и протокольный результат — разные уровни, поэтому статус HTTP 200 сам по себе ещё не означает успешную операцию.
Сохраняем категорию внутри сервиса
Для учебного обработчика заведём собственные типы результата. В них нет cookie, сырых аргументов кошелька или полного traceback. Служебная диагностика получает отдельный безопасный correlation id.
from dataclasses import dataclass
from enum import Enum
class RpcOutcome(Enum):
SUCCESS = "success"
REJECTED = "rejected"
UNKNOWN = "unknown"
UNAVAILABLE = "unavailable"
@dataclass(frozen=True)
class RpcFailure:
outcome: RpcOutcome
operation_id: str
retryable_read: bool
category: str
REJECTED означает, что получен валидный отказ операции. UNKNOWN оставляем для запроса, который мог попасть на сервер. UNAVAILABLE используем, когда запрос заведомо не был отправлен, например не удалось прочитать локальную конфигурацию до сетевого обращения. Точная классификация зависит от места отказа, поэтому исключение не нужно угадывать по тексту сообщения.
В реальном транспортном слое ошибка должна сохранять фазу: подготовка, отправка, чтение, разбор ответа. Даже connect timeout и read timeout имеют разный смысл. В коротком клиенте прошлого урока это ещё не реализовано полностью; здесь описываем контракт, который предстоит добавить при будущей реализации.
Повторяем чтение с ограничениями
Пусть gettransaction не ответил за пять секунд. Наблюдатель может повторить чтение той же известной транзакции, но делает это по расписанию с паузой и пределом попыток. Он не создаёт параллельную бесконечную очередь на каждый timeout. При длительной недоступности источник объявляется устаревшим.
Политика, например, допускает три попытки с увеличением задержки. Это условные параметры лаборатории, а не универсальный норматив. Между попытками сохраняем id наблюдения и исходный txid. Новый ответ заменяет текущую проекцию этого наблюдения, а не добавляет второй платёж на ту же сумму.
Случайная добавка к паузе помогает избежать одновременного наплыва обработчиков. При этом не нужно продолжать попытки после обнаруженного неверного endpoint или отказа авторизации. Такие ошибки требуют исправления конфигурации; повышение частоты вызовов только увеличивает шум и нагрузку.
Не повторяем изменение вслепую
Другой пример — getnewaddress для нового заказа. Клиент потерял ответ, но кошелёк уже выделил адрес. Если выделить ещё один и забыть первый, средства, отправленные по прежней инструкции, могут остаться неназначенными. Поэтому запрос создания имеет своё состояние и сохранённый ключ заказа, как в четвёртом уроке.
Сам JSON-RPC request id не обеспечивает идемпотентность серверного метода. Это идентификатор ответа, а не обещание Core исполнить изменение только один раз. Собственная операция приложения должна хранить этапы и уметь переводить неоднозначный исход в восстановление либо ручной разбор.
Возврат средств ещё строже: отсутствие ответа не даёт права сформировать другую транзакцию на ту же сумму. Сначала устанавливают существование прежнего подписанного результата и его дальнейшую судьбу. Наша служба наблюдения не имеет команды отправления, поэтому такая ошибка не может быть исправлена внутри читательского RPC-адаптера.
Что увидит пользователь
Карточка счёта должна различать «оплаты пока не видно» и «сервис давно не получил актуального наблюдения». Первое основано на свежих данных, второе сообщает предел знания. Если источник недоступен, не показываем уверенное «Вы не оплатили» и не просим отправить сумму ещё раз.
{
"status": "observation_delayed",
"message": "Статус обновляется с задержкой. Повторный перевод не требуется.",
"lastObservedAt": "2026-10-12T12:04:00Z"
}
Это условное представление задержки, а не фактический ответ сайта. Время означает последнее успешное наблюдение, а не момент, когда открыт экран. Если обновлять его при каждом HTTP-запросе независимо от Core, интерфейс начнёт скрывать реальную давность данных.
Для собственного счёта пользователь может запросить обновление карточки. Такая кнопка не вызывает дорогой RPC напрямую на каждое нажатие: сервер использует ограниченную очередь и уже сохранённую проекцию. Это сохраняет доступность публичной страницы, когда внутренний источник работает медленно.
Меняем условие: источник снова доступен
После восстановления связи наблюдатель продолжает с сохранённой контрольной точки. Он сверяет цепочку, перечитывает необходимые операции и применяет наблюдения по их устойчивым идентификаторам. Пауза сервиса сама по себе не отменяет платёж и не создаёт новое право доступа.
Если новые данные противоречат старой проекции, сохраняем причину перехода: конфликт, изменение глубины или реорганизация. Нельзя просто заменить старый JSON новым и потерять историю решения о доступе. Последующие уроки построят журнал именно для такого восстановления.
Разбор результата
Теперь отказ RPC имеет определённый смысл. Чтение повторяется ограниченно, изменение не дублируется при неизвестном исходе, а интерфейс показывает давность наблюдения. Это позволяет отделить проблему связи от отсутствия оплаты и не перекладывать её на читателя повторным переводом.
Следующий шаг — нормализация выходов кошелька. На её основе появится устойчивый журнал, который сможет пережить повторные ответы и перезапуск процесса. Оглавление связывает все главы курса.