Паттерны: Retry vs Circuit Breaker

Сегодня хочется рассказать про два интересных паттерна: Retry и Circuit Breaker. На первый взгляд они очень похожи, но используются для решения совершенно разных проблем.

Retry

Контекст и проблема

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

Решение

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

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

Пример

Удаленный сервис:

public interface ITransactionService
{

    void SendMoney(int sum);

}
public class TransactionService : ITransactionService
{
    private readonly Random _random = new Random();

    public void SendMoney(int sum)
    {

        if (_random.Next(3) == 0)
            throw new HttpException("Network problems...");        
        Console.WriteLine($"Money sent. Sum {sum}");
    }
}

Код приложения:

class Program
{
    private static readonly int _retryCount = 3;

    static void Main(string[] args)
    {
        RetryPatternTest();

    }
    private static void RetryPatternTest()
    {
        var service = GetService();
        var currentRetry = 0;

        do
        {
            try
            {
                currentRetry++;
                service.SendMoney(100); //try to call remote service
                break;
            } 
            //if our exception is transient and we don't exceed retry attempts
            //we just log exception and try again
            catch (Exception ex) when (currentRetry <= _retryCount && IsTransient(ex))
            {
                Trace.WriteLine(ex);
            }

            //small delay between attempts
            Thread.Sleep(300);
        } while (true);        
        Console.WriteLine("Operation complete");
        Console.WriteLine($"Attempts: {currentRetry}");
    }
    private static bool IsTransient(Exception ex)
    {
        //check if Exception is transient
        return ex is HttpException;
    }    
    private static ITransactionService GetService()
    {
        return new TransactionService();
    }
}

Код, который вызывает удаленный сервис заключен в блоке try-catch внутри цикла. Цикл будет завершен, если метод service.SendMoney будет завершен без ошибки. Если метод выкинет исключение, то блок catch проверит причину ошибки, и если она временная и количество попыток не исчерпано, то залогирует ошибку и предпримет еще одну попытку вызвать метод после небольшой задержки. Метод IsTransient проверяет ошибку и может отличаться в зависимости от окружения и других условий.Так же, классически этот паттерн применяется при решении проблемы оптимистического параллелизма при работе с базой данных через Entity Framework:

var retryCount = 3;
var currentRetry = 0;

using (var context = new DbContext(ConnectionString))
{
    var user = context.Set<user>().First(o => o.Id == 1);
    user.Login = "newuserlogin";
    do
    {
        try
        {
            currentRetry++;
            context.SaveChanges();
            break;
        }
        catch (DbUpdateConcurrencyException ex) when (currentRetry <= retryCount)
        {
            var entry = ex.Entries.Single();
            entry.OriginalValues.SetValues(entry.GetDatabaseValues());
        }
    } while (true);
}

### Когда использовать

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

Когда не использовать

  1. Ошибки имеют долговременный характер, и приложение будет бесполезно тратить ресурсы на попытки повторить операции
  2. Для обработки не временных ошибок (ошибки бизнес-логики приложения)
  3. В качестве альтернативы для решения проблем масштабирования. Если сервис слишком часто сигнализирует о том, что он "занят", то скорее всего он требует больше ресурсов

Circuit Breaker

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

Контекст и проблема

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

Решение

Паттерн Circuit Breaker предотвращает попытки приложения выполнить операцию, которая скорее всего завершится неудачно, что позволяет продолжить работу дальше не тратя важные ресурсы, пока известно, что проблема не устранена. Этот паттерн так же позволяет приложению отслеживать, была ли решена проблема. И если это так, то приложение может попытаться повторить операцию. Circuit Breaker выступает как прокси-сервис между приложением и удаленным сервисом. Прокси-сервис мониторит последние возникшие ошибки, для определения, можно ли выполнить операцию или просто сразу вернуть ошибку. Прокси-сервис может быть реализован в виде конечного автомата со следующими состояниями:

  1. Closed: запрос от приложения направляется напрямую к сервису. Прокси увеличивает счетчик ошибок, если операция завершилась не успешно. Если количество ошибок за некоторые временной интервал превышает заранее заданное значение, то прокси-сервис переходит в состояние Open и запускает таймер. Когда таймер истекает, прокси переходит в состояние Half-Open. Назначение таймера - дать сервису время для решения проблемы, прежде чем позволить приложению заново отправлять запросы.
  2. Open: запрос от приложения немедленно завершает с ошибкой.
  3. Half-Open: ограниченному количеству запросов от приложения разрешено обратиться к сервису. Если эти запросы успешны, то считаем что предыдущая ошибка исправлена и прокси-сервис переходит в состояние Closed (счетчик ошибок сбрасывается на 0). Если любой из запросов завершился ошибкой, то считаем, что ошибка все еще присутствует, тогда прокси возвращается в состояние Open и перезапускает таймер. Состояние Half-Open помогает предотвратить быстрый рост запросов к сервису. Т.к. после начала работы сервиса, некоторое время он может быть способен обрабатывать ограниченное число запросов до полного восстановления.

Пример

Удаленный сервис, который может выдавать постоянную ошибку продолжительное время (эмуляция тайм-аута):

public interface ITransactionService
{
    void SendMoney(int sum);
}

public class TransactionService : ITransactionService
{
    private readonly Random _random = new Random();
    private static int _counter = 0;

    public void SendMoney(int sum)
    {
        _counter++;

        Thread.Sleep(1000);

        if (_counter > 5 && _counter < 10)
        {
            Thread.Sleep(4000); //timeout exception
            throw new HttpException("Network problems...");
        }        
        Console.WriteLine($"Money sent. Sum {sum}");
    }
}

Класс Circuit Breaker:

public enum CircuitBreakerState
{
    Closed,
    Open,
    HalfOpen
}

public class CircuitBreaker
{
    private const int ErrorsLimit = 3; //errors count limit
    private readonly TimeSpan _openToHalfOpenWaitTime = TimeSpan.FromSeconds(10); //time to wait for half open state change
    private int _errorsCount; //current errors count
    private CircuitBreakerState _state = CircuitBreakerState.Closed;
    private Exception _lastException;
    private DateTime _lastStateChangedDateUtc;
    
    private void Reset()
    {
        _errorsCount = 0;
        _lastException = null;
        _state = CircuitBreakerState.Closed;
    }    
    
    private bool IsClosed => _state == CircuitBreakerState.Closed;
    
    public void ExecuteAction(Action action)
    {
        //state == Closed
        if (IsClosed)
        {
            try
            {
                //pass action to service
                action();
            }
            catch (Exception ex)
            {
                //error occured, increment error counter and set last error
                TrackException(ex);
                //pass exception to application
                throw;
            }
        }
        else
        {
            //check if proxy is Half-Open
            //or if state is Open and timer expired
            if (_state == CircuitBreakerState.HalfOpen || IsTimerExpired())
            {
                _state = CircuitBreakerState.HalfOpen;                //try to execute action
                try
                {
                    action();
                }
                catch(Exception ex)
                {
                    Reopen(ex);
                    throw;
                }
                //reset proxy state, if no error occured
                Reset();
                return;
            }
            //if state == Open, just pass last error to application
            throw _lastException;
        }
    }
    
    private void Reopen(Exception exception)
    {
        _state = CircuitBreakerState.Open;
        _lastStateChangedDateUtc = DateTime.UtcNow;
        _errorsCount = 0;
        _lastException = exception;
    }
    
    private bool IsTimerExpired()
    {
        return _lastStateChangedDateUtc + _openToHalfOpenWaitTime < DateTime.UtcNow;
    }
    
    private void TrackException(Exception exception)
    {
        _errorsCount++;

        if (_errorsCount >= ErrorsLimit)
        {
            _lastException = exception;
            _state = CircuitBreakerState.Open;
            _lastStateChangedDateUtc = DateTime.UtcNow;
        }
    }
}

Код прокси-сервиса:

public class TransactionServiceProxy : ITransactionService
{
    private readonly ITransactionService _service = new TransactionService();
    private readonly CircuitBreaker _circuitBreaker = new CircuitBreaker();
    public void SendMoney(int sum)
    {
        _circuitBreaker.ExecuteAction(() => _service.SendMoney(sum));
    }
}

Код вызывающего приложения:

{
    static void Main(string[] args)
    {
        var service = GetService();
        for (int i = 0; i < 100; i++)
        {
            var sw = Stopwatch.StartNew();
            try
            {
                service.SendMoney(100);
            }
            catch (Exception ex)
            {
                Console.WriteLine("Error occured. Wait fo 500 milliseconds");
                Thread.Sleep(500);
            }
            finally
            {
                sw.Stop();
                Console.WriteLine($"Elapsed time: {sw.ElapsedMilliseconds}");
            }
        }    
    }    

    private static ITransactionService GetService()
    {
        return new TransactionServiceProxy();
    }

}

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

Когда использовать

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

Когда не использовать

  1. Для обращения к приватным ресурсам приложения - это даст только дополнительный overhead на выполнение операции
  2. Как замена обработки исключений бизнес-логики

Ссылки

  1. Retry Pattern
  2. Circuit Breaker Pattern
  3. Entity Framework Optimistic Concurrency Pattern
  4. Retry guidance for specific services
  5. Polly - .NET resilience and transient-fault-handling library that allows developers to express policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, and Fallback in a fluent and thread-safe manner
blog comments powered by Disqus