Перейти к содержанию

Отложенный повтор для одного обработчика

Catalog может временно не записать SQLite-проекцию, хотя событие корректно. Немедленно возвращать ту же доставку в основную очередь опасно: она снова приходит без паузы и мешает другой работе. В этом уроке отложим повтор для конкретного consumer и ограничим число попыток. Результат — понятный путь временного отказа с сохранённым eventId.

Полные consumer.py и broker.py содержат retry-механизм. Автор не включал отказ и не запускал TTL-переходы. Документация TTL и документация dead letter exchanges определяют серверную часть задержки; пределы её надёжности относятся к выбранным classic queues и обсуждаются явно.

Временная ошибка отличается от неправильного договора

Недоступное хранилище или управляемый сценарий временного отказа дают шанс на успешное повторение позже. Неизвестная schemaVersion, неверный eventId или конфликт содержимого того же факта повторением времени не исправляются. Поэтому обработчик разделяет TransientError и PermanentError. Это предметная классификация, а не правило «любое исключение повторить трижды».

В учебном коде sqlite3.OperationalError рассматривается как временный отказ записи. Но в конкретной системе такой класс может включать и устойчивые проблемы конфигурации. При сопровождении нужно уточнять причины, а не считать тип исключения универсальным доказательством временности. Наша классификация показывает механизм и оставляет известную границу локального примера.

Для контролируемого будущего наблюдения предусмотрена переменная SIMULATE_TEMPORARY=article-107. Пока она задана в процессе consumer, соответствующий материал вызывает временную ошибку до изменения SQLite. Этот режим не запускается автором и не является случайной производственной неисправностью. Он нужен читателю, чтобы связать путь попытки с одним понятным событием.

Retry принадлежит обязанности получателя

У каждой основной очереди есть своя retry-очередь. Delivery.catalog.retry возвращает сообщение только catalog через delivery.resume и точный routing key catalog. Повтор не направляется в исходный topic-exchange, поэтому уже завершивший работу audit не получает ненужную новую копию. Исходный type события при этом остаётся material.published.

Полное объявление retry-очереди каталога в терминах текущей топологии:

channel.queue_declare(
    queue='delivery.catalog.retry', durable=True,
    arguments={
        'x-queue-type': 'classic',
        'x-message-ttl': 5000,
        'x-dead-letter-exchange': 'delivery.resume',
        'x-dead-letter-routing-key': 'catalog'
    }
)

В эту очередь не запускаем обычный consumer. Сообщение ждёт истечения TTL, после чего broker перенаправляет его по dead-letter-механизму. Значение пять тысяч миллисекунд является учебной задержкой. Оно не обещает ровно пять секунд до новой обработки: доступность назначения, очистка истёкших сообщений и занятость consumer влияют на наблюдаемое время.

TTL имеет отношение к нахождению сообщения в очереди. После возвращения в основную очередь начинается новая транспортная доставка. EventId и revision сохраняются, поскольку факт не изменился. Номер попытки находится в AMQP-заголовке attempt и увеличивается приложением только при выбранном временном отказе.

Сначала передать копию, затем подтвердить исходную доставку

Временный отказ вызывает публикацию той же полезной нагрузки в адресную retry-очередь. Канал работает в режиме confirms. Только после успешного принятия этой публикации отправляем ack исходной доставки. Так ответственность переходит в отложенное назначение, а не исчезает между двумя независимыми действиями приложения.

Следующий фрагмент находится в ветке временного отказа полного callback; event уже прошёл проверку:

attempt = (properties.headers or {}).get('attempt', 0)
target = (f'delivery.{name}.retry'
          if attempt < 3 else f'delivery.{name}.dead')
publish_event(channel, event, min(attempt + 1, 3),
              target, type(error).__name__)
channel.basic_ack(delivery_tag=method.delivery_tag)

Если приложение упало после принятой retry-публикации, но до ack, исходная доставка может вернуться и создать ещё одну отложенную копию. Порядок защищает от простой потери между действиями, но допускает дубликат. Это ещё одна причина не обещать exactly-once и хранить предметную квитанцию эффекта.

Если публикация в retry отказала, ack не выполняется. Исключение выйдет из callback, соединение будет закрыто в управляемом цикле, а неподтверждённая доставка может вернуться. Такое восстановление нельзя заменять блоком finally с безусловным ack. Отказ назначения означает, что приложение ещё не передало ответственность за работу.

Счётчик попыток не является числом всех доставок

Первая попытка имеет attempt 0. После временного отказа создаётся 1, затем 2 и 3. Отказ при attempt 3 направляет сообщение в dead-очередь. Но сетевой повтор той же транспортной стадии может сохранить тот же attempt. Поэтому значение не обязано совпадать с общим количеством вызовов callback за весь жизненный цикл факта.

Если publisher повторно отправил исходное событие с attempt 0 после неизвестного confirm, у получателя могут оказаться две независимые транспортные ветви одного eventId. Ограничение каждой ветви полезно, но не является глобальным бюджетом всей истории. Для более строгого бюджета потребуется устойчивое состояние попыток в прикладном хранилище.

Ожидаемый путь управляемого отказа:

catalog: attempt 0 → retry
TTL → catalog: attempt 1 → retry
TTL → catalog: attempt 2 → retry
TTL → catalog: attempt 3 → dead
audit: собственная обработка не повторяется по этому маршруту

Схема не утверждает точного времени и количества доставки при сетевых сбоях. Если отключить переменную временного отказа до исчерпания лимита, следующая попытка может сохранить эффект и завершиться ack. Дубликаты всё равно должны быть допустимы для функции записи.

Внутренний DLX имеет отдельную границу надёжности

В нашем retry используются classic queues и обычный dead-letter-переход. Он не получает автоматически такую же подтверждённую защиту, как явная публикация приложения. При недоступном назначении возможна потеря на внутреннем этапе. Поэтому весь итоговый учебный путь нельзя объявлять универсальной at-least-once-гарантией при любых отказах.

Для рабочих требований изучают quorum queues и специально настроенную стратегию at-least-once dead lettering, а также альтернативы вроде устойчивого планировщика повторов. Выбор зависит от допустимой потери, репликации и операционных возможностей. В этой серии сохраняем простой TTL-пример и честно ограничиваем его, не скрывая сложность инфраструктурного перехода.

Одинаковый TTL на всю retry-очередь делает учебный путь проще. Если задавать разную задержку каждому сообщению в одной очереди, порядок истечения и положение сообщения могут влиять на наблюдаемую доставку. Это не универсальный точный планировщик по времени. Для разных классов задержки можно использовать отдельные очереди, но тогда возрастает число объектов и правил возврата.

Заголовок attempt проверяется до обработки и ограничен допустимым целым диапазоном. Не принимаем отрицательное число или строку, чтобы ошибочный издатель не создавал бесконечный бюджет повторов. При адресной перепубликации тип факта сохраняется, а причина отказа остаётся коротким транспортным заголовком. Так consumer может снова прочитать тот же договор, не смешивая его с историей инфраструктурных попыток.

После урока временная ошибка имеет паузу и ограниченный путь конкретного получателя. Для упражнения объясните различие attempt и delivery_tag, затем момент падения после retry-confirm перед исходным ack. Далее сохраним постоянные и исчерпанные отказы, чтобы сопровождающий мог принять предметное решение по сообщению.

Оглавление серии