Введение

Привет, давайте знакомиться! Меня зовут, Иван. Для самых нетерпеливых и пытливых, которые хотят сразу к сути и проматывают введение, в этой статье поговорим о:

  • Что такое устойчивость и какое влияние на нее имеет retry?

  • Анализируем, где применять retry;

  • Реализуем retry;

  • Пишем unit-тесты с wiremock;

  • Делаем starter;

Для тех, кто хочет услышать плавную нить повествования - Я java-разработчик в компании АльфаСтрахование с опытом в ИТ – 13 лет. Java-разработчик, а вернее Spring адепт, начинающий. В этой роли накапливаю знания и развиваю навыки в течении 10 месяцев. Постоянно стремлюсь к тому, чтобы разобраться в назначении и принципах функционирования абстракций, с которыми сталкиваюсь. Постепенно накапливается опыт, который помогает на каждом следующем шаге развития. После того, как удалось разобраться в чем-то сложном или полезном, делюсь знаниями и историями. Рассказывать истории нравится больше. В них дыхание жизни. Меня драйвит мысль о том, что каждое сложное явление или артефакт можно представить в виде набора понятных элементов. С любой сложностью сможет справиться каждый. Это касается всего, что нас окружает. Может не сразу, может понадобиться сделать retry. Может даже ни один раз. Но так знания, опыт и навыки будут крайне устойчивыми и основательными.

Предыстория

  • Что это?

  • Какие есть проблемы и возможности?

  • Варианты решения

Наша команда занимается разработкой распределенной, многопользовательской системой. Мы создаем API компании в части оформления договоров страхования для страховых продуктов (КАСКО/ОСАГО/ИФЛ/...). Процесс оформления любого продукта строится из 4 основных этапов - посчитать предложение, сохранить предложение, оплатить предложение, распечатать договор. Есть какие-то детали и дополнения, когда речь идет о конкретных продуктах. Сложная, но понятная задача с очевидной ценностью. Очередная стадия осознания бизнес потребности иметь адаптер компании, который в своих процессах могут использовать как внешние клиенты, так и внутренние потребители, подкрепленная технологическим витком развития компании привела к решению в виде микросервисной архитектуры. И у нас полный и необходимый фарш стек. Приложения разворачиваются в Docker, инфраструктура построена на Kubernetes, микросервисы написаны c применением фреймворка Spring, база данных для оперативной и справочной информации - sql(postgre), nosql(redis), мониторинг на kibana. Это верхушка айсберга. Под капотом еще много сервисов, которые делают процесс разработки и архитектуру  зрелыми и удобными (сверяемся мы по Agile Fluency Model). Выгоды понятны. Но и сложностей с таким решением возникает не мало. Чем решение становится более зрелым и критичным, тем выше планка к нефункциональным характеристикам разрабатываемого типа информационной системы. Это и устойчивость, и надежность, транзакционность, масштабируемость и т.д. Время от времени, осознавая свои наиболее отстающие показатели, наша команда работает над их улучшением. Сейчас хочется с Вами поделиться тем, как мы работаем над повышением устойчивости. Устойчивость характеристика комплексная. Есть много подходов к тому, как ее повысить и достигнуть определенного уровня качества. В целом, про устойчивость я не напишу более интересно и содержательно, чем написано тут. После анализа логов и продолжительного мониторинга решили сконцентрироваться на:

  • Инфраструктура. Перенастройка podAntiAffinity для повышения сетевой устойчивости за счет deploy на разных узлах:

    • Подробнее про эту политику можно посмотреть тут;

  • Инфраструктура. Настройка политики мягкого вывода пода из эксплуатации:

    • Эту задачу нам еще предстоит решить;

  • Код. Настройка политик retry (перезапрос сообщений) в случае получения 5xx при попытке установить сетевое соединение:

    • Об этом мы дальше и будем говорить подробнее;

  • Код. Отсутствие политик выключения самого spring. Kubernetes тут принимает все решения самостоятельно:

    • Над этим еще предстоит подумать;

Часть проблем уходит в инфраструктурный блок. Вернее в настройки k8s. Об этом обязательно расскажем позднее. Настройки выключения spring тоже тема отдельная и обширная и ее тоже осветим, когда будет что интересное рассказать. А вот про наши политики retry уже можно поговорить.

Контекст

  • Фокус на конкретике

  • Как проявляется проблема?

  • Что будем использовать для решения?

Для распределенных микросервисных архитектур привычно находиться в гетерогенных условиях информационного ландшафта, когда ваш микросервис или набор микросевисов вызывает нужные для его функционирования сервисы и не всегда сразу получает позитивный ответ (статус 200). Возможны сетевые сбои, вызванные внутренним состоянием вызываемых сервисов, задержкой в сетевом взаимодействии, согласованием передачи информации между узлами сети, переполнением сетевых хранилищ и пр. В этом случае нам поможет обработка временных сбоев и переотправка сообщений, на которые был получен ошибочный статус. Этот механизм должен поддерживать прозрачное повторение неудачного действия. Это может улучшить стабильность приложения в целом. Важно, чтобы решаемые проблемы не носили системно воспроизводимый характер, и повторение запроса, который ранее завершился неудачей, мог быть успешным при последующей попытке. Этот способ обработки проблем, а вернее сказать шаблон повторных попыток (retry) - комплексное решение, которое имеет много деталей и, возможностей, а также привносит определенную ответственность для системы, реализующей этот шаблон. Независимо от типа возникающей проблемы необходимо отслеживать количество возможных повторений и интервал повторений, чтобы исключить бесконечные повторные попытки и провоцирование ddos-атак. Более того, многоуровневые повторные попытки на разных этапах выполнения процесса могут привести к увеличению задержки. Многие SDK включают конфигурации повторных попыток. Мы же будем концентрироваться на том, как эту проблему решает spring, а точнее feign. Скажу кратко, что feign это http client из стека Netflix. Удобное и качественное решение, о котором можно почитать тут и точно, если решите его использовать, нужно прочесть тут. Если вы не новичок в этой теме, и для вам нужен краткий конспект с основными моментами реализации retry для spring сервисов, без дополнений логики отбора обрабатываемых сообщений и unit тестов, то Вам будет полезно посмотреть это тут.

Интрига

  • В чем ценность?

  • Разбор возможностей

  • Границы решения

Самое время определиться с тем, что мы потенциально хотим обрабатывать повторно, а что обрабатывать повторно будет неосмотрительно или даже вредно. Во первых - у нас есть логи. Они помогут начать и определить самые важные для нас серверные ошибки, нуждающиеся в повторных попытках. В нашем случае, это 503 (сервис недоступен), 504 (задержка ответа о шлюза) и 506 (вариант тоже проводит согласование). Но ограничиваться только известными ошибками и не осознать возможности используемого подхода было бы не профессионально, поэтому смотрим глубже. Можно найти 19 возможных кодов ошибок сервера. Мы фокусируемся на тех, которые попадают в интервал 500-511. Ошибки из категории 52x - однозначно говорят о недоступности приложений и их использовать не рекомендуется. Так мы сузили поле для наших исследований:

  • Ошибка 500 общая:

    • Она возникает, если не удалось точно категоризировать проблему;

    • Это может быть как проблема в коде, так и проблема в инфраструктуре;

    • Повторно ее обрабатывать - увеличивать время задержки и снижать эффективность нашего решения.

    • Ее не будем обрабатывать;

  • Ошибка 501 - запрашиваемый ресурс не внедрен:

    • Тоже не похоже на мигающие сетевые проблемы;

    • От обработки этого кода откажемся;

  • Ошибка 502 - сервер, выступая в роли шлюза/ прокси-сервера, получил недействительное ответное сообщение от вышестоящего сервера:

    • Возможно временная проблема;

    • Ее обработаем;

  • Ошибка 503 - сервис недоступен:

    • Как показывает практика, этот тип проблем тоже можно отнести к категории мигающих;

    • Обработаем;

  • Ошибка 504 - сервер в роли шлюза или прокси-сервера не дождался ответа от вышестоящего сервера для завершения текущего запроса:

    • Кэш на сетевых узлах может нам помочь;

    • То же обработаем;

  • Ошибка 505 - сервер не поддерживает или отказывается поддерживать указанную в запросе версию протокола HTTP:

    • Не будем обрабатывать;

  • Ошибка 506 - в результате ошибочной конфигурации сервера выбранный вариант указывает сам на себя, из-за чего процесс связывания прерывается:

    • Обработаем; 

    • Похоже на проблему с настройкой заголовков, или кэш нам немного навредил;

  • Ошибка 507 - не хватает места для выполнения текущего запроса:

    • А место после чистки может освободиться:

    • Обработаем;

  • Ошибка 508 - операция отменена, т.к. сервер обнаружил бесконечный цикл при обработке запроса без ограничения глубины:

    • Не будем обрабатывать;

    • Навредим;

  • Ошибка 509 - превышение отведённого ограничения на потребление трафика:

    • Не будем обрабатывать;

  • Ошибка 510 - на сервере отсутствует расширение, которое желает использовать клиент:

    • Не будем обрабатывать;

  • Ошибка 511 - этот ответ посылается не сервером, которому был предназначен запрос, а сервером-посредником — например, сервером провайдера — в случае, если клиент должен сначала авторизоваться в сети:

    • Не будем обрабатывать;

После небольшой мыслительной работы мы разобрали возможности и сформировали свои ограничения. Формализуем свои границы с помощью Java. Мы используем зоопарк версий от 8 до 17. Примеры кода будут показаны на 14. У нас есть наш конечный список кодов. Похоже, что получается так:

private List<Integer> retryableStatuses() {
        return Arrays.asList(
                HttpStatus.BAD_GATEWAY.value(),
                HttpStatus.SERVICE_UNAVAILABLE.value(),
                HttpStatus.GATEWAY_TIMEOUT.value(),
                HttpStatus.INSUFFICIENT_STORAGE.value(),
                HttpStatus.BANDWIDTH_LIMIT_EXCEEDED.value(),
                HttpStatus.NOT_EXTENDED.value());
    }

Поясню. Как альтернативное, более производительное решение, напрашивается использование простой переменной в виде final static, которая будет содержать требуемый массив значений. Так сделать не сложно. Я руководствуюсь понятными мне стимулами расширения кода и выделения этого списка в отдельный интерфейс с реализацией по умолчанию. Будет возможность внедрять ее в иерархию классов, каждый из которых будет по своему обрабатывать коды ошибок. Если мои стимулы, после моего пояснения не стали вам близки, то переделывайте метод в final static переменную. 

Решение

  • Алгоритм

  • Возможности переиспользования сделанного

  • Тестирование

Теперь у нас есть список кодов, на которые должна быть реакция. Отлично. Самое время научиться правильно реагировать. Верная реакция должна задействовать feign client. Сверяемся с документацией и учимся реагировать правильно. Для реализации повторных попыток нужно задействовать интерфейс Retryer. Он реагирует на RetryableException и ничего не возвращает. При выполнении он либо выдает исключение, либо успешно завершается. Если будет исключение вызов завершится ошибкой. Подробнее разберем процесс обработки и сигнатуру RetryableException, чтобы максимально эффективно использовать возможности класса. Для того, чтобы разобраться в нем ничего лучше кода не нашлось. Это исключение наследовано от FeignException и у него есть 2 конструктора. Первый заточен под обработку уже случившегося исключения, второй под самостоятельный проброс исключения. Отлично. В нашей ситуации нужен самостоятельный проброс. Сигнатура этого конструктора требует:

  • Cтатус - есть;

  • Cообщение - соберем;

  • Тип вызываемого метода, который вернул ошибку - есть;

  • Дату, после которой можно/нужно перезапрашивать - пока проигнорируем. В нашей задаче возможности для такого тюнинга кажутся лишними;

  • Запрос, который вернул ошибку - есть;

Прекрасно. У нас есть все необходимое для продолжения. То есть, чтобы сигнализировать о необходимости повторной попытки мы должны бросить RetryableException. У нас есть понимание, что делать и когда делать. Похоже исключение готово:

throw new RetryableException(
                    response.status(),
                    String.format("%s : %s", exceptionMethod, response.status()),
                    response.request().httpMethod(),
                    null,
                    response.request())

Мы проверяем статус сообщения и, если этот статус находится в допустимом диапазоне нашего списка кодов, мы инициируем ошибку, которую обработает feign:

public void retry(Response response, String exceptionMethod) {
        if (retryableStatuses().contains(response.status())) {
            throw new RetryableException(
                    response.status(),
                    String.format("%s : %s", exceptionMethod, response.status()),
                    response.request().httpMethod(),
                    null,
                    response.request());
        }
    }

Итак, у нас целиком готова законченная реакция на возникновение сетевой нестабильности. С точки зрения spring, это модуль, который указывает, что класс содержит методы определения для внедрения в Bean модули. То есть, для нас это конфигурация, которую необходимо пометить аннотацией @Configuration. Придумаем название и оставим краткий и понятный javadoc:

import feign.Response;
import feign.RetryableException;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
 
import java.util.Arrays;
import java.util.List;
 
 
/**
 * Handling 5xx statuses caused by network instability
 */
 
@Configuration
public class CheckRetry5xxStatuses {
 
    public void retry(Response response, String exceptionMethod) {
        if (retryableStatuses().contains(response.status())) {
            throw new RetryableException(
                    response.status(),
                    String.format("%s : %s", exceptionMethod, response.status()),
                    response.request().httpMethod(),
                    null,
                    response.request());
        }
    }
 
     
private List<Integer> retryableStatuses() {
        return Arrays.asList(
                HttpStatus.BAD_GATEWAY.value(),
                HttpStatus.SERVICE_UNAVAILABLE.value(),
                HttpStatus.GATEWAY_TIMEOUT.value(),
                HttpStatus.INSUFFICIENT_STORAGE.value(),
                HttpStatus.BANDWIDTH_LIMIT_EXCEEDED.value(),
                HttpStatus.NOT_EXTENDED.value());
    }
 
}

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

Когда мы получаем ошибочный ответ, Feign передает его экземпляру интерфейса ErrorDecoder, который решает, что с ним делать. Что наиболее важно, декодер может сопоставить исключение с экземпляром RetryableException, позволяя Retryer повторить вызов. Реализация ErrorDecoder по умолчанию создает экземпляр RetryableExeception только тогда, когда ответ содержит заголовок «Retry-After».Чаще всего мы можем найти его в ответах 503 Service Unreachable.

Мы используем Feign. У нас есть ErrorDeoder, то есть уже готово место имплементации нашей обработки. Полагаться на заполненность заголовка «Retry-After» мы не можем, да нам это и не нужно. На предыдущих шагах мы четко определили границы запроса статусами сообщений, которые собираемся обрабатывать. Их и будем применять. Теперь найдем сервис, который больше всего нуждается в повышении устойчивости (логи нам в помощь) и на его примере отработаем способ внедрения нашего кода. Это не составило труда. Kibana и KQL (профильный язык запросов) решили нашу задачку. Находим нашего клиента:

public class SomeServiceErrorHandler implements ErrorDecoder {
 
 
    @Override
    public Exception decode(String methodKey, Response response) {
        final String message = "Ошибка вызова ... Код " 
          + response.status() 
          + ". Тело ответа: " 
          + response.body().toString();
        return new FeignInternalException(
          methodKey, 
          Collections.singletonList(message));
    }
}

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

@AllArgsConstructor
public class SomeErrorHandler implements ErrorDecoder {
 
    private CheckRetry5xxStatuses checkRetry5xxStatuses;
 
    @Override
    public Exception decode(String methodKey, Response response) {
        checkRetry5xxStatuses.retry(response, methodKey);
        final String message = "Ошибка вызова ... Код " 
          + response.status() 
          + ". Тело ответа: " 
          + response.body().toString();
        return new FeignInternalException(
          methodKey, 
          Collections.singletonList(message));
    }
}

Мы внедрили свой класс в виде отдельного приватного параметра. Используем агрегацию. Соблюдаем принципы SOLID. Таким образом, наш класс-обработчик выполняет свою обязанность (single responsobility). При этом он спроектирован верно (open/closed), что позволило нам легко внести изменения. Спасибо коллеги :-). Мне нравится использовать lombok, я обязательно проверяю как он работает, чтобы не допустить проблем при компиляции, поэтому тут @AllArgsConstructor вполне к месту. Теперь возвращаемся к конфигурационному файлу и дополняем его. Вставляем в него наш класс-обработчик. Сверяемся с документацией и проверяем все ли необходимое мы сделали:

Feign предоставляет разумную реализацию интерфейса Retryer по умолчанию. Он будет повторять только заданное количество раз, будет начинаться с некоторого интервала времени, а затем увеличивать его с каждой повторной попыткой до заданного максимума. Давайте определим его с начальным интервалом 100 миллисекунд, максимальным интервалом 3 секунды и максимальным количеством попыток 5.

То есть, нам нужно добавить компонент, который будет возвращать Retryer. Он и реализует повторные запросы. Его default настройки нас полностью устраивают. У меня нет задачи специфичной повторной обработки статусов, поэтому я не буду использовать свои параметры. Воспользуюсь предоставленной мудростью. Добавил, но IDEA подсвечивает мне, указывая на то, что мой файл конфигурации, который является обычным файлом может иметь проблемы с обработкой добавленных компонентов. Причина в том, что в приложении Spring внедрение одного bean-компонента в другой bean-компонент очень распространено. Однако иногда желательно внедрить компонент в обычный объект, как в нашем случае и произошло. Для обработки этого используется аннотация @Configurable, которая позволяет экземплярам декорированного класса содержать ссылки на bean-компоненты Spring. В конечном итоге наш класс конфигурации будет выглядеть так:

@Configurable
public class SomeServiceConfiguration {
 
    @Autowired
    private CheckRetry5xxStatuses checkRetry5xxStatuses;
     
    @Bean
    ErrorDecoder apiSomeErrorDecoder() {
        return new SomeErrorHandler(checkRetry5xxStatuses);
    }
     
    @Bean
    Retryer SomeServiceRetryer() {return new Retryer.Default();}
 
}

Запускаем, проверяем. Победа. Но не совсем. Нужно проверить и зафиксировать поведение нашего кода. Настало время unit-tests. В нашем случае мы проверяем, что при определенных условиях будет выполнено установленное количество попыток перезапроса сообщения, если получен нужный для нас ошибочный статус сообщения. Для этого теста нужен инструмент мокирования сообщений. Для нас привычной практикой является использование wiremock. Основные сложности тут не с использованием синтаксиса wiremock, а с его настройкой. В данном случае мне повезло. Нужный класс уже был настроен. Про настройку wiremock я не буду рассказывать подробно. Это тема отдельной статьи. Вместо этого дам вам полезную ссылочку.
В моем unit-test мне надо проверить, что

  1. При запросе по определенному адресу, определенным методом ->

  2. Возникнет определенного типа исключение, которое приведет к ->

  3. Возникновению исключения в конфигурации сервиса, которое приведет к тому, что ->

  4. Будет выполнено определенное количество повторов сообщений (в нашем случае 5)

Отлично. Задача сформулирована. Самое время ее реализовать:

@Test
void shouldThrownRetryException() {
    final String url = "some_url";
    int status = HttpStatus.BAD_GATEWAY;
 
 
    givenThat(post(urlEqualTo(url))
            .willReturn(
                    aResponse()
                            .withStatus(status)));
 
    doThrow(RetryableException.class)
            .when(CheckRetry5xxStatuses).retry(any(Response.class), anyString());
 
    assertThatThrownBy(() -> service.method(new SomeObject()))
            .isInstanceOf(RetryableException.class);
 
    verify(5, postRequestedFor(urlEqualTo(url)));
 
}

Знакомьтесь givenThaturlEqualToverifypostRequestedFor - это wiremock. Мы сымитировали вызов по url, дальше с помощью Mockito сымитировали проброс исключения, проверили, что оно было отловлено, и так 5 раз. Здорово, но чего-то не хватает. Зачем мы используем тут ошибочный статус, если у нас они уже есть в отдельной структуре. Точно. Используем ее, но для этого придется немного усложнить и использовать параметризацию тестов. В конечном виде тест получится такой:

   @ParameterizedTest
   @ArgumentsSource(ProvideRetryableStatuses.class)
   void shouldThrownRetryException(int status) {
       final String url = "some_url";
 
       givenThat(post(urlEqualTo(url))
               .willReturn(
                       aResponse()
                               .withStatus(status)));
 
       doThrow(RetryableException.class)
               .when(CheckRetry5xxStatuses).retry(any(Response.class), anyString());
 
       assertThatThrownBy(() -> service.method(new SomeObject()))
               .isInstanceOf(RetryableException.class);
 
       verify(5, postRequestedFor(urlEqualTo(url)));
   }

Про параметризованные тесты нужно писать отдельную статью. Я это обязательно сделаю, но более конкретно чем тут я точно не напишу. Поясню, что все многообразие определенных статусов я использую с помощью класса, в котором инкапсулирована логика обработки тех самых статусов. Это класс ProvideRetryableStatuses. Он выглядит так:

   public class ProvideRetryableStatuses implements ArgumentsProvider {
 
    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext extensionContext) {
        return new CheckRetry5xxStatuses().retryableStatuses()
                .stream()
                .map(Arguments::of);
    }
}

В нем единственный метод, который по сути возвращает int, обернутый stream в Arguments. На этом кажется все, логику обработки и повышения устойчивости своего сервиса мы реализовали, но решение получается не завершенным. Есть какая-то недосказанность ...

Развитие

  • Упаковываем в программную компоненту

  • Пилотируем

  • Масштабируем

Мы реализовали и внедрили нужное изменение. Вынесли его в бой. Промониторили, что в отдельно взятом сервисе стало лучше. И стало понятно, что нужно постепенно масштабировать этот код на оставшиеся сервисы. Мы написали достаточно кода, который нужно имплементировать во все конфигурации сервисов, которые мы вызываем из своих микросевисов. После того, как я сел писать 2 раз тот же самый код, но в другом сервисе, у меня возникло ощущение "дня сурка". А не хотелось бы повторяться. Хочется проживать свою профессиональную жизнь во всем ее многообразии и разнообразии. Ведь сервисов, которые нуждаются в этом коде - много. Очень много. Надо придумать/использовать подходы, которые это упростят. В spring boot используется система пере используемых компонентов, которые называются - stаrter. Вы можете создать собственный stаrter, сложить его у себя в хранилище артефактов и с помощью сборщика проекта и специальных файлов использовать starter, везде, где Вам нужно. Starter, его создание и использование это уже изученная и понятная тема, которая на конкретном примере хорошо описана тут. В этом примере описаны конкретные аннотации, который помогут Вам сделать переиспользуемый starter. Мне для упаковки своего класса из этой статьи CheckRetry5xxStatuses потребовалось вынести его в отдельный spring проект и настроить конфигурацию, которая будет символизировать сервисам, в которых я использую свой стартер о том, что необходимо из starter добавить bean в поднимаемый контекст spring. Конфигурирование:

  • Добавить в проект отдельную структуру папок api-retryer-starter\starter\src\main\resources\META-INF\spring.factories;

  • Содержимое файла должно однозначно говорить о том, что мы добавляем в контекст;

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  ru.alfastrah.api.apiretryerstarter.checktoretry.CheckRetry5xxStatuses

Если у Вас там более одного класса и есть определенная иерархия обработок (мы вот создали), то через запятую указываете абсолютный путь до каждой. Упаковали наш стартер. Теперь самое время покрыть его unit test. Напомню, что класс, который мы вынесли в starter не реализует логики перезапроса сообщений. Он отвечает только за бросание конкретного исключения. Ну что же. Все получается в духе SOLID, поэтому тесты будет лаконичными и конкретными:

  • Запрос ->

  • Ответ ->

  • Проверка типа выброшенного исключения

По ходу введем параметризацию, для того, чтобы проверить все обрабатываемые нами статусы и в цикле проверим, что такое поведение будет обрабатываться всеми методами http. Похоже, что мы на 100% покрыли весь свой код. В конкретных сервисах мы будем проверять логику повторных вызов сообщений, а наш starter будет отвечать за проверку и выброс сообщения по требуемым для нас статусам:

@ExtendWith(SpringExtension.class)
class CheckRetry5xxStatusesTest {
 
    @SpringBootConfiguration
    @ComponentScan(
            basePackageClasses = CheckRetry5xxStatuses.class,
            useDefaultFilters = false,
            includeFilters = {
                    @ComponentScan.Filter(
                            type = FilterType.ASSIGNABLE_TYPE,
                            value = CheckRetry5xxStatuses.class)
            })
    public static class TestConfig {}
 
     
    @Autowired
    private CheckRetry5xxStatuses checkRetry5xxStatuses;
 
     
    @ParameterizedTest
    @ArgumentsSource(ProvideRetryable5xxStatuses.class)
    void should_getRetryableException_HTTPStatus5xx_HttpMethod(int status) {
 
        for (Request.HttpMethod httpMethod : Request.HttpMethod.values()) {
 
             
            Request request = UtilTestData.TestBuider.requestBuider(httpMethod);
 
             
            Response response = Response.builder()
                    .status(status)
                    .body(UtilTestData
                          .MessageData
                          .someExceptionMethod
                          .getBytes(StandardCharsets.UTF_8))
                    .request(request)
                    .build();
 
             
            assertThatThrownBy(() -> checkRetry5xxStatuses
                               .retry(
                                 response, 
                                 UtilTestData.MessageData.someExceptionMethod))
                    .isInstanceOf(RetryableException.class)
                    .hasMessage(
                            UtilTestData.MessageData.someExceptionMethod +
                                    " : " +
                                    status
                    );
        }
    }
  
}

Starter закончен. Помещаем его в системное хранилище, из которого вы или кто-то, имеющий к нему доступ, сможете его брать. Следом настраиваем конфигурацию сборщика, который будет внедрять стартер в работу конкретного сервиса. Для хранения наших starter мы используем nexus, в качестве сборщика - maven. Типичная maven конфигурация выглядит так:

<dependency>
    <groupId>ru.alfastrah.api</groupId>          // папка хранения starter
    <artifactId>api-retryer-starter</artifactId> // название starter
    <version>2.0.0</version>                     // продуктивная версия starter
</dependency>

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

Благодарности

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

Завершение

В заголовке я поднял вопрос. В статье я обозначил мою позицию: "NO". Для меня ответ заключается в том, что устойчивость - это Not Only retry. По мере нашего движения в сторону ее повышения я или мои коллеги напишем для Вас что-нибудь еще из нашего опыта. Успехов и процветания, не скучайте сами и не давайте скучать вашим коллегам.

Комментарии (14)


  1. BugM
    28.01.2023 01:47
    +3

    Как же сложно. Это решается одним статическим методом в утиле. Строчек на 20-30 примерно.

    result = MyHttpUtils.callWithRetry(() -> originalCall(...), retryCount, delay) или прямо в него оригинальные параметры вызова передать, как больше нравится. Или экспоненциальная задержка, тоже как больше нравится.


    1. in86 Автор
      28.01.2023 08:21

      Здравствуйте, спасибо за комментарий. Да, так тоже можно. Это решение приведет к тому, что постепенно мы скопируем всю расписанную тут логику в предлагаемые Вами метод. А когда решим масштабироваться, начнем делать копипаст. Для меня было важно донести технологию решения проблемы. Решить ее можно многими способами.


      1. BugM
        28.01.2023 15:17
        +2

        Код делающий примерно тоже самое из документации https://docs.oracle.com/en/cloud/paas/app-container-cloud/cache/handle-connection-exceptions-retries.html И даже он переусложнен. Объект тут не нужен, да и класс не нужен. Одного метода хватит. Сделаем скидку что там Джава слишком старая. Даже без лямбд.

        У вас Спринг, тесты, Спринг стартер, Спринг магия. А потом что-то код медленно работает, что-то ничего не понятно что на самом деле происходит. Вот из-за такого оверинжиниринга непонятно и медленно.

        Сложно стоит писать сложные штуки. А простые штуки надо писать максимально просто. Ретраи это просто. Как обычно с подводными камнями вроде добавки джиттера, не забыть про возможность экспоненциальных ретраев и вообще помнить про то чтобы не убить запросами пошатнувшегося соседа. Но все равно просто.


        1. in86 Автор
          28.01.2023 16:35

          Чем описанная Вами простота проще, чем описанная мной?

          Когда ты работаешь с фреймворком, тебе следует использовать возможности Фреймворка, потому что - просто сопровождать, просто обновлять, просто масштабировать.

          Назначение сложного продукта, понятно решать сложные задачи.

          У нас нет проблем с медленной работой, но за ссылку спасибо, обязательно посмотрю )


          1. BugM
            28.01.2023 16:45
            +1

            Чем описанная Вами простота проще, чем описанная мной?

            У меня 20 строк кода. Который явно вызывается и явно работает. Что он делает и как он это делает поймет любой джун минут за 10.

            У вас космолет какой-то. Сроки на понять а что там происходит резко растут. Начать использовать мой код можно секунд за 10. Просто вызвать функцию. Как ваш код использовать надо думать. Я не хочу думать над тем как вызывать функцию.

            Мой код отлично сочетается как с функциональным стилем, так и с императивным. Хотите в Either обернуть и в стримах гонять? Легко! Хотите в hot path запихнуть и делать все с минимумом аллокаций? Тоже легко. У вас кажется с этим будут проблемы.

            просто сопровождать, просто обновлять, просто масштабировать

            Этот код стейтлесс, его не надо масштабировать. Сколько раз вызывали столько и отработает.

            Сопровождать его в общем тоже не надо, он просто работает десятилетиями. Но если что он простой и любой джун внесет в него нужные правки.

            Назначение сложного продукта, понятно решать сложные задачи.

            Продукт может быть сколько угодно сложен. Это нормально. А вот ретраи http просты. И их стоит написать просто. Сложно надо писать только сложные вещи.

            Когда ты работаешь с фреймворком, тебе следует использовать возможности Фреймворка

            Когда у тебя в руках есть только молоток, тогда все вокруг превращается в гвозди.

            У нас нет проблем с медленной работой

            Это глобально про такой подход. Если все в таком стиле написать, то возникают разные проблем. Проблема с производительностью это одна из них.


            1. in86 Автор
              28.01.2023 17:15
              -1

              Используя Фреймворк и все его возможности Вы уходите от того, чтобы в руках был только молоток.

              Предлагаемое решение раскладывает по полкам разные уровни решения и представления задачи.

              Каждый уровень имеет возможность для расширений и дополнений.

              Каждый уровень имеет понятный тест, который просто поддерживать и развивать.

              Перед глазами понятно, на что ретраим, понятно, как ретраим.

              Если мы все заворачиваем в единую реализацию и отдаем джуну, то мы формируем ком функциональности, который начнет жить собственной жизнью через какое-то время.

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


              1. BugM
                29.01.2023 02:13
                +1

                Вы мой ненаписанный код обвинили в серьезных преступлениях. Давайте я его напишу для справедливости

                private static HttpClient client = HttpClient.newHttpClient();
                private static Set<Integer> retryableCodes = Set.of(HTTP_BAD_GATEWAY, HTTP_UNAVAILABLE, HTTP_GATEWAY_TIMEOUT); //расширьте по вкусу
                
                /*
                    Дописывайте свои специфичные реализации, кому что надо
                 */
                public static @Nullable String httpGetStringWithRetries(HttpRequest request, int retryCount, int delay) {
                    Optional<HttpResponse<String>> result = httpGetWithRetries(request, retryCount, delay, false, HttpResponse.BodyHandlers.ofString());
                    return result.map(HttpResponse::body).orElse(null);
                }
                
                public static <T> Optional<HttpResponse<T>> httpGetWithRetries(HttpRequest request,
                                                                               int tryCount,
                                                                               int delay,
                                                                               boolean isExponentDelay,
                                                                               HttpResponse.BodyHandler<T> bodyHandler) {
                    if (tryCount < 1) {
                        throw new RuntimeException("Нельзя меньше 1 попытки");
                    }
                    if (delay < 0) {
                        throw new RuntimeException("Машину времени еще не придумали. Задержка между попытками должна быть неотрицательной.");
                    }
                    int retryDelay = delay;
                    for (int i = 0; i < tryCount; ++i) {
                        if (i > 0 && delay > 0) {
                            try {
                                int jitter = (int) (ThreadLocalRandom.current().nextInt() % (retryDelay * 0.2)) - (int) (retryDelay * 0.1);
                                Thread.sleep(retryDelay + jitter);
                            } catch (InterruptedException e) {
                                throw new RuntimeException(e);
                            }
                        }
                        HttpResponse<T> result = null;
                        try {
                            result = client.send(request, bodyHandler);
                        } catch (Exception e) {
                            if (!(e instanceof IOException))
                                throw new RuntimeException(e);
                        }
                        if (result == null || retryableCodes.contains(result.statusCode())) {
                            if (isExponentDelay)
                                retryDelay = retryDelay * 2;
                            continue;
                        }
                        return Optional.of(result);
                    }
                    return Optional.empty();
                }

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

                Плюс у моего кода нет зависимостей. Вообще. Его можно использовать откуда угодно и он вообще ничего не тянет за собой. В отличии от вашего. Что я считаю большим плюсом.

                Если нужна версия с еще меньшим количеством аллокаций, то как ее написать понятно. Не знаю зачем, но вдруг.

                А теперь попробуйте в моем коде показать все те ужасы которые вы перечислили.

                Что вы хотите расширять и дополнять? Ну серьезно.

                Где тут уровни, полки и прочий буллшит? Тут 20 строк несложного кода, ничего другого я не вижу.

                Что и как ретраим отлично видно. В том числе из вызывающего кода понятно что происходит. Прям с одного взгляда.

                Ком функциональности покажите. Ну или опишите путь как он появится. С учетом что когда джун правит коммоны его ревьюят.

                Разбираться в 20 строчках? Ну, блин.

                Можете писать смело. Считайте что я на ревью вам принес ПР с удалением вашего кода из проекта и его заменой на этот кусок. С обоснованием "я тут хорошо сделал". Отморозиться и закрыть ПР мой кто я такой у вас варианта нет. Я уверен в себе и своем коде и готов эскалировать к руководству.


                1. in86 Автор
                  29.01.2023 09:40
                  -2

                  Если мы посчитаем строчки кода в моем решении и строчки кода в Вашем решении, то в Вашем решении их не окажется меньше с учетом структурированности и форматирования. Да и с

                  Я сильно сомневаюсь, что джун с ходу (10 минут) разберется в чем-то типа

                  ThreadLocalRandom.current()

                  Разберётся так, что сможет протестировать и взять на поддержку.

                  Вложенность некоторых try и if до 3 уровня и все в одном модуле, что сильно затрудняет читаемость. Чисто стилистически я бы предпочел так не делать. Но это стилистические предпочтения.

                  Каждый if - своя ответственность, которая потенциально обрастает своим окружением. Например.

                  Если Вам нужно будет какой-то код обработать особо. Например 401, то вот тут

                  if (result == null || retryableCodes.contains(result.statusCode())) ->

                  Появятся дополнения и все это прямо тут ...


                  1. BugM
                    29.01.2023 15:28

                    ThreadLocalRandom.current()

                    Это уже много лет как рекомендованный способ получения случайного числа. Это прямо в комментариях к методу написано.

                    Даже если джун не в курсе поймет по клику провалившись в метод.

                    Разберётся так, что сможет протестировать и взять на поддержку.

                    Этот код не надо поддерживать. Он реально способен работать десятилетиями без изменений. Пока что-то глобальное в мире не изменится, вроде кодов ответа http или http клиента.

                    Разберется так что поймет что и как там делается и сможет осознанно это использовать.

                    Если Вам нужно будет какой-то код обработать особо. Например 401, то вот тут

                    Никаких особых обработок. Это утиль метод. Только ретраи того что мы хотим ретраить всегда в любом вызывающем коде.

                    На ревью такое изменение зарубать надо. Ему место где-то в другом методе.

                    Cчитайте что я на ревью вам принес ПР с удалением вашего кода из проекта и его заменой на этот кусок. С обоснованием "я тут хорошо сделал".

                    В общем я мерджу. Возражений нет.


                    1. in86 Автор
                      29.01.2023 20:15

                      Даже если джун не в курсе поймет по клику провалившись в метод.

                      Вот уже мы начинаем с Вами уходит в дискуссию по вопросу, который и для Вас, очевидно, понятен, что "джун может не разобраться". Пограничное состояние между очевижно/сложно/требует погружения пройдено и Вы так же подтверждаете, что код требует того, чтобы в нем разобрались. А с учетом того, что у Вас кода много, он в одном месте, веток развития много, логика не инкапсулирована и каждый if отвечает за свое, цикломатичность возрастает.

                      На ревью такое изменение зарубать надо. Ему место где-то в другом методе.

                      Почему? Логика работы со многими авторизационными механизмами предполагает перехват 401 сообщения. У Вас секреты устарели и их надо обновить. Вы поймаете 401 и обновляйте. Если это предлагаемый подход - это ком. Если все разложено по полочкам, то каждая полка дает возможность в рамках ответственности полки сделать необходимые действия и не погружаться дальше

                      В общем я мерджу. Возражений нет.

                      Конечно, мерджите. Ваш мердж, Ваша поддержка и развитие сделанного )


                      1. BugM
                        29.01.2023 20:35

                        Вот уже мы начинаем с Вами уходит в дискуссию по вопросу, который и для Вас, очевидно, понятен, что "джун может не разобраться".

                        Ну блин. Мои джуны точно способны провалиться в метод стандартной либы и прочитать 3 строчки о том что он делает. Тем более что метод стандартнее некуда.

                        логика не инкапсулирована и каждый if отвечает за свое, цикломатичность возрастает

                        20 строк. Это точно много? Ифчики под которыми одна строка это точно сложно?

                        Не забывайте что у вас спрингмагия во все поля. В которой сеньор рискует на день закопаться.

                        Почему? Логика работы со многими авторизационными механизмами предполагает перехват 401 сообщения.

                        Ретраи не для авторизации. Ретраи не для секретов. Ретраи не для ловли четырехсоток. Это просто ретраи флапающих ошибок сети или удаленного сервера. Зачем вы пытаетесь туда запихать что-то не то?

                        Сделать рядом метод httpGetWithAuth(..) с вашей типовой авторизацией и секретами это хорошая и полезная идея. Но это отдельная функция. Которая внутри себя имеет право вызывать httpGet с ретраями. И httpGetWithAuth тоже делается без спингмагии. Просто статический метод, просто параметры.

                        Конечно, мерджите. Ваш мердж, Ваша поддержка и развитие сделанного )

                        Это не надо развивать. Это не надо поддерживать. У этого не надо обновлять зависимости. Это не зависит от внешнего кода. Один раз написали и оно 10-20 лет просто работает. Возможно и дольше. С нулем затрат на поддержку. Представляете как круто?

                        Попробуйте писать с минимумом Спринга там где его польза неочевидна. Можно передать параметром? Передавайте параметров. Можно без бина? Значит без бина. Можно просто отдельную функцию? Значит просто отдельная функция. Саплаеры и подобное хорошо работают для явного вызова функций в обратную сторону. И даже страшное: через new объекты можно создавать. Это часто имеет смысл. Спрингом заинжектили всякие сервисы и даошки и хватит, остальное вероятно не нужно. Все связи сразу становятся очевидны, а где искать реально исполняющийся код понятно. Нет всех этих наслоений ничего не делающих классов, которые нужны только чтобы Спрингу понравиться. Код от этого становится заметно проще и удобнее в эксплуатации. У вас виден сильный оверинжинеринг, там где он совсем не нужен.


                      1. in86 Автор
                        29.01.2023 21:59

                        За мнение спасибо. Мнение понятно.


  1. kirya522
    29.01.2023 02:16
    +1

    Советую к шаблону докрутить callback, который будет вызван при повторе/падении запроса.

    Так метрики можно будет собирать не только на уровне API (только коды ответов и тд), но и этой обертки (сколько из этих запросов были повторены).

    Реализовали у себя очень похожую штуку.


    1. in86 Автор
      29.01.2023 09:28

      Спасибо. Идея понятна. В нашем случае такую задачу решает автоматическая выгрузка/загрузка логов на уровне k8s. Ваге предложение сделает модуль более независимым и самостоятельным. Круто.