company_banner

Повышаем надёжность HttpClient’а в .NET Core или как ошибиться в 3 строках кода 4 раза

    За несколько недель до 14 февраля системе Dodo IS немного поплохело под нагрузкой. Одной из причин стало то, что в backend’ах мобильного приложения и сайта не совсем корректно работали политики поверх HttpClient’а (Retry, Circuit Breaker, Timeout). В этой статье я хочу поделиться с вами потенциальными проблемами, которые могут возникнуть при неправильном использовании таких политик.



    Политики HttpClient’а и надёжность запросов


    Для начала краткая вводная: о каких «политиках поверх HttpClient’а» мы говорим, и зачем они нужны?


    Допустим, сервис А запрашивает какие-то данные у сервиса B путём обычного http-запроса. К сожалению, сеть — штука ненадёжная, а сервера могут выходить из строя. Мы не можем гарантировать, что наш запрос успешно дойдёт и будет обработан на стороне сервиса B.


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


    1. Retry policy — для ситуации, когда в ответ на наш запрос вернулась ошибка, существует политика повторных запросов. Идея очевидная: если что-то пошло не так, то давайте попробуем ещё раз?
    2. На случай, если мы ждём ответ от сервиса B, а его всё нет, придумана политика таймаутов.
    3. Circuit Breaker — ещё одна интересная политика, которая позволяет нам останавливать все запросы к какому-то сервису, если мы точно знаем, что он недоступен (подробнее см. дальше).

    Такие политики работают как обёртка над стандартным HttpClient’ом. Каждая из политик перехватывает запрос, проверяет ответ от сервера и выполняет какие-то операции.


    Спасибо Polly за наше счастливое детство


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


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


    Как ошибиться в 3 строках кода 4 раза


    В чём же тут проблема? Берём клиент, обвешиваем нужными политиками, которые декорируют исходный HttpClient, и всё работает. Ниже приведён пример, как сейчас собирается HttpClient с помощью IHttpClientBuilder’а и какие политики к нему применяются.


    clientBuilder
        .AddRetryPolicy(settings.RetrySettings)
        .AddCircuitBreakerPolicy(settings.CircuitBreakerSettings)
        .AddTimeoutPolicy(settings.TimeoutPerTry);

    Здесь ровно три строчки кода с применением политик. Ниже мы рассмотрим, как можно ошибиться в каждой из них, вдобавок ещё и порядок напутать. Итого 4 ошибки.


    Retry policy


    Начнём с повторных запросов. Тут вроде бы всё достаточно просто: если код ответа от сервера находится в списке кодов, которые нужно «ретраить» (разрешите мне ввести в нашу терминологию такое классное русское слово), то мы выполняем запрос ещё раз. В самой библиотеке Polly есть описание синтаксиса и работы такой политики.


    В чём же тут можно ошибиться? На самом деле, когда мы имеем дело с распределёнными системами, то ошибиться можно абсолютно во всем. Рассмотрим несколько моментов, которые могут потенциально вызвать проблему.


    Какие ответы от сервера нужно ретраить


    Давайте разберёмся с тем, какие ответы от сервера мы собираемся ретраить. Вообще, в индустрии уже придуманы best practices на эту тему, которые говорят, что ретраить нужно так называемые временные ошибки (transient errors).


    Авторы Polly уже подумали о нас и включили в библиотеку обработчик таких ошибок. К ним относятся:


    • все 5xx коды (server errors);
    • код 408 (request timeout).

    Про последний мы поговорим отдельно, а 5xx в идеальном мире вообще не должно быть (как бы не так!). Если сервер нам ответил пятисоткой, значит что-то не так, и мы надеемся, что это временная проблема.


    У нас в компании возник один вопрос, который вызвал много горячих споров:


    – А стоит ли ретраить код 500 (Internal Server Error)?
    – Да, стоит.


    Ответа с кодом 500 не должно быть. Но в какой ситуации в реальной жизни нам может вернуться 500? Самый банальный случай — это моя самая любимая ошибка NullReferenceException (NRE) в коде на стороне сервера. И встаёт вопрос, а нужно ли ретраить NRE. Есть ли какие-то основания полагать, что с какой-то N-ной попытки наш запрос будет успешно обработан?


    Да, в идеальном мире NRE не должно быть, но мы — живые люди, и мы ошибаемся. Для себя мы пока решили оставить ретраи для кода 500, но вопрос всё ещё дискуссионный.


    Какие интервалы нужны между повторными запросами


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


    Тут есть несколько стратегий:


    1. Ретраи через фиксированные интервалы времени, например, через каждые 20 ms. Такая стратегия — это просто молоток, который долбит сервер несмотря ни на что. Бывают ситуации, когда просто моргнула сеть, тогда быстрый ретрай нам как раз помогает получить ответ от сервера. Но что, если и во второй, и в третий раз нам возвращается какой-нибудь 503? Может, сервер ушёл в перезагрузку, или выкатывается новая версия. Если мы на стороне клиента поняли, что сервер сейчас недоступен, а ответ нам очень нужен, то лучше подождать побольше.


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


      initialValue * Math.Pow(2 * i)

      Здесь initialValue — это стартовое значение, например, 20 ms, а i — номер попытки.
      Хоть эта стратегия и лучше, в ней есть одна тонкость. Опасно, когда у нас начинает ретраиться много разных запросов и при этом их попытки синхронизируются по времени, пусть даже по экспоненциальной стратегии. В этом случае мы создаём моменты пиковой нагрузки на сервер, которому и без нас плохо.


    3. Чтобы как-то защитить наш многострадальный сервер, мы можем использовать так называемый jitter. Мы немного «размажем» нагрузку и добавим некоторую случайную составляющую к интервалу между попытками, чтобы запросы не улетали одновременно. К счастью, реализация такой стратегии уже есть в Polly.


      Отдельно хочу отметить, что автором алгоритма, который лежит в основе рекомендуемой к использованию стратегии DecorrelatedJitterBackoffV2, является Георгий Полевой — разработчик из команды платформы в Dodo.


    Где тут можно ошибиться? Случай из жизни


    Мы вроде бы учли все эти нюансы. Использовали правильную стратегию выбора интервалов между повторными запросами. И написали вот такой код:


    …
    var delay = Backoff.DecorrelatedJitterBackoffV2(
        medianFirstRetryDelay: TimeSpan.FromSeconds(medianFirstRetryDelayInSeconds),
        retryCount: count
    );
    
    return FailurePolicyBuilder.WaitAndRetryAsync(
        delay,
        onRetry: (exception, retryCount) => { … });

    Дело в том, что DecorrelatedJitterBackoffV2 возвращает нам IEnumerable<TimeSpan>. Мы вычисляем его один раз и потом передаём в метод WaitAndRetryAsync.


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


    Func<IEnumerable<TimeSpan>> delay = () => Backoff.DecorrelatedJitterBackoffV2(
        medianFirstRetryDelay: TimeSpan.FromSeconds(medianFirstRetryDelayInSeconds),
        retryCount: count);

    Наше решение


    Собрав всё вместе, мы получили следующий код для нашей политики ретраев:


    private static IHttpClientBuilder AddRetryPolicy(
        this IHttpClientBuilder clientBuilder,
        IRetrySettings settings)
    {
        return clientBuilder
            .AddPolicyHandler(HttpPolicyExtensions
                .HandleTransientHttpError()
                .Or<TimeoutRejectedException>()
                .WaitAndRetryAsync(
                    settings.RetryCount,
                    settings.SleepDurationProvider,
                    settings.OnRetry));
    }

    Здесь, SleepDurationProvider — это как раз стратегия, которая под капотом использует Backoff.DecorrelatedJitterBackoffV2 обёрнутый в лямбду.


    Возможно, у вас возникнет справедливый вопрос: если мы ретраим все временные ошибки, к которым относится и код 408 (request timeout), то зачем в коде отдельно добавлена обработка TimeoutRejectedException? На этот вопрос мы подробнее ответим, когда дойдём до раздела TimeoutPolicy.


    Circuit Breaker policy


    Понятие Circuit Breaker’а (CB) пришло к нам из схемотехники. Идея в следующем: если мы в какой-то момент понимаем, что сервер нам перестал отвечать, то давайте не будем его добивать и «разомкнём цепь», т.е. остановим все запросы к этому серверу на некоторое время. Он и без того уже в огне. Даже если мы сделали бриллиантовую политику ретраев, то это всё равно не защищает нас от ситуаций, когда мы бесконечно стучимся в умирающий сервер.


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


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


    Для этой цели в Polly уже предусмотрено целых два CircuitBreaker’а: обычный и продвинутый. Здесь мы не будем рассматривать, как настраивается CB, так как по ссылкам выше всё подробно расписано. Вместо этого сфокусируемся на потенциальной проблеме неправильного подключения CB. Дополнительно рассмотрим ситуацию, когда клиент обращается к разным сервисам на разных хостах.


    Где тут можно ошибиться? Случай из жизни


    Здесь ошибка прямо противоположная той, что мы видели с ретраями. Посмотрите на пример кода:


    clientbuilder
        .AddPolicyHandler(policySelector: (serviceProvider, _) => 
            FailurePolicyBuilder.AdvancedCircuitBreakerAsync( ... ));

    В AddPolicyHandler мы передаём не политику, а selector, который представляет собой лямбду, внутри которой создаётся CB.


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


    Наше решение


    Вот так выглядит наш текущий код, где исправлена данная проблема:


    private static IHttpClientBuilder AddCircuitBreakerPolicy(
        this IHttpClientBuilder clientBuilder,
        ICircuitBreakerSettings settings)
    {
        return clientBuilder.AddPolicyHandler(BuildCircuitBreakerPolicy(settings));
    }
    
    private static AsyncCircuitBreakerPolicy<HttpResponseMessage> BuildCircuitBreakerPolicy(
        ICircuitBreakerSettings settings)
    {
        return HttpPolicyExtensions
            .HandleTransientHttpError()
            .Or<TimeoutRejectedException>()
            .OrResult(r => r.StatusCode == (HttpStatusCode) 429) // Too Many Requests
            .AdvancedCircuitBreakerAsync(
                settings.FailureThreshold,
                settings.SamplingDuration,
                settings.MinimumThroughput,
                settings.DurationOfBreak,
                settings.OnBreak,
                settings.OnReset,
                settings.OnHalfOpen);
    }

    Тут нет ничего особенного, всё то же, что и в ретраях, за исключением ошибки Too Many Requests (429). Если сервер начал нам отвечать, что он уже не может обрабатывать запросы, для нас это повод начать считать такие ответы и приостановить дальнейшие попытки нагружать сервер.


    Дополнение: один клиент и несколько серверов на разных хостах

    Есть ещё одна интересная ситуация, когда у нас есть один клиент и много серверов, которые расположены на разных хостах.



    Рис. 1. Один клиент взаимодействует с несколькими серверами на разных хостах


    Практический сценарий в нашем случае: у нас есть country agnostic бэкенд мобильного приложения (клиент), один и тот же для всех стран. Он обращается за данными в бэкофис (сервер). Бэкофис пока ещё country specific и под каждую страну деплоится отдельно (см. рис 1).


    Представим себе ситуацию, когда бэкофис на какой-то стране из-за местного национального праздника перестал справляться с запросами, и CB на стороне клиента решил открыться. В текущей реализации политики для CB это означает, что у нас остановятся все запросы для всех стран. Этого мы не хотим, поэтому нам нужно каким-то образом привязать запросы к хостам, к которым они обращаются, и открывать CB раздельно. Такая возможность существует. Для этого у Polly предусмотрен PolicyRegistry, который под капотом содержит ConcurrentDictionary. Использовать его можно следующим образом:


    private static IHttpClientBuilder AddHostSpecificCircuitBreakerPolicy(
        this IHttpClientBuilder clientBuilder,
        ICircuitBreakerSettings settings)
    {
        var registry = new PolicyRegistry();
        return clientBuilder.AddPolicyHandler(message =>
        {
             var policyKey = message.RequestUri.Host;
             var policy = registry.GetOrAdd(policyKey, BuildCircuitBreakerPolicy(settings));
             return policy;
         });
    }

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


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


    Timeout policy


    Начнём с того, что таймаутов, вообще говоря, несколько.


    1. Таймаут на отдельный запрос. Это временное ограничение, которое мы накладываем на каждый запрос в отдельности. В случае с ретраями — на каждую попытку в отдельности.
    2. Общий таймаут на одну логическую операцию. Например, мы посылаем запрос к серверу и ожидаем получить результат в течение 30 секунд. Нас не интересует, будут ли там внутри ретраи, открылся CB или нет. Если за 30 секунд мы не получили ответ, то всё, операция считается неуспешной. Это такой же таймаут, но уже вне зоны действия политики ретраев.
    3. Также сам HttpClient имеет свой внутренний таймаут, который по умолчанию равен 100 секундам.

    Polly, разумеется, предоставляет нам готовую политику таймаутов. С её помощью можно реализовать как таймаут на отдельный запрос, так и общий таймаут. Когда такой таймаут истечёт, будет сгенерировано исключение TimeoutRejectedException. Как вы видели в коде выше, мы перехватываем и обрабатываем эту ошибку и в политике ретраев, и в CB.


    HttpClient в случае своего таймаута генерирует другую ошибку — TaskCancelledException. Если она произошла, то клиент остановит все запросы в данной сессии вне зависимости от ваших политик, поэтому такой исход тоже нужно учитывать.


    В общем случае нужно убедиться, что таймауты выставлены корректно: таймаут на клиенте больше, чем таймаут на попытку. Ошибки в этих настройках могут свести на нет все остальные настройки политик, так как работа будет прерываться по таймауту раньше, чем нужно.


    Наше решение


    Сейчас мы в качестве политики используем только таймаут на одну попытку + выставляем таймаут на самом HttpClient’е. Эта политика выглядит проще всего:


    private static IHttpClientBuilder AddTimeoutPolicy(
        this IHttpClientBuilder httpClientBuilder,
        TimeSpan timeout)
    {
        return httpClientBuilder.AddPolicyHandler(
            Policy.TimeoutAsync<HttpResponseMessage>(timeout));
    }

    Следует помнить ещё про один вид таймаутов: таймаут, который может прийти от сервера. Сервер может нам ответить кодом 408 (Request Timeout). Это именно ответ от сервера, политика таймаутов к такому ответу не имеет отношения. Код 408 может вернуться в ситуациях, когда сервер примет решение закрыть неиспользуемые соединения в целях экономии ресурсов. Такой ответ относится к временным (transient) ошибкам, и мы будем его ретраить.


    Порядок имеет значение


    Итак, у нас есть некоторый набор политик, и мы знаем, как они работают. Кроме понимания работы каждой политики в отдельности важно, чтобы вместе они работали правильно. Политики подключаются как декораторы, так что запрос, проходя через политики, сначала обрабатывается политикой A, затем B, затем C, а ответ в обратном порядке: C -> B -> A (см. рис. 2). Это важно, потому что неправильный порядок может привести к непредсказуемым результатам.



    Рис. 2. Путь запроса и ответа через политики


    Например, если наша политика CB окажется левее на схеме, чем политика ретраев (WaitAndRetry), то наш CB или никогда не откроется, или же откроется катастрофически поздно. Так, если запрос пройдет CB и попадет в политику ретраев, то он будет спокойно ретраиться и CB не получит ответ до тех пор, пока не пройдут все ретраи.


    Также важно помнить о том, что политика таймаутов может появляться в нескольких местах. На рис. 2 показаны расположения таймаута на попытку (Timeout Per Try) и общего таймаута (Overall Timeout), про которые мы говорили выше. Поэтому крайне важно точно понимать ту схему работы вашего клиента, которую вы ожидаете, и, исходя из неё, подключить политики в правильном порядке.


    Идемпотентность и подводные камни


    При работе с распределёнными системами ещё важно помнить про такую штуку как идемпотентность запросов.


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

    Если мы говорим про REST, то в общем случае POST запрос, который по классике отвечает за создание нового ресурса, не является идемпотентным сам по себе, так как повторное выполнение запроса будет создавать новые ресурсы.


    А в каком случае может возникнуть повторное выполнение? В том случае, если мы ретраим наши запросы. Важно помнить, что если нам вернулся, например, таймаут, и мы повторно отправляем запрос, то это не значит, что первоначальный запрос не был обработан сервером. Таймаут означает лишь то, что мы больше не ждём этот результат.


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


    Ещё один важный момент — посмотреть на свои политики со стороны пользователя. Казалось бы, что все эти ретраи и прочие таймауты — внутренняя кухня сервиса, но всё это занимает время. Если мы сделали супернадёжную систему, но пользователь, нажимая на кнопку создания заказа, смотрит на крутящийся спиннер 2 минуты, пока мы там в фоне ретраим или ещё что-нибудь делаем, то клиент просто уйдёт, и наша надёжная система станет никому не нужна.


    Иногда лучше «упасть раньше» и сообщить клиенту, что у нас что-то не получилось, чем создавать бесконечное ожидание. Это вопрос уже не технический, а, скорее, продуктовый, но о нём тоже не стоит забывать.


    Наша open-source библиотека


    К сожалению, эта статья написана кровью. Чтобы избежать подобных ошибок в будущем, мы упаковали всю описанную выше логику в отдельную библиотеку. Библиотека предоставляет преднастроенные политики по умолчанию, позволяет их сконфигурировать (например, передать количество попыток при ретраях или время таймаутов). Например, все описанные выше политики с нашей библиотекой могут выглядеть так:


    using Dodo.HttpClient.ResiliencePolicies;
    …
    
    // С настройками по умолчанию, если вы нам доверяете :)
    clientBuilder
        .AddDefaultPolicies();
    
    // Вы можете определить свои настройки и использовать политики с этими настройками
    var settings = new HttpClientSettings(…);
    clientBuilder
        .AddDefaultPolicies(settings);

    Под капотом мы используем Polly и предоставляем обёртку над этой библиотекой. Сейчас это решение по умолчанию для большинства наших сервисов. Если у нас нет каких-то специфичных требований (или нет желания читать всю документацию к Polly), то просто берём эту библиотеку и получаем HttpClient на стероидах.


    Проект Dodo Open Source


    Мы как компания исследуем тему и задумываемся о том, что нужно инвестировать в Open Source, а также вносить свой вклад в комьюнити разработчиков. В этом смысле платформенные инструменты и библиотеки — отличные кандидаты на эту роль. И начинаем мы как раз с библиотеки для повышения надёжности HttpClient’а.


    Библиотека называется Dodo.HttpClient.ResiliencePolicies. Исходный код доступен на GitHub. Распространяется как NuGet-пакет.


    Мы будем очень рады, если эта библиотека окажется вам полезной. И, разумеется, в этой библиотеке есть ещё над чем поработать, так что ваши Issues и PR приветствуются.


    Заключение


    Помните, с чего всё начиналось? Не с проблем с HttpClient’ом, а с Дня Святого Валентина. Так вот, 14 февраля мы успешно пережили, достигнув в пике 369 заказов в минуту. Мне как разработчику приятно осознавать, что наша библиотека стала одним из кубиков в стабильности всей системы. Помимо того, что мы научились лучше решать проблемы, возникающие при взаимодействии сервисов, мы также сделали первый шаг в сторону Open Source. И я искренне надеюсь, что это первый шаг на большом пути. Присоединяйтесь!


    Полезные ссылки


    • Cloud Design Patterns – прекрасный набор архитектурных паттернов от Microsoft, в котором раскрываются эти и множество других вопросов при проектировании распределённых систем.
    • Библиотека Polly.
    • Библиотека Dodo.HttpClient.ResiliencePolicies.
    Dodo Pizza Engineering
    О том как IT доставляет пиццу

    Комментарии 31

      0
      Да, в идеальном мире NRE не должно быть, но мы — живые люди, и мы ошибаемся. Для себя мы пока решили оставить ретраи для кода 500, но вопрос всё ещё дискуссионный.
      Очень философски
        +5
        При работе с распределенными системами нужно быть немножко философом :)
          +2
          При работе с распределенными системами и разработке браузеров)
          0

          норм, vk api опрашивать — самое оно, его как мейл купил, так там через день 500 стало падать

          –7

          Отличная статья, это нужно прочитать каждому, кто собирается делать автоматические повторы запросов. Но возникает вопрос — а зачем это все нужно? Есть очень простая политика — сервис вернул ошибку, ретранслируй ее пользователю, не пытайся повторять запросы. Не вижу смысла использовать что-то ещё в сервисе заказа пиццы или интернет магазине. Какие проблемы пользователя или бизнеса вся эта машинерия решает?

            +5
            Есть очень простая политика — сервис вернул ошибку, ретранслируй ее пользователю, не пытайся повторять запросы.

            А что пользователь будет с этой ошибкой делать?


            Какие проблемы пользователя или бизнеса вся эта машинерия решает?

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

              +3

              Я комментарий свой написал когда поймал себя на мысли, что тяжело придумать такие трансиентные ошибки, которые бы а) возникали достаточно часто, чтобы это слало проблемой и б) решались сами собой в пределах 10 секунд.


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

                +4

                Простейший пример — сеть моргнула.

                  +2
                  Сеть моргнула это пожалуй единственный сценарий, который очевиден. Но он же и самый редкий в контексте сервиса, который крутится в дата центре, с надежной сетью. Даже если она падает, то случается это так редко, что тратить месяцы усилий на корректный retry кажется излишним.

                  Ниже вот подсказывают кейс с пулом сервисов и 429. Это понятный сценарий и очень даже жизненный. Я о таких сценариях, в контексте Dodo и спрашиваю.

                    +4
                    Самый простой пример — сервис, который мы планируем ретраить, крутится на n серверах. В какой-от момент один из серверов начинает плохо себя чувствовать (память кончилась, в рестарт ушел — в облаке часто что-то случается). В такой ситуации запрос, пришедший на умирающий сервер, после ретрая с большой вероятностью (nginx выводит плохие сервера из апстима) попадет на здоровую машину и пользователь не заметит проблемы.
                    У нас такое случается постоянно, по разным причинам.
                  +3
                  Мы же говорим о системе которая живет в облаке, скорее всего в одном дата центре.

                  Почему бы?


                  При этом обсуждаются синхронные сценарии

                  Почему бы?


                  Ну то есть вы взяли какие-то допущения, я понимаю, но почему вы считаете, что эти допущения верны?


                  Скажем, у меня вот мобильное приложение говорит с бэкендом (тут, в принципе, уже все это применимо, но на мобилке, все-таки, маловероятен .net, так что пойдем дальше), и запускает там некую условно долгоиграющую (15-30-60 секунд) задачу. Это, понятное дело, уже асинхрония (в моем случае — через поллинг, но не важно), поэтому второе ваше допущение неверно. А бэкенд говорит с сервисом, предоставляемым другой компанией, и они в разных облаках, если не на разных континентах (что уже передает привет всем сетевым проблемам), и в документации на этот сервис явно написано: бывает вот такая ошибка, в этом случае сделайте ретрай через время, указанное в заголовке.


                  Если у меня есть политики повторов в бэкенде, все будет работать более-менее само. Если у меня бэкенд будет просто пробрасывать ошибку пользователю, пользватель будет сам нажимать кнопку "повторить" (если мы ее ему дали) и расстраиваться. А зачем?

                    0
                    Я говорю о сценарии с которого все началось в статье — «Одной из причин стало то, что в backend’ах мобильного приложения и сайта не совсем корректно работали политики поверх HttpClient’а (Retry, Circuit Breaker, Timeout).»
                      0

                      Ну так у меня ровно это и есть: бэкенд мобильного приложения. Он же бэкенд сайта.

                +4
                Все описанные подходы по сути решают проблему с транзиентными ошибками, то есть, мы считаем, что это не бизнес-ошибка, что это временный сбой системы, который с течением времени исправится.
                Сделать последствия таких сбоев менее заметными и не усугубить ситуацию — для этого и используются все эти политики.

                Ну и, в дополнение, существует еще ряд несистемных транзиентных ошибок, которые могут быть заложены бизнес-логикой приложения. Например, eventually consistency или optimistic concurrency в принципе используют такой дизайн, что ошибки (а с ними и ретраи) будут неизбежны.
                  –1

                  Сколько можно делать retry? Где-то несколько секунд, ну секунд 20 максимум, после этого пользователю запрос уже не интересен. Из ошибок, которые сами быстро исправляются я могу придумать только рестарт сервиса. Какие конкретно ошибки в вашей системе вы обходите с помощью повторов запроса?

                    +1
                    Несколько секунд, а тем более 20, это довольно значительное время даже по меркам http-запросов.
                    Я привёл выше ошибки с eventually consistency, когда информация, нужная для запроса, еще не успела записаться в БД. Ошибки блокировки записей в БД.
                    И не забывайте, что речь идёт не только о взаимодействии пользователя с системой, а о распределенной системе, где сервисы взаимодействуют между собой в том числе через http-запросы. Если Вы думаете, что в интернет-магазине весь процесс заканчивается, после того, как пользователь нажал на кнопку «Заказать», то Вы сильно ошибаетесь.
                      +5
                      Тут везде ответ: it depends.

                      Про кейсы.
                      Ну например, мы послали запрос в пулл серверов и получили ответ 429. Что это означает? Это означает, что конкретный инстанс сейчас перегружен, ему тяжело. Это не значит, что соседний инстанс не сможет обработать запрос.
                      Таймаут мы можем получить опять же по причине утилизации коннекшенов.
                      Кейс с рестартом сервиса тоже валидный, так как по разным причинам такое может случиться.

                      Про количество и время.
                      Опять же, вопрос что с чем взаимодействует. Например, у нас есть платежный шлюз и есть эквайреры, у которых заложено очень большое время на ответ. И ты вынужден, во-первых, долго ждать, во-вторых, очень хочется прийти в консистентное состояние в результате взаимодействия.
                        +1
                        Экваеры и платежи это мне близко — я пишу торговых роботов, там тоже retry на каждом шагу, потому что биржи и всякие API находятся далеко и имеют тенденцию отваливаться слишком часто. И кажется это как раз сценарии, на которые polly заточена и где все эти retry и CB нужны и оправданы.

                        За пример с пулом серверов спасибо. Пожалуй пока это единственный сценарий, когда retry оправдан в рамках обработки пользовательского запроса, а не какой-то background job.
                          0

                          Кстати, а почему 429, а не 503?


                          429 означает же, что конкретный клиент сделал слишком много запросов, а перегрузка чаще возникает когда клиентов много.

                            0
                            У них немножко разная природа. 503 тоже считается транзиентной ошибкой и тоже ретраится.

                            429 это же клиентский код, это значит, что сервер может его использовать пока он еще в состоянии обрабатывать запросы, но ему уже сложно. Например, когда вы используете bulkhead на стороне сервера, и намеренно обрубаете запросы, которые не влезают в очередь.
                            А 503 – это уже как бы последняя стадия. Не смогла, так не смогла.
                      0
                      Далеко не все сервисы могут нормально понимать что им плохеет из-за большого кол-ва запросов и отсылать 429. Чтобы сервис так работал, надо еще постараться. А в многих случаях из загруженного сервиса валятся 503 или еще чего хуже. И что там будет валиться сложно предугадать и можно попробовать на перформанс тестах отловить, но все очень вариативно.
                      Потому что «синхронность» запроса она очень условная и внутри запрос разойдется на кучу потоков, могуть создаться деад-локи, может заблочится доступ к файлам, может какой-нибудь сервис начать отбивать ошибку перегрузки, а может тупо отваливаться по таймауту. Очередь может забиться и перестать принимать сообщений и т.д.
                        0
                        Все правда. В частности, мы специально работали над тем, чтобы некоторые наши сервисы умели отдавать 429.
                          –1
                          Маленькое добавление:
                          Из своих вариантов: 404 (балансировщик выкинул все ноды сервиса, нет ни одной доступной), таймаут соединения, 502 (в балансировщике сервис есть — по факту не в состоянии ответить ), etc
                        +1
                        Такие политики работают как обёртка над стандартным HttpClient’ом.

                        Скорее "плагин", а не обертка. Под оберткой обычно имеют ввиду агрегирование одного класса другим

                          0
                          Да, пожалуй, вы правы. С терминологией у меня вообще тут сложно получается местами.
                          +1
                          При работе с таймаутами в распределенных системах еще возникает вот такая коллизия:

                          Есть сервис, который выполняет какие-то операции и вызывает другой сервис. Этот сервис тоже выполняет операции и вызывает сервис. Наконец, последний сервис просто что-то делает. У каждой операции в пайплайне есть свой таймаут, например 10 секунд. Если собственные операции каждого сервиса будут работать по 4 секунды, то на гейтвее может случиться ошибка таймаута, хотя каждый сервис отработал за корректное время. Вы как-то решаете подобные проблемы?
                            0
                            Вы правы, хорошее замечание, такая проблема существует. И, пожалуй, неправильно выбирать какой-то константный таймаут для любого сервиса вне зависимости от архитектуры вашего решения. Сейчас мы больше опираемся на здравый смысл с выбором таймаутов в разных сервисах. Какого-то эффективного механизма или формулы правильной настройки таймаутов я пока не знаю.
                              +1
                              Ну например в гайдлайнах некоторых советуют использовать паттерн Deadline для распределенных таймаутов: www.datawire.io/guide/traffic/deadlines-distributed-timeouts-microservices

                              Но вообще несмотря на то, что Polly — это прекрасная библиотека, вам наверное стоит пересмотреть идею использовать ее для CircuitBreaker'а. Раз у вас SOA, то вы наверное используете горизонтальное масштабирование, и вряд ли вы сможете шарить состояние CB между двумя инстансами одного сервиса. Возможно, целый ряд ваших проблем с распространением ошибки решат Hystrix или Istio, но CB-то уж точно стоит туда отправить.
                                0
                                Спасибо за ссылку на гайдлайн.

                                У нас сейчас действительно CB на уровне отдельных инстансов и, как правило, в логах можно наблюдать, как они практически одновременно открываются на каждом из инстансов. Да, я чуть-чуть смотрел на Istio, согласен, что в случае горизонтального масштабирования это выглядит лучше. Спасибо за ваш совет.
                            +1
                            Интересно было читать

                              0
                              Спасибо!

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

                            Самое читаемое