Aumentado a performance do HttpClient
Tags: dotnet, dotnetcore, performance
Published: 2023-03-09 12:41:02

Muitas vezes escrevemos códigos de forma automática, sem considerar possíveis variações que poderiam melhorar a performance.

Em um mundo cada vez mais dependente de APIs, em que os sistemas se conectam principalmente por meio do protocolo HTTP, saber usar o HttpClient pode ser uma grande vantagem e contribuir significativamente para um sistema mais performático.

O HttpClient é uma classe fundamental para a comunicação HTTP em aplicativos .NET. Ele fornece uma maneira fácil de enviar solicitações HTTP e receber respostas HTTP. No entanto, o HttpClient tem um comportamento padrão de carregar todo o conteúdo da resposta HTTP em memória antes de retornar a resposta como um objeto HttpResponseMessage, ou seja, a resposta é lida para um MemoryStream e só depois é consumida pelos métodos "padrões" que utilizamos (GetAsync e SendAsync).
Isso pode ser problemático quando a resposta HTTP contém uma grande quantidade de dados.

Felizmente, o HttpClient também permite que você trabalhe com fluxos (streams) em vez de carregar todo o conteúdo da resposta na memória. Isso é feito passando um valor HttpCompletionOption para os métodos HttpClient.GetAsync, HttpClient.SendAsync e HttpClient.Send. 

O HttpCompletionOption é um enum com duas opções: 

- **ResponseHeadersRead:** Esta opção indica que a chamada HTTP é considerada concluída quando apenas os cabeçalhos da resposta foram lidos. O conteúdo da resposta ainda não foi lido e não está disponível para uso.

- **ResponseContentRead:** Esta opção indica que a chamada HTTP é considerada concluída quando a resposta foi lida completamente. Isso significa que todo o conteúdo da resposta foi carregado em memória e está disponível para uso.

O uso dessas opções pode ter implicações importantes no desempenho e no uso da memória do sistema. A opção ResponseContentRead pode ser a mais conveniente em muitos casos, pois permite que todo o conteúdo da resposta seja lido e manipulado em um único lugar. No entanto, para respostas muito grandes, pode ser preferível usar a opção ResponseHeadersRead para evitar carregar todo o conteúdo da resposta em memória de uma só vez.

Código do [HttpClient](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs) no GitHub:

```csharp
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
{
            // Called outside of async state machine to propagate certain exception even without awaiting the returned task.
            CheckRequestBeforeSend(request);
            (CancellationTokenSource cts, bool disposeCts, CancellationTokenSource pendingRequestsCts) = PrepareCancellationTokenSource(cancellationToken);

            return Core(request, completionOption, cts, disposeCts, pendingRequestsCts, cancellationToken);

            async Task<HttpResponseMessage> Core(
                HttpRequestMessage request, HttpCompletionOption completionOption,
                CancellationTokenSource cts, bool disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)
            {
                bool telemetryStarted = StartSend(request);
                bool responseContentTelemetryStarted = false;
                HttpResponseMessage? response = null;
                try
                {
                    // Wait for the send request to complete, getting back the response.
                    response = await base.SendAsync(request, cts.Token).ConfigureAwait(false);
                    ThrowForNullResponse(response);

                    // Buffer the response content if we've been asked to.
                    if (ShouldBufferResponse(completionOption, request))
                    {
                        if (HttpTelemetry.Log.IsEnabled() && telemetryStarted)
                        {
                            HttpTelemetry.Log.ResponseContentStart();
                            responseContentTelemetryStarted = true;
                        }

                        await response.Content.LoadIntoBufferAsync(_maxResponseContentBufferSize, cts.Token).ConfigureAwait(false);
                    }

                    return response;
                }
                catch (Exception e)
                {
                    HandleFailure(e, telemetryStarted, response, cts, originalCancellationToken, pendingRequestsCts);
                    throw;
                }
                finally
                {
                    FinishSend(cts, disposeCts, telemetryStarted, responseContentTelemetryStarted);
                }
            }
}
```
 
Analisando o método SendAsync, observamos uma decisão para armazenar a resposta em um buffer (ShouldBufferResponse):

```csharp
if (ShouldBufferResponse(completionOption, request))
{
  if (HttpTelemetry.Log.IsEnabled() && telemetryStarted)
  {
    HttpTelemetry.Log.ResponseContentStart();
    responseContentTelemetryStarted = true;
  }
  await response.Content.LoadIntoBufferAsync(_maxResponseContentBufferSize, cts.Token).ConfigureAwait(false);
}
```

``` csharp
private static bool ShouldBufferResponse(HttpCompletionOption completionOption, HttpRequestMessage request) =>
            completionOption == HttpCompletionOption.ResponseContentRead &&
            !string.Equals(request.Method.Method, "HEAD", StringComparison.OrdinalIgnoreCase);
```

Testando a performance:

Usando uma API que retorna um array de inteiros com base na quantidade solicitada, vamos executar dois testes. O primeiro teste solicitará uma quantidade de 10 inteiros e o segundo teste solicitará uma quantidade de 100.000 inteiros.

Primeiro teste com quantidade 10:

![Quantidade de 10](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/by95n2wn0r63t7ejly9n.png)

Segundo teste com quantidade 100.000:

![Quantidade 100.000](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0rp56m5fmf7dw85bis67.png)

Nos dois primeiros testes desserializamos o resultado em uma lista utilizando System.Text.Json.JsonSerializer.

Vamos realizar mais dois testes com as mesmas quantidades, porém sem desserializar o resultado, apenas utilizando a leitura do buffer.

Primeiro teste com quantidade 10:

![Sem desserialização com 10](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tnf6v7z28ybsdy47higj.png)

Segundo teste com o limite de 100.000:

![Sem desserialização 100.000](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cg9a13i0wy0hdgbnuva0.png)

Após analisarmos os resultados dos testes, podemos observar que a utilização da opção HttpCompletionOption.ResponseHeadersRead pode ser um grande aliado na otimização de chamadas a APIs que retornam um grande volume de dados. Isso pode ajudar a aliviar o consumo de memória e reduzir a pressão do GC na aplicação, contribuindo significativamente para um sistema mais performático.

Portanto, ao trabalhar com HttpClient em aplicativos .NET, é importante considerar o uso da opção HttpCompletionOption.ResponseHeadersRead para evitar carregar todo o conteúdo da resposta em memória de uma só vez, especialmente quando lidando com respostas muito grandes. 

Até a próxima!

Referências:

[HttpCompletionOption](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpcompletionoption?view=net-8.0)
[HttpClient.SendAsync](/https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.sendasync?view=net-8.0)
[HttpClient](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient?view=net-8.0)