Что делать, если произошла ошибка? Житейская мудрость говорит, что можно попробовать еще раз.
В контексте разработки ПО существует известный и простой паттерн для повышения отказоустойчивости — retry.
Реализация ретраев в Java-приложениях придумана давно. Как вариант — достаточно добавить в код стандартные аннотации. Но внедрить ретраи — это не так просто, как кажется: нужно решить еще много вопросов — например, как сохранить согласованность данных, как подружить ретраи на разных уровнях архитектуры, а еще учесть особенности разных технологий. И наконец, как при этом не навредить проекту.
Привет! Меня зовут Петр Деменев, я Java-разработчик в MTC Web Services. В материале расскажу о нашем горьком, но успешном опыте внедрения политики ретраев в реальный проект и о неочевидных особенностях, с которыми мы столкнулись. В тексте будет акцент не на строгих теоретических канонах, а на реальной жизни, чтобы приземлиться на уровень практического опыта. И попутно расскажу о некоторых способах ретрая.

В этом материале:
3.1 Блокирующий ретрай на консюмере
3.2 «Вырожденный» ретрай на консюмере
Retry policy в цепочке микросервисов
4.1 Retry на разных уровнях, избегаем retry-шторм
4.2 Фантомные ретраи
4.4 История про дубли № 1. Пакетное создание записей
4.5 История про дубли № 2. Неработающий ретрай в готовой логике
Коротко о проекте и архитектуре
Проект, в который мы внедряли политику ретраев, в части архитектуры достаточно обычный, с типовыми решениями. Это один из проектов для автоматического взаимодействия с партнерами. Партнеры делают нам запрос, мы собираем данные, обрабатываем и формирует ответ. На рисунке ниже можно увидеть очень упрощенную схему проекта:

Вот некоторые из особенностей архитектуры:
В основе — конвейер из микросервисов. В этой цепочке микросервисы асинхронно передают данные на следующий этап обработки, отправляя сообщения в очередь Kafka.
Для общения с внешними сервисами и на промежуточных этапах между микросервисами используются http-запросы.
Данные храним в базе данных Postgres.
Требования к проекту:
Гарантия доставки — at-least-once, то есть хотя бы один раз, но должны ответить.
Ограничение по времени ответа есть, но это ограничение относительно не жесткое. Можно отвечать хоть несколько минут.
Интересная особенность задачи, которая перед нами стояла, это то, что вся функциональная логика уже была готова. Многие части проекта мы переиспользовали от другого проекта — оставалось только подтюнить надежность.
А где же нужны ретраи? Вот список того, где есть высокий риск ошибок, которые сложно предотвратить программно, и где хотелось бы видеть отказоустойчивую работу, а значит, и ретраи:
запросы во внешние системы и инфраструктурные сервисы;
запросы между сервисами внутри проекта;
места, где могут быть высокая нагрузка, таймауты, перезапуски;
места, где может быть «что-то с сетью».
Если выделить кружками на схеме эти места, то их получается довольно много:

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

Алгоритм несложный, и дальше посмотрим, как его можно реализовать. Мы будем возвращаться к этой схеме, чтобы рассмотреть разные варианты завершения алгоритма.
Простая реализация
Реализовать ретраи можно, просто написав соответствующий Java-код, как в примере ниже:
int retryCount = 3; for (int i = 1; i <= retryCount; i++) { try { execute(); break; } catch (RetryableException ex) { if (i == retryCount) { throw ex; } Thread.sleep(3000); } }
Возможно, кто-то скажет: «Зачем городить свой код, если все уже давно написано и можно только добавить аннотации?» Но у этого самописного решения есть свои плюсы:
Мы сами полностью контролируем ретраи.
Это решение все-таки достаточно простое.
Такого решения часто вполне достаточно, и иногда нет смысла подключать внешние тяжеловесные инструменты.
В одном из наших сервисов примерно так и реализованы ретраи.
Далее посмотрим на готовые решения. Хороший вариант — библиотека resilience4j, но этот вариант оставим упомянутым вскользь. А рассмотрим чуть подробнее аннотацию Spring Retryable.
Spring-аннотация Retryable
Простое и популярное решение — Spring-аннотация Retryable. Вся реализация уже написана под капотом, нам нужно только добавить настройки. Вот пример таких настроек:
@Retryable( value = { RetryableException.class, TimeoutException.class }, maxAttempts = 5, backoff = @Backoff( delay = 1000, maxDelay = 600000, multiplier = 2 ) ) public class MyService { //class code }
В настройках указаны исключения, которые нужно ретраить, максимальное количество попыток, и описана стратегия ретрая: начинаем с задержки 1000 секунд, затем каждую последующую задержку умножаем на два. В данном случае настроена экспоненциальная стратегия. Про эти настройки можно посмотреть в документации и в большом количестве статей в интернете.
А мы внимательнее посмотрим на то, что делать в случае неуспешного ретрая, то есть когда попытки кончились. Это самый правый вариант окончания алгоритма на блок-схеме. Кстати, легко вообще забыть обработать эти неуспешные ветви алгоритма.
Для такого случая есть аннотация Recover — это своего рода спутник аннотации Retryable. Создаем отдельный метод, который принимает в качестве аргумента исключение, которое нужно ретраить. Прописываем, что делать при неуспешном ретрае. В примере ниже оборачиваем исходное исключение в свое исключение RetryableException с нужным пояснением. Помечаем новый вспомогательный метод аннотацией Recover и складываем в тот же класс:
@Recover public <T> T recover(TimeoutException ex) { throw new RetryableException("All attempts failed", ex); }
Вот тут появляется один из подводных камней. А что, если слишком велик список исключений, которые мы хотим ретраить, и этим исключениям не подобрать общего родителя? Значит, нужно создать отдельный recover-метод для каждого исключения. И это уже неудобно.
Второй момент: это возвращаемый тип. Recover-метод должен возвращать тот же тип, что и retryable-метод. Один recover-метод может работать сразу для всех методов класса, у которых такой же возвращаемый тип. Но допустим, у нас в классе несколько методов и у каждого свой возвращаемый тип. Пример — методы CRUD. Получается, для каждого такого метода нужно вновь создавать отдельный recover-метод. Для обычных Java-типов еще можно обойтись общим параметризованным recover-методом, как в примере кода выше, а если возвращается примитивный тип или метод ничего не возвращает, то отдельный recover-метод все равно придется писать и для каждого примитивного типа, и для void. В примере ниже добавлены методы recoverForVoid:
@Recover public <T> T recover(EnotherException ex) { throw new RetryableException("All attempts failed for EnotherException", ex); } @Recover public void recoverForVoid(TimeoutException ex) { throw new RetryableException("All attempts failed for void method and TimeoutException", ex); } @Recover public void recoverForVoid(EnotherException ex) { throw new RetryableException("All attempts failed for void method and EnotherException", ex); }
Теперь посмотрим на последнюю ветвь алгоритма на блок-схеме, то есть на случай, когда произошло исключение, которое и нет смысла ретраить. Для такого случая вновь используем recover-методы. И здесь точно так же нужны отдельные методы на разные возвращаемые типы и разные non-retryable-исключения. В примере ниже тип исключения — Exception, что означает «все остальные» исключения, кроме тех, для которых уже есть recover-методы. То есть если есть recover-метод с более конкретным типом исключения, то сработает именно этот метод. А если такого метода нет, то сработает метод с более общим типом исключения:
@Recover public <T> T recover(Exception ex) throws Exception { throw ex; } @Recover public void recoverForVoid(Exception ex) throws Exception { throw ex; }
Вот сколько recover-методов нужно создать, если в классе много разных методов! По сути, происходит комбинаторный взрыв: вариативность типов исключений и возвращаемых типов кратно увеличивают количество recover-методов. А это засоряет код и усложняет его поддержку. Например, если у нас был красивый код класса с несколькими лаконичными методами, то это все безжалостно обрастает retry-обвеской, которая может превышать по объему кода бизнес-логику.
Конечно, решения есть, например:
Вынести код повторных попыток в интерфейс/класс-родитель или в класс-обертку. Или даже вообще в отдельную библиотеку.
Перехватывать разные retryable-исключения в бизнес-коде с помощью try-catch и единообразно оборачивать в свой самописный RetryableException вместо обработки всех возможных типов исключений.
Важно то, что когда-нибудь в будущем вы и ваши коллеги можете расширить код, и нужно не забыть расширить и retry-обвеску.
Еще есть пара интересных особенностей:
Если произошло исключение, которое не описано в recover-методах, а в классе есть хоть один метод с аннотацией Recover, то это исключение будет обернуто в ExhaustedRetryException и проброшено дальше. Если recover-методов в классе нет, то исключение просто будет проброшено дальше. Это важно учитывать, если вы потом на более высоких уровнях как-то перехватываете исключения.
Ретрай этим способом срабатывает даже на те исключения, которые не выброшены напрямую, а которые являются cause внутри других исключений-оберток. Это неявное поведение, его тоже стоит учитывать. Хотя в общем случае оно добавляет удобства.
Что ретраить, а что нет?
Выше мы обсуждали, что нужно реагировать на разные типы исключений. Но важно еще и различать, что ретраить, а что нет. Первое, что приходит в голову: ретраить надо все исключения. Мысль неудачная, потому что дополнительные попытки — это дополнительное затраченное время, а, как правило, большую часть ошибок как раз ретраить и не нужно. Пример — ошибки пользователя, когда он что-то не так ввел.
Второе, что приходит в голову: давайте ретраить везде какие-нибудь типовые исключения — IOException, например. На практике может быть много разных исключений для разных случаев. И особенно остро проблема стоит, если используются какие-то внешние библиотеки или фреймворки.
Подводный камень здесь в том, что может понадобиться целое исследование:
Какие вообще могут быть исключения по коду этих внешних решений?
Какие исключения бывали при тестах / на проде?
Нужно ли каждое конкретное исключение ретраить или нет?
То есть нужно заложить ресурсы на это исследование, иначе ретрай может остаться бессмысленным. Кстати, наличие телеметрии (логов, метрик и трассировок) очень поможет в таком исследовании.
Ретраи и Kotlin
Короткая заметка про случай, когда в вашем проекте есть классы, написанные на Kotlin. Допустим, вы широким фронтом внедряете ретраи через аннотацию Retryable. В Kotlin эта аннотация ожидаемо тоже работает. И вот у вас появился код с аннотацией Retryable и suspend-функцией:
@Retryable // Не работает suspend fun getCount(): Int { return count() }
В данном случае ретрай молчаливо не сработает, потому что у suspend-функций особый механизм обработки исключений. Вариант решения — пометить аннотацией Retryable не метод getCount() в примере, а реализацию метода count(). Эту особенность я подробно описывать не буду, лишь скажу, что подводный камень в том, что важно учитывать вот такие особенности.
Retry policy и Kafka
Блокирующий ретрай на консюмере
Одно из мест, где можно добавить ретрай, — это чтение сообщений из очереди Kafka, то есть ретрай на консюмере. Один способов такого ретрая — это блокирующий ретрай: сервис-консюмер вычитывает сообщение из очереди и не берет в работу следующее сообщение, пока не завершил все попытки обработать текущее сообщение.

В Spring Kafka есть встроенный механизм такого ретрая. Его можно настроить, указав примерно те же настройки, что и для аннотации Retryable (например, задержку, количество попыток), сделать нужный обработчик. Примеры можно посмотреть в обзорной статье на сайте документации Spring, я же перейду к необычному описанному далее кейсу.
«Вырожденный» ретрай на консюмере
Когда сервис вычитывает сообщение из очереди, то по завершении вычитывания он делает коммит и приступает к следующему сообщению. Если произошла ошибка при обработке вычитанного сообщения, эта ошибка, по идее, должна быть обработана обработчиком ошибок. В противном случае консюмер не сделает коммит, а значит, вычитает это же сообщение еще раз.
Можно сказать, что это тоже своего рода ретрай и не нужны никакие дополнительные настройки. А если эта ошибка non-retryable, то есть повторные попытки не решат проблему, то может получиться ситуация, в которой консюмер в бесконечном цикле и без задержки между итерациями читает одно и то же сообщение. Обработка очереди встает, консюмер «крутится вхолостую», а в логах видно лавину из миллионов записей. Вот на такой подводный камень можно напороться, то есть получить «вырожденный» ретрай, если не настроить правильно обработку ошибок.
Неблокирующий ретрай на консюмере
Наиболее интересной выглядит схема с неблокирующим ретраем:

Если после первой попытки обработать сообщение из очереди результат не успешен, то сообщение пересылается в специальный retry-топик в Kafka. А в основном топике чтение этого сообщения коммитится, и консюмер берет в работу следующее сообщение. Далее параллельно с чтением основного топика происходит чтение retry-топика. Если обработка сообщения вновь терпит неудачу, такое сообщение опять пересылается в этот же retry-топик (вообще, возможна и другая схема, когда для каждой попытки ретрая используется свой retry-топик).
Когда все попытки использованы, сообщение попадает в dead-letter-топик — и обработка данного сообщения считается окончательно неуспешной. В этот же dead-letter-топик попадают неуспешно обработанные сообщения, которые и нет смысла ретраить. Прикладываю ссылку на описание этого ретрая на сайте Spring, а ниже – пример настройки такого ретрая в коде:
@RetryableTopic( attempts = "5", kafkaTemplate = "kafkaTemplate", include = { RetryableException.class, EnotherException.class }, sameIntervalTopicReuseStrategy = SameIntervalTopicReuseStrategy.SINGLE_TOPIC, backoff = @Backoff(delay = 10000)) @KafkaListener( /* Listener configuration */ ) protected void accept(Event event) { handleEvent(event); } @DltHandler protected void handleDltTopic( Event event, @Header(KafkaHeaders.EXCEPTION_MESSAGE) String exceptionMessage ) { logger.error(exceptionMessage); }
Добавлю, что здесь, как и у аннотации Retryable, ретрай срабатывает, во-первых, непосредственно на те исключения, которые перечислены в настройках (include), во-вторых, и на обернутые исключения из цепочки cause.
И тут есть маленький подводный камушек: нужно решить, что же делать с мертвыми сообщениями в dead-letter-топике. Можно их просто логировать и запускать ветви компенсирующих действий (паттерн Saga как раз про это), можно отправлять алерт техподдержке, чтобы она вмешалась и что-то сделала руками или что-либо еще. В любом случае нужно не забыть создать дополнительную логику для этого (на схеме это DLT-сервис — сервис dead-letter-топика, а в примере кода — метод handleDltTopic). А когда вся бизнес-логика готова, как было у нас, — нужно добавить дополнительную ветку сценария для такого случая.
Ретрай на продюсере
Механизм повторных попыток на стороне продюсера, то есть при записи в очередь, уже встроен в клиент Kafka. Его можно настроить опять же стандартными настройками (смотри документацию), и этот механизм имеет некоторые дефолтные настройки.

Но для продюсера нельзя настроить исключения, потому что низкоуровнево запись в Kafka происходит внутри кода Spring Kafka, и там же внутри инкапсулирован ретрай. То есть пользователю незачем знать о внутренних исключениях этого процесса.
Однако нам удалось поймать исключение записи в Kafka, которое не ретраилось. Это было исключение таймаута получения ответа от очереди (KafkaException с RetriableException в качестве cause). По своей сути оно и не должно было ретраиться, так как оно бросалось чуть выше по уровню, чем то место, где происходит встроенный ретрай. Однако в нашем случае ретрай помогал бы при таком исключении. Для такого случая можно применить:
аннотацию Retryable в более высокоуровневом сервисе по сравнению с продюсером;
доверить ретрай консюмеру, если весь процесс в сервисе инициирован чтением сообщения из очереди.
Второй случай проиллюстрирован ниже:

Эти два кружочка ретрая, по сути, превращаются вот в такую схему:

То есть если нужен ретрай на разных этапах обработки вычитанного сообщения, в том числе при записи в очередь, то достаточно одного ретрая на весь процесс на консюмере.

И тут мы подошли к следующему разделу, в котором будет речь и про многоуровневый ретрай.
Retry policy в цепочке микросервисов
Retry на разных уровнях, избегаем retry-шторм
На рисунке ниже пример цепочки микросервисов с ретраями. Один микросервис вычитывает сообщение из очереди, затем делает http-запрос во второй микросервис, а второй делает запрос в базу данных. Ретрай можно сделать на всех трех этапах.

На первый взгляд — мы видим цепочку последовательных ретраев, но ранее мы уже разобрали подобный случай и видим, что рисунок выше можно перерисовать иначе с учетом вложенности процессов: ниже — те же самые ретраи, но уже в виде вложенных рамок. То есть получаем многоуровневый ретрай.

А с этим связаны интересные эффекты. Теперь посмотрим на ретрай на самом вложенном уровне на диаграмме последовательности:

Если бездумно реализовать ретраи во всех трех точках на исходной схеме (а это значит, что будут ретраи на всех трех уровнях), то получим примерно следующую картину. Рост количества ретраев на самом вложенном уровне будет даже не кратным, а экспоненциальным в зависимости от количества уровней:

То есть получили retry-шторм, который сильно нагружает конечный сервис (в данном примере — базу данных). Конечный сервис и без того почему-то не может успешно завершить итерации ретрая, а мы ему еще и повышенную нагрузку добавляем. Рассмотрим варианты выхода из такой ситуации.
Один из способов — оставить все как есть, но добавить еще реализацию паттерна circuit breacer: отключать запросы к конечному сервису, если нагрузка становится слишком большой. Или использовать паттерн rate limiter. Минус этого способа — значительное усложнение архитектуры, в которой и так есть неочевидные эффекты. К тому же в нашем проекте нельзя было просто так отбросить какой-то этап обработки — это бы означало остановку обработки всего запроса, а нам важно как минимум один ответ отправить получателю.
Еще один способ — вновь оставить все как есть, но использовать иную стратегию ретрая. То есть сделать не фиксированную задержку между попытками, а сделать эту задержку случайной по величине. Это позволит немного разнести попытки по времени. Но эффект может остаться незначительным.
Можно сделать ретраи на разных уровнях согласованными, то есть разнести во времени все попытки, чтобы пачки ретраев не перекрывались. Для этого задержка между попытками должна учитывать аналогичную задержку и количество попыток на нижележащем уровне. Способ хороший на практике, но минус его в том, что нарушается принцип инверсии зависимостей: сервису на более высоком уровне приходится «знать» о реализации ретрая в сервисе на более низком уровне.
Можно оставить ретрай только на самом низком, то есть самом вложенном уровне. И тут есть минус: если таких низкоуровневых сервисов не один, а много, то нужно настраивать ретрай для каждого отдельно.
Наконец, можно оставить ретрай только на самом высоком уровне. Преимущество этого способа — весь ретрай в одном месте. Но есть и сложности. Первая: повторять приходится весь высокоуровневый процесс, даже если какие-то низкоуровневые процессы уже успешно отработали. Вторая: вновь нарушается принцип инверсии зависимостей: более высокоуровневому сервису приходится знать о retryable-исключениях на всех нижележащих уровнях. А это решаемо: нижние уровни могут передавать на верхние только стандартизованные исключения-обертки, например RetryableException и NonRetryableException.
Фантомные ретраи
Интересную ситуацию можно получить, если оставить несогласованные ретраи на разных уровнях. Диаграмма последовательности такого кейса:

Первая попытка запроса к низкоуровневому сервису (DB на схеме) неуспешна. Происходит ретрай, и сервис на среднем уровне (Task engine на схеме) делает вторую попытку — и она вновь неуспешна. В это время высокуровневый сервис (Handler на схеме) ждет ответа в течение времени таймаута, не дожидается и делает еще одну попытку уже на своем уровне. И тут, о чудо, низкоуровневый сервис «просыпается» и успешно отрабатывает.
Высокоуровненвый сервис считает ретрай успешным и уходит на какие-то следующие шаги (вне схемы) продолжать свой сценарий. Но в среднеуровневом сервисе уже начат процесс ретрая, и этот процесс ничего знает о том оглушительном успехе. И этот процесс делает третью попытку, которая тоже будет успешна, ведь низкоуровневый сервис уже «проснулся». Как итог: имеем два успешных запроса к БД вместо одного, а такое поведение может быть нежелательным.
Как вообще бороться с дублями? Есть разные способы, некоторые рассмотрим дальше.
Retry и транзакции
Что делать, если какой-то процесс должен быть и внутри транзакции, и внутри обработки ретрая? Транзакция должна быть внутри ретрая или ретрай внутри транзакции?
Рассмотрим один простой пример: внутри ретрая и транзакции должен быть какой-то запрос к базе данных. И одна из проблем, которую можно решить ретраем, это ошибки связи с базой данных. Но если пропадает связь с базой данных, то не выполнится и запрос даже на открытие транзакции. А значит, имеет смысл поместить операции с транзакциями внутрь обработки ретрая.

Это один из случаев, однако у вас может быть и другая ситуация, и, может быть, полезно ретрай делать внутри обработки транзакции. То есть важно понимать, что происходит в вашем случае и что вы хотите ретраить.
В Java-коде со Spring при внедрении ретраев может так получиться, что у одного класса одновременно окажутся аннотации Transactional и Retryable. И вновь: транзакция будет внутри ретрая или наоборот?
@Retryable @Transactional public class Service { //class code }
Говоря в общем, ответ будет таким: не определено. И тем более порядок применения аннотаций для одного класса не зависит от порядка их написания. Но можно погрузиться в код реализации этих аннотаций и убедиться, что если эти две аннотации на одном классе или методе, то все-таки транзакция будет внутри ретрая. Однако такое поведение не гарантируется и, теоретически, может измениться в новых версиях Spring. Поэтому для определенности рекомендую разносить эти аннотации по разным классам.
Далее опишу два кейса, в которых при внедрении ретраев появлялись дубли, а мы пытались с дублями бороться.
История про дубли № 1. Пакетное создание записей
Задача была: создать несколько записей в базе данных. При этом все записи должны быть созданы пачкой, то есть нельзя, чтобы пачка была создана лишь частично. Гарантии доставки: exactly-once, то есть один и только один раз.
Исходное положение дел: если происходит ошибка, а часть пачки еще не создана, то мы имеем лишь частично созданную почку. Это нас не устраивает.
Первая мысль: ретраить создание всей пачки. Однако если происходит сбой, когда часть пачки создана, а другая часть пачки не создана, то при ретрае мы получаем дубли записей, которые все-таки были созданы в первую итерацию. На рисунках ретрай обозначен красной пунктирной рамкой, а сбой — крестиком:

Следующая мысль: добавить транзакцию вокруг всей пачки создаваемых записей. При этом поместить ее внутрь ретрая. То есть если начинаем создавать пачку, то она должна создаться только целиком. Но тут тоже возникает проблема: когда пачка записей успешно создана и транзакция уже завершилась, может произойти сбой на каких-либо операциях уже после этой транзакции, но еще внутри ретрая. И при повторной попытке, вызванной ретраем, мы получаем создание второй пачки записей. А это опять дубли. На рисунках транзакция обозначена черной пунктирной рамкой:

Мы посмотрели внимательно на создаваемые записи и увидели, что у одной из записей в пачке есть уникальный параметр — externalId. Чтобы избежать дублирования создания всей пачки, можем добавить своего рода ключ идемпотентности, основанный как раз на этом идентификаторе. Этот параметр будем проверять при создании каждой пачки: существует ли этот идентификатор в базе или еще нет. Итак, внедрив ретраи, транзакцию и ключ идемпотентности, мы получили то, что хотели. Задача решена.

Вывод: часто нельзя просто так внедрить ретраи для повышения отказоустойчивости, а нужно применить еще и дополнительные архитектурные решения, чтобы не получить дополнительную проблему в виде дублей.
История про дубли № 2. Неработающий ретрай в готовой логике
В проекте в одном из мест уже была готовая следующая бизнес-логика: происходит определенное событие, оно является триггером следующей активности (это зеленый квадрат на рисунке) — формирования итогового документа отчета. Эта логика была реализована таким образом, что таких событий-триггеров могло быть больше одного. А гарантия доставки здесь тоже была exactly-once, то есть дубли недопустимы. И в этой же логике была предусмотрена защита от дублей: был добавлен запрет на повторное выполнение активности.

Однако мы планировали повысить отказоустойчивость за счет внедрения ретраев. А ретраи подразумевали, что данная цепочка может быть выполнена повторно. И новая проблема, которая появилась, заключалась в том, что после внедрения ретрая этот ретрай не работал, потому повторная активность была запрещена уже существующей логикой.
Мы подумали и посмотрели, как часто вообще бывают такие ситуации. Оказалось, что нечасто. Потом посмотрели внимательно на исходные требования и решили, что здесь нет необходимости строгой гарантии exactly-once. Мы изменили исходные требования и разрешили дубли. И отменили запрет на дубли в исходной логике. В результате получили работающий ретрай и редкие дубли, которые вполне допустимы. При этом мы не стали надстраивать какую-либо усложняющую логику.

Вывод по этой истории: иногда ретраи могут не согласовываться с бизнес-логикой и мешать друг другу. А решение может быть нестандартным — в данном случае это изменение исходных требований.
Бонус: ретраи еще нужно тестировать
На первый взгляд для тестирования ретраев можно создать mock’s, которые будут имитировать нужные ошибки. И по-быстрому протестировать ретраи. Но если вернуться к началу статьи к схеме проекта с кружочками, где можно применить ретраи, то видно, что этих мест много и они разные. И если вспомнить блок-схему ретрая, то видно, что сценариев при ретрае пять штук. Это значит, что объем тестирования превращается в монстра. Проблему тестирования добивает то, что, если вы используете внешние библиотеки и фреймворки, бывает очень сложно имитировать ситуации, в которых возникают внутренние исключения этих библиотек и фреймворков. Конечно, любого монстра можно победить. Но основная мысль этого короткого раздела: закладывайте силы и время на тестирование ретраев.
Ключевые идеи
Это уже конец статьи, и вот ключевые идеи:
Внедрение ретраев требует большей глубины погружения, чем может показаться на первый взгляд.
Не нужно слепо полагаться на решения библиотек и фреймворков.
Retry policy — это не надстройки для отдельных методов, а система решений на разных уровнях всего проекта.
Внедряя ретраи, проверь: не нарушаешь ли существующую логику?
Возможно, вы заметили, что многие описанные в статье моменты раскрыты ознакомительно. Я считаю, что этот материал будет полезен не погружением в технические детали, а тем, что он подсвечивает неочевидные моменты. Подробную информацию по отдельным моментам можно легко найти в других статьях на Хабре, или поиском в интернете.
Ретраи — это мощный инструмент повышения отказоустойчивости, но пользоваться им нужно с умом. Если в вашем проекте уже есть ретраи — я призываю посмотреть на них внимательно: все ли моменты вы учли? А если вы только планируете внедрять ретраи, то теперь сможете обойти многие подводные камни.
