Привет, давайте знакомиться! Меня зовут, Иван. Для самых нетерпеливых и пытливых, которые хотят сразу к сути и проматывают введение, в этой статье поговорим о: Что такое устойчивость и какое влияние на нее имеет 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. У нас есть наш конечный список кодов. Похоже, что получается так: Поясню. Как альтернативное, более производительное решение, напрашивается использование простой переменной в виде final static, которая будет содержать требуемый массив значений. Так сделать не сложно. Я руководствуюсь понятными мне стимулами расширения кода и выделения этого списка в отдельный интерфейс с реализацией по умолчанию. Будет возможность внедрять ее в иерархию классов, каждый из которых будет по своему обрабатывать коды ошибок. Если мои стимулы, после моего пояснения не стали вам близки, то переделывайте метод в final static переменную.    Алгоритм Возможности переиспользования сделанного Тестирование Теперь у нас есть список кодов, на которые должна быть реакция. Отлично. Самое время научиться правильно реагировать. Верная реакция должна задействовать feign client. Сверяемся с документацией и учимся реагировать правильно. Для реализации повторных попыток нужно задействовать интерфейс Retryer. Он реагирует на RetryableException и ничего не возвращает. При выполнении он либо выдает исключение, либо успешно завершается. Если будет исключение вызов завершится ошибкой. Подробнее разберем процесс обработки и сигнатуру RetryableException, чтобы максимально эффективно использовать возможности класса. Для того, чтобы разобраться в нем ничего лучше кода не нашлось. Это исключение наследовано от FeignException и у него есть 2 конструктора. Первый заточен под обработку уже случившегося исключения, второй под самостоятельный проброс исключения. Отлично. В нашей ситуации нужен самостоятельный проброс. Сигнатура этого конструктора требует: Cтатус - есть; Cообщение - соберем; Тип вызываемого метода, который вернул ошибку - есть; Дату, после которой можно/нужно перезапрашивать - пока проигнорируем. В нашей задаче возможности для такого тюнинга кажутся лишними; Запрос, который вернул ошибку - есть; Прекрасно. У нас есть все необходимое для продолжения. То есть, чтобы сигнализировать о необходимости повторной попытки мы должны бросить RetryableException. У нас есть понимание, что делать и когда делать. Похоже исключение готово: Мы проверяем статус сообщения и, если этот статус находится в допустимом диапазоне нашего списка кодов, мы инициируем ошибку, которую обработает feign:   Итак, у нас целиком готова законченная реакция на возникновение сетевой нестабильности. С точки зрения spring, это модуль, который указывает, что класс содержит методы определения для внедрения в Bean модули. То есть, для нас это конфигурация, которую необходимо пометить аннотацией @Configuration. Придумаем название и оставим краткий и понятный javadoc: Поведение готово. Можно проверять и писать Похоже на проблему с настройкой заголовков, или кэш нам немного навредил, но их оставим на конец. Разберемся куда и как имплементировать наш модуль и что необходимо для полноценных повторных запросов:   Когда мы получаем ошибочный ответ, Feign передает его экземпляру интерфейса ErrorDecoder, который решает, что с ним делать. Что наиболее важно, декодер может сопоставить исключение с экземпляром RetryableException, позволяя Retryer повторить вызов. Реализация ErrorDecoder по умолчанию создает экземпляр RetryableExeception только тогда, когда ответ содержит заголовок «Retry-After».Чаще всего мы можем найти его в ответах 503 Service Unreachable. Мы используем Feign. У нас есть ErrorDeoder, то есть уже готово место имплементации нашей обработки. Полагаться на заполненность заголовка «Retry-After» мы не можем, да нам это и не нужно. На предыдущих шагах мы четко определили границы запроса статусами сообщений, которые собираемся обрабатывать. Их и будем применять. Теперь найдем сервис, который больше всего нуждается в повышении устойчивости (логи нам в помощь) и на его примере отработаем способ внедрения нашего кода. Это не составило труда. Kibana и KQL (профильный язык запросов) решили нашу задачку. Находим нашего клиента:   В нем вы задаете логику обработки ошибок. Для него вы можете создать свое семейство классов обработчиков ошибок от конкретных сервисов, можете имплементировать дополнительную логику. Наша задача - внедрить свою обработку повторных вызовов. У нас все для этого есть, да и код мы не сильно усложним:   Мы внедрили свой класс в виде отдельного приватного параметра. Используем агрегацию. Соблюдаем принципы SOLID. Таким образом, наш класс-обработчик выполняет свою обязанность (single responsobility). При этом он спроектирован верно (open/closed), что позволило нам легко внести изменения. Спасибо коллеги :-). Мне нравится использовать lombok, я обязательно проверяю как он работает, чтобы не допустить проблем при компиляции, поэтому тут @AllArgsConstructor вполне к месту. Теперь возвращаемся к конфигурационному файлу и дополняем его. Вставляем в него наш класс-обработчик. Сверяемся с документацией и проверяем все ли необходимое мы сделали:   Feign предоставляет разумную реализацию интерфейса Retryer по умолчанию. Он будет повторять только заданное количество раз, будет начинаться с некоторого интервала времени, а затем увеличивать его с каждой повторной попыткой до заданного максимума. Давайте определим его с начальным интервалом 100 миллисекунд, максимальным интервалом 3 секунды и максимальным количеством попыток 5. То есть, нам нужно добавить компонент, который будет возвращать Retryer. Он и реализует повторные запросы. Его default настройки нас полностью устраивают. У меня нет задачи специфичной повторной обработки статусов, поэтому я не буду использовать свои параметры. Воспользуюсь предоставленной мудростью. Добавил, но IDEA подсвечивает мне, указывая на то, что мой файл конфигурации, который является обычным файлом может иметь проблемы с обработкой добавленных компонентов. Причина в том, что в приложении Spring внедрение одного bean-компонента в другой bean-компонент очень распространено. Однако иногда желательно внедрить компонент в обычный объект, как в нашем случае и произошло. Для обработки этого используется аннотация @Configurable, которая позволяет экземплярам декорированного класса содержать ссылки на bean-компоненты Spring. В конечном итоге наш класс конфигурации будет выглядеть так:   Запускаем, проверяем. Победа. Но не совсем. Нужно проверить и зафиксировать поведение нашего кода. Настало время unit-tests. В нашем случае мы проверяем, что при определенных условиях будет выполнено установленное количество попыток перезапроса сообщения, если получен нужный для нас ошибочный статус сообщения. Для этого теста нужен инструмент мокирования сообщений. Для нас привычной практикой является использование wiremock. Основные сложности тут не с использованием синтаксиса wiremock, а с его настройкой. В данном случае мне повезло. Нужный класс уже был настроен. Про настройку wiremock я не буду рассказывать подробно. Это тема отдельной статьи. Вместо этого дам вам полезную ссылочку.В моем unit-test мне надо проверить, что При запросе по определенному адресу, определенным методом -> Возникнет определенного типа исключение, которое приведет к -> Возникновению исключения в конфигурации сервиса, которое приведет к тому, что -> Будет выполнено определенное количество повторов сообщений (в нашем случае 5) Отлично. Задача сформулирована. Самое время ее реализовать: Знакомьтесь givenThat, urlEqualTo, verify, postRequestedFor - это wiremock. Мы сымитировали вызов по url, дальше с помощью Mockito сымитировали проброс исключения, проверили, что оно было отловлено, и так 5 раз. Здорово, но чего-то не хватает. Зачем мы используем тут ошибочный статус, если у нас они уже есть в отдельной структуре. Точно. Используем ее, но для этого придется немного усложнить и использовать параметризацию тестов. В конечном виде тест получится такой:   Про параметризованные тесты нужно писать отдельную статью. Я это обязательно сделаю, но более конкретно чем тут я точно не напишу. Поясню, что все многообразие определенных статусов я использую с помощью класса, в котором инкапсулирована логика обработки тех самых статусов. Это класс ProvideRetryableStatuses. Он выглядит так:   В нем единственный метод, который по сути возвращает 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; Содержимое файла должно однозначно говорить о том, что мы добавляем в контекст; Если у Вас там более одного класса и есть определенная иерархия обработок (мы вот создали), то через запятую указываете абсолютный путь до каждой. Упаковали наш стартер. Теперь самое время покрыть его unit test. Напомню, что класс, который мы вынесли в starter не реализует логики перезапроса сообщений. Он отвечает только за бросание конкретного исключения. Ну что же. Все получается в духе SOLID, поэтому тесты будет лаконичными и конкретными: Запрос -> Ответ -> Проверка типа выброшенного исключения По ходу введем параметризацию, для того, чтобы проверить все обрабатываемые нами статусы и в цикле проверим, что такое поведение будет обрабатываться всеми методами http. Похоже, что мы на 100% покрыли весь свой код. В конкретных сервисах мы будем проверять логику повторных вызов сообщений, а наш starter будет отвечать за проверку и выброс сообщения по требуемым для нас статусам: Starter закончен. Помещаем его в системное хранилище, из которого вы или кто-то, имеющий к нему доступ, сможете его брать. Следом настраиваем конфигурацию сборщика, который будет внедрять стартер в работу конкретного сервиса. Для хранения наших starter мы используем nexus, в качестве сборщика - maven. Типичная maven конфигурация выглядит так:   Starter артефакты помогают развивать программную инженерию предприятия, создавать, переиспользовать сервисы и библиотеки, которые сделают благое дело не только конкретной команде, а всем потребителям, кому такая реализация паттерна будет полезной. Используемые нами инструменты и подходы это позволяют. Ну что же. Будем держаться выбранного пути. Надеюсь статья была Вам полезна.   Под завершение я чувствую себя обязанным сказать большое спасибо моей семье, которая терпит меня и мое занудство в освоении новой для меня профессии, моим коллегам - Никите, который находится в таком же положении как я, но при этом помогает мне с тем, что идет у меня туго, АльфаСтрахование, руководителям, архитекторам, ревьюерам этой статьи, причастным к развитию нашего продукта и технологического стека компании - Вы даете возможности, которыми я и мои коллеги стараемся воспользоваться, нашим бизнес пользователям и тебе, дорогой хабравчанин. Чем больше критики и предложений ты выскажешь, тем более полезно это будет для меня.   В заголовке я поднял вопрос. В статье я обозначил мою позицию: "NO". Для меня ответ заключается в том, что устойчивость - это Not Only retry. По мере нашего движения в сторону ее повышения я или мои коллеги напишем для Вас что-нибудь еще из нашего опыта. Успехов и процветания, не скучайте сами и не давайте скучать вашим коллегам.   Интересующийся технологиями и их реализацией