logo

Patrón Circuit-Breaker desde .NET 10


Dentro de esta publicación voy a presentar un método para implementar el patrón Circuit Breaker en servicios HTTP remotos utilizando .NET.

Para quienes no lo conozcan, el patrón Circuit Breaker es sencillo de describir, algo más complejo de implementar, pero resulta especialmente útil cuando se trabaja con servicios remotos de cualquier tipo. Este patrón fue introducido por primera vez en el libro Release It! publicado por Michael Nygard, cuya lectura es altamente recomendable.

Dado que aquí nos vamos a centrar en la implementación, puede consultarse más información en el libro mencionado o en la página de Microsoft Learn. A grandes rasgos, el patrón responde a un comportamiento similar al que se muestra en el siguiente diagrama:

Al código!

Nota: debemos tener en cuenta que el siguiente código está orientado a proporcionar una clase base para realizar llamadas a servicios HTTP remotos. Podríamos implementar el patrón Circuit Breaker para otros tipos de servicios no HTTP, como una base de datos o un repositorio de archivos, pero ese no es el objetivo de esta publicación.

Ahora, revisemos el código.

public interface ICircuitBreaker {

    HttpStatusCode? LastRequestStatusCode { get; }
    CircuitBreakerStatus Status { get; }

    TimeSpan OpenToHalfOpenThresold { get; }
    int HalfOpenToCloseThresold { get; }
    CircuitBreakerThresold CloseToOpenThresold { get; }

    void Reset();
}

Esta interfaz define las propiedades y métodos básicos del patrón que todos los servicios deben implementar. Posteriormente, se definirá una clase base abstracta que servirá como implementación común, con el fin de evitar la repetición de código en nuestras aplicaciones.

El método Reset() se recomienda como una funcionalidad útil para forzar manualmente el cambio de estado de los servicios desde Open a Close en caso de que queden bloqueados en dicho estado.

public abstract class CircuitBreakerBase : ICircuitBreaker 
{
    Semaphore _semaphore = new Semaphore(1, 1);
    CircuitBreakerStatusController _currentStatusController;
    ILogger? _logger;

    public CircuitBreakerBase(TimeSpan openToHalfOpenThresold,
        int halfOpenToCloseThresold, 
        CircuitBreakerThresold closeToOpenThresold, 
        string serviceName, ILogger? logger = null)
    {
        OpenToHalfOpenThresold = openToHalfOpenThresold;
        HalfOpenToCloseThresold = halfOpenToCloseThresold;
        CloseToOpenThresold = closeToOpenThresold;
        ServiceName = serviceName;
        _logger = logger;
        _currentStatusController = new CircuitBreakerStatusController(openToHalfOpenThresold, halfOpenToCloseThresold, closeToOpenThresold, StatusChanged);
    }

    public HttpStatusCode? LastRequestStatusCode => _currentStatusController.LastRequestStatusCode;
    public CircuitBreakerStatus Status => _currentStatusController.Status;
    public TimeSpan OpenToHalfOpenThresold { get; private set; }
    public int HalfOpenToCloseThresold { get; private set;  }
    public string ServiceName { get; private set; }
    public CircuitBreakerThresold CloseToOpenThresold { get; private set; }

    protected async Task<CircuitBreakerResponse> ExecuteInCircuitBreaker(Func<Task<HttpResponseMessage>> functionToExecute)
    {
        _semaphore.WaitOne();

        if (!_currentStatusController.CanBeInvoked())
            return CircuitBreakerResponse.Opened();

        HttpResponseMessage response;
        try
        {
            response = await functionToExecute();
            _currentStatusController.CheckResponse(response);
        } 
        catch (Exception excep)
        {
            _currentStatusController.AddErrorResponse();
            throw excep;
        }
        finally
        {
            _semaphore.Release();
        }

        return new CircuitBreakerResponse(response, _currentStatusController.Status);
    }

    public void Reset() => _currentStatusController.Reset();

    private void StatusChanged(CircuitBreakerStatus fromStatus, CircuitBreakerStatus toStatus) {
        _logger?.Log(
            toStatus != CircuitBreakerStatus.Open ? LogLevel.Information : LogLevel.Critical,
            $"[Circuit Breaker] {ServiceName} service moved from {fromStatus} to {toStatus} status."
        );
    }

}

La clase base para la implementación de la interfaz ICircuitBreaker simplemente añade métodos que permiten a las clases hijas que hereden de ella ejecutar operaciones asíncronas e implementar las propiedades definidas en su clase padre.

Siguiendo los principios SOLID, vamos a implementar esta clase con la única responsabilidad de encolar las llamadas (únicamente cuando el estado sea distinto de Open), sin ocuparse de la gestión de los diferentes estados que requiere el patrón Circuit Breaker para funcionar.

La clase necesita cinco parámetros para operar:

Todos estos parámetros se almacenarán en las propiedades del objeto y se pasarán como argumentos al constructor de CircuitBreakerStatusController.

Dentro del método ExecuteInCircuitBreaker se recibirá como parámetro una función asíncrona que debe devolver un objeto HttpResponseMessage. El servicio invocará esta función encolando las distintas llamadas bajo un enfoque FIFO (First In, First Out).

Este comportamiento puede afectar al rendimiento del servicio, por lo que será necesario decidir cuidadosamente cómo implementar esta clase en nuestras aplicaciones.

internal class CircuitBreakerStatusController(
    TimeSpan openToHalfOpenThresold, 
    int halfOpenToCloseThresold, 
    CircuitBreakerThresold closeToOpenThresold,
    Action<CircuitBreakerStatus, CircuitBreakerStatus>? statusChangedCallback = null)
{
    CircuitBreakerStatus _status = CircuitBreakerStatus.Close;

    public HttpStatusCode? LastRequestStatusCode { get; private set; }
    public CircuitBreakerStatus Status { get { CheckCurrentStatus(); return _status; } private set { _status = value; } }
    public DateTimeOffset? SetTime { get; private set; }

    public FixedSizeQueue<CircuitBreakerStatusControllerRequest> _lastFailedRequestsQueue =
        new FixedSizeQueue<CircuitBreakerStatusControllerRequest>(closeToOpenThresold.NumberOfFailures);

    int _halfOpenSuccessfulCalls = 0;

    #region Public surface

    public bool CanBeInvoked()
    {
        CheckCurrentStatus();
        return _status != CircuitBreakerStatus.Open;
    }
    public void AddErrorResponse()
    {
        if (_status == CircuitBreakerStatus.HalfOpen)
            SetCurrentStatus(CircuitBreakerStatus.Open);
        else
        {
            _lastFailedRequestsQueue.Enqueue(CircuitBreakerStatusControllerRequest.CreateError());
            if (ShouldMoveFromCloseToOpen())
                SetCurrentStatus(CircuitBreakerStatus.Open);
        }
    }

    public void CheckResponse(HttpResponseMessage responseMessage)
    {
        LastRequestStatusCode = responseMessage.StatusCode;
        if (responseMessage.IsSuccessStatusCode)
            AddSuccessfulResponse();
        else
            CheckErrorResponse(responseMessage);
    }

    public void Reset() => SetCurrentStatus(CircuitBreakerStatus.Close);

    #endregion

    #region Private surface

    private void SetCurrentStatus(CircuitBreakerStatus status)
    {
        statusChangedCallback?.Invoke(_status, status);
        Status = status;
        SetTime = DateTimeOffset.Now;
    }

    private void MoveToHalfOpen()
    {
        SetCurrentStatus(CircuitBreakerStatus.HalfOpen);
        _halfOpenSuccessfulCalls = 0;
    }

    private void CheckCurrentStatus()
    {
        if (_status == CircuitBreakerStatus.Open &&
            SetTime.HasValue && SetTime.Value.Add(openToHalfOpenThresold) < DateTimeOffset.Now)
            MoveToHalfOpen();
    }

    private void AddSuccessfulResponse()
    {
        if (_status == CircuitBreakerStatus.HalfOpen)
            _halfOpenSuccessfulCalls++;

        if (_halfOpenSuccessfulCalls >= halfOpenToCloseThresold)
            SetCurrentStatus(CircuitBreakerStatus.Close);
    }

    private bool ShouldMoveFromCloseToOpen() => _lastFailedRequestsQueue.Full &&
        _lastFailedRequestsQueue.All(it => !it.Status && it.When.Add(closeToOpenThresold.TimeThresold) >= DateTimeOffset.Now);

    private void CheckErrorResponse(HttpResponseMessage responseMessage)
    {
        if (responseMessage.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
            SetCurrentStatus(CircuitBreakerStatus.Open);
        else if ((int)responseMessage.StatusCode >= 500)
            AddErrorResponse();
    }

    #endregion

    internal class CircuitBreakerStatusControllerRequest(DateTimeOffset when, bool status)
    {
        public DateTimeOffset When => when;
        public bool Status => status;

        public static CircuitBreakerStatusControllerRequest CreateSuccessful() => new CircuitBreakerStatusControllerRequest(DateTimeOffset.Now, true);
        public static CircuitBreakerStatusControllerRequest CreateError() => new CircuitBreakerStatusControllerRequest(DateTimeOffset.Now, false);
    }
}

El CircuitBreakerStatusController tendrá la responsabilidad de gestionar los cambios de estado entre los diferentes estados posibles. La lógica aplicada en su interior será la siguiente:

Implementación

Es momento de implementar algunos ejemplos con este patrón y comenzar a utilizarlo. El servicio remoto elegido para el ejemplo es el Open Data proporcionado por la página zaragoza.es, una API REST gratuita que permite consultar prácticamente todos los indicadores medibles disponibles en la ciudad.

internal interface IWaterTreatmentPlantService : ICircuitBreaker
{
    Task<List<OriginByDate>?> GetOriginsByDates();
}

La interfaz heredará de ICircuitBreaker y definirá los métodos personalizados para este servicio. En este ejemplo, se trata de obtener los diferentes orígenes de agua de la ciudad durante un período de tiempo, desglosados por días.

public class WaterTreatmentPlantService : CircuitBreakerBase, IWaterTreatmentPlantService
{
    static HttpClient HTTP_CLIENT = new HttpClient();

    public WaterTreatmentPlantService(ILogger<WaterTreatmentPlantService> logger) :
        base(TimeSpan.FromMinutes(1), 3,
            new CircuitBreakerThresold(3, TimeSpan.FromMinutes(5)),
            "WaterTreatmentPlan", logger)
    {
    }

    public async Task<List<OriginByDate>?> GetOriginsByDates()
    {
        var result = new List<OriginByDate>();
        var response = await ExecuteInCircuitBreaker(() =>
        {
            return HTTP_CLIENT.GetAsync("https://www.zaragoza.es/sede/servicio/potabilizadora/procedencia.json");
        });

        if (response.IsSuccess)
        {
            var stringData = await response.ResponseMessage!.Content.ReadAsStringAsync();
            var data = JsonSerializer.Deserialize<object[][]>(stringData);

            Dictionary<int, string> headers = new();

            for (int i = 0; i < data[0].Length; i++)
            {
                var header = data[0][i];
                var stringName = ((JsonElement)header).GetString();

                if (stringName != "X")
                {
                    headers.Add(i, stringName);
                }
            }

            data.Skip(1).ToList().ForEach(it =>
            {
                if (DateTime.TryParseExact(((JsonElement)it[0]).GetString(), "dd-MM-yyyy", null, System.Globalization.DateTimeStyles.None, out var dt))
                {
                    result.Add(new OriginByDate
                    {
                        Date = dt,
                        Distribution = headers.ToDictionary(head => head.Value, head => ((JsonElement)it[head.Key]).GetInt16())
                    });
                }
            });
        }

        return result;
    }
}

El método heredará de la clase base CircuitBreakerBase y posteriormente implementará la interfaz IWaterTreatmentPlantService. Como podemos observar, obtendremos la respuesta del servicio utilizando el método protegido ExecuteInCircuitBreaker, definido dentro de la clase base, que será gestionado por el patrón.

El resto del método se comporta como debería hacerlo cualquier llamada a un endpoint HTTP RESTful remoto: obtener la respuesta en formato cadena y deserializarla en un objeto legible por la aplicación.

Nota: Este es un ejemplo pequeño y muy sencillo. En un entorno real (RealLife™) deberíamos implementar estas clases base con mayor cuidado, teniendo en cuenta lo siguiente:

Reflexiones finales

Este patrón constituye un buen punto de partida cuando trabajamos con servicios remotos en nuestras aplicaciones, pero debería combinarse con otros patrones como Retry, Health Endpoint Monitoring o el Dependency Injection Pattern para lograr una solución más robusta y completa.