Padrões de Resiliência em C#: Explorando Polly, Circuit Breaker e Idempotência

C# 4 de Dez de 2024

Polly

O Polly é uma biblioteca popular no ecossistema .NET que ajuda a implementar políticas de resiliência e tolerância a falhas em sistemas, permitindo que os erros sejam tratados de forma mais inteligente. Com o Polly, podemos aplicar as seguintes estratégias:

Retry (Repetir)

  • Permite repetir uma operação quando ocorre uma falha temporária. Por exemplo, se uma API retornar um erro 5.x.x, o Polly poderá realizar uma nova tentativa após um intervalo definido.

Circuit Breaker (Disjuntor)

  • Monitora falhas sucessivas e "abre o circuito" para evitar sobrecarregar um serviço que já está com problemas. Após um intervalo definido, o circuito "fecha", e novas tentativas são realizadas. É útil para serviços instáveis. Essa estratégia funciona como um disjuntor que, ao identificar uma sobrecarga na rede elétrica da sua casa, desarma para evitar danos aos equipamentos.

Hedging

  • Envia solicitações redundantes para diversas instâncias de um serviço com o objetivo de obter a resposta mais rápida e as demais são descartadas.

Timeout

  • Refere-se a um tempo máximo que uma solicitação pode levar até ser concluída.

Rate Limiter (Limitador de taxa)

  • Controla o número de solicitações que podemos realizar em um determinado tempo. Isso nos ajuda a evitar que sobrecarregamos o serviço que estamos utilizando.

Fallback

  • É uma abordagem que define uma ação secundária a ser tomada quando a operação principal falha. Isso pode incluir retornar valores padrão, usar dados em cache ou redirecionar a solicitação para um serviço alternativo.

Idempotência

Idempotência é um conceito da matemática e da ciência da computação que se refere à possibilidade de uma operação ser aplicada várias vezes sem que o resultado se altere.

Quando falamos sobre idempotência em API’s REST, estamos dizendo sobre os métodos HTTP. Mas o que é um método HTTP idempotente? Segundo a documentação da Mozilla

Um método HTTP é idempotente se o efeito pretendido no servidor de fazer uma única solicitação for o mesmo que o efeito de fazer várias solicitações idênticas.

De forma resumida, é a capacidade de uma operação em uma API ser executada várias vezes e produzir sempre o mesmo resultado.

Verbos HTTP

  • GET é idempotente porque sempre irá retornar o mesmo recurso e também não altera nenhum estado no servidor.
  • PUT é idempotente porque a cada solicitação feita, sempre os mesmo dados serão alterados.
  • DELETE é idempotente porque, ao realizarmos a primeira chamada do método e removermos o dado, retornaremos um status code 200 ou 204 e caso realizarmos a segunda chamada, o retorno será o mesmo, pois não existe mais o dado no banco.
  • HEAD e OPTIONS são idempotentes.
  • O POST não é idempotente porque cada requisição gera uma nova operação, gerando diferentes estados no servidor. Por exemplo, enviar uma requisição POST para criar um novo usuário criará um registro distinto a cada solicitação.
Método HTTP Idempotente? Motivo
GET Sim Não altera o estado do recurso e retorna sempre o mesmo dado.
PUT Sim Sempre atualiza ou cria o recurso com o mesmo estado final.
DELETE Sim Após a remoção inicial, subsequentes requisições não alteram o estado.
POST Não Cada requisição cria um novo recurso ou altera o estado.
HEAD Sim Similar ao GET, mas retorna apenas os cabeçalhos.
OPTIONS Sim Apenas consulta as opções de comunicação com o servidor.

Implementação

1º - Crie um projeto API Web do ASP.NET Core e salve-o na pasta de sua preferência:

2º - Mantenha as configurações padrão:

3º - Instale o pacote Microsoft.Extensions.Http.Polly em sua API:

Agora, criaremos uma classe para abstrair a injeção de dependência do nosso cliente HTTP e também da configuração do Polly.

Crie a seguinte estrutura em sua API:

📦Api
 ┣ 📂Extensions
 ┃ ┣ 📝HttpClients.cs

Adicione o seguinte código ao arquivo HttpClients.cs:

internal static class HttpClients
{
    public static void AddClientsFactory(this WebApplicationBuilder builder)
    {
        const int retryCount = 3;
        HttpMethod[] idempotentMethod = { HttpMethod.Get, HttpMethod.Put, HttpMethod.Delete };

        builder.Services.AddHttpClient("Api-Externa", client =>
        {
            client.BaseAddress = new Uri("<https://postman-echo.com>");

        }).AddPolicyHandler(Policy<HttpResponseMessage>
          .Handle<HttpRequestException>()
          .OrResult(r => !r.IsSuccessStatusCode)
          .CircuitBreakerAsync(retryCount, TimeSpan.FromMinutes(1),
             onBreak: (outcome, breakDelay) =>
             {
                 Console.WriteLine($"Circuito aberto devido a {retryCount} falhas consecutivas.");
             },
             onReset: () =>
             {
                 Console.WriteLine("Circuito fechado. Retomando tentativas.");
             })).AddPolicyHandler(request =>
               {

                   if (idempotentMethod.Contains(request.Method))
                   {
                       return Policy<HttpResponseMessage>
                           .Handle<HttpRequestException>()
                           .OrResult(r => !r.IsSuccessStatusCode)
                           .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
                                              onRetry: (outcome, timespan, retryCount, context) =>
                                              {
                                                  Console.WriteLine($"Retry {retryCount} falhou. Tentando novamente em {timespan}.");
                                              });
                   }

                   return Policy.NoOpAsync<HttpResponseMessage>();
               });
    }
}

Em Program.cs, chame o método:

/*...*/
builder.AddClientsFactory();
/*...*/

Logo após adicione a seguinte rota:

app.MapGet("/retry-circuit-breaker", async ([FromServices] IHttpClientFactory httpClientFactory) =>
{

    try
    {
        var client = httpClientFactory.CreateClient("Api-Externa");

        var response = await client.GetAsync("/status/500");

        if (response.IsSuccessStatusCode)
        {
            var content = await response.Content.ReadAsStringAsync();
            return Results.Ok(new
            {
                Message = "Requisição bem-sucedida!",
                Data = content
            });
        }
        else
        {
            return Results.Json(new
            {
                Message = "Falha ao processar a requisição.",
                StatusCode = response.StatusCode,
            }, statusCode: (int)response.StatusCode);
        }

    }
    catch (BrokenCircuitException)
    {
        return Results.Json(new
        {
            Message = "Circuito aberto: o serviço está indisponível temporariamente. Por favor, tente novamente mais tarde."
        }, statusCode: 503);
    }
    catch (Exception ex)
    {
        return Results.Problem(title: "Erro Interno", detail: ex.Message, statusCode: 500);
    }

})
    .WithName("RetryCircuitBreaker")
    .WithOpenApi();

Ao realizar uma requisição, veremos nos logs o retry junto com o circuit-breaker atuando no caso de falha.

Explicação do código

Configurando o cliente HTTP

 builder.Services.AddHttpClient("Api-Externa", client =>
  {
     client.BaseAddress = new Uri("https://postman-echo.com");
  })

No trecho de código acima, começamos a definir nosso cliente HTTP que será nomeado como Api-Externa e também a URL que vamos usar. A API do Postman fornece para nós algumas rotas que podemos usar para testes. Segue o link da documentação:

Postman

Configurando o Retry

.AddPolicyHandler(request =>
        {
            if (idempotentMethod.Contains(request.Method))
            {
                return Policy<HttpResponseMessage>
                    .Handle<HttpRequestException>()
                    .OrResult(r => !r.IsSuccessStatusCode)
                    .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
                                       onRetry: (outcome, timespan, retryCount, context) =>
                                       {
                                           Console.WriteLine($"Retry {retryCount} falhou. Tentando novamente em {timespan}.");
                                       });
            }

            return Policy.NoOpAsync<HttpResponseMessage>();
        })

Aqui começamos a configurar o retry. No método WaitAndRetryAsync definimos a quantidade de repetição que será realizada até o circuito abrir e também o tempo que será feito as retentativas. Neste caso foi definido um tempo exponencial que vai começar com 2 segundos depois 4 e assim sucessivamente. Essa abordagem é chamada de backoff exponencial.

E qual é a intenção de adicionar essa validação de métodos idempotentes?

if (idempotentMethod.Contains(request.Method)){
//...

A ideia é que o retry seja executado apenas em métodos que são idempotentes, pois imagine executando uma retentativa em um POST, a requisição chega até a API, ela processa 50% e lança uma exception, o retry imediatamente irá enviar uma nova requisição e isso pode gerar efeitos colaterais criando dados duplicados. Abaixo há um exemplo de como seria o efeito colateral:

// POST sem idempotência
POST /orders
{
"id": 1,
"produto": "Notebook",
"quantidade": 1
}

// Resultado:
{
"orderId": 101
}

// Retry automático sem idempotência gera:
{
"orderId": 102 // Pedido duplicado
}

Lembre-se que estamos tratando essa situação como um consumidor e não como o fornecedor da API. Existe uma abordagem para métodos que não são idempotentes que seria o cliente enviar uma chave identificando a requisição e a API que está sendo consumida tratar da forma que independente das request solicitadas não tenha efeitos colaterais como duplicação. Fique a vontade para pesquisar por Idempotency-Key.

Configurando o circuit-breaker

Já nesse trecho de código abaixo, vamos configurar o circuit-breaker. No método CircuitBreakerAsync definimos após quantas retentativas o circuito vai abrir e por quanto tempo ficará aberto. Aqui, é importante observar que o circuit-breaker tem três estados:

  • Aberto
  • Meio aberto
  • Fechado

Quando passa do tempo definido na configuração, ele sai do estado fechado para meio aberto que significa que ele irá liberar parcialmente as requisições e se caso alguma retornar sucesso ele troca o estado para aberto:

AddPolicyHandler(Policy<HttpResponseMessage>
            .Handle<HttpRequestException>()
            .OrResult(r => !r.IsSuccessStatusCode)
            .CircuitBreakerAsync(retryCount, TimeSpan.FromMinutes(1),
                                 onBreak: (outcome, breakDelay) =>
                                 {
                                     Console.WriteLine($"Circuito aberto devido a {retryCount} falhas consecutivas.");
                                 },
                                 onReset: () =>
                                 {
                                     Console.WriteLine("Circuito fechado. Retomando tentativas.");
                                 }));
    }

Resumo

  • Polly nos fornece ferramentas para lidar com erros transientes e também pode nos causar sérios problemas caso não tenhamos em consciência a idempotência dos métodos.
  • Métodos idempotentes garante que o estado final do recurso seja o mesmo após múltiplas requisições.
  • Circuit-breaker tem 3 estados: aberto, meio-aberto e fechado.
    • Quando dizemos que o circuito está aberto, significa que estamos impedido de realizar novas request, quando está fechado, estamos liberado realizar uma nova tentativa.

Referências

Idempotent - MDN Web Docs Glossary: Definitions of Web-related terms | MDN
An HTTP method is idempotent if the intended effect on the server of making a single request is the same as the effect of making several identical requests.
RFC9110
HTTP Semantics

Marcadores