← GX Software caso técnico · .NET 10

Engenharia assistida por IA

De um .Result a uma decisão de arquitetura

Este não é um tutorial de async/await. É um caso técnico baseado em padrões reais de refatoração, mostrando o que a IA acelera num código legado e o que continua sendo decisão minha. O código desta página compila sem aviso, os testes passam e os números de execução saíram da aplicação rodando.

.NET 10.0.300 0 avisos no build 9 testes passando 200, 404 e 502 medidos

01 · O ponto de partida

O código como estava

Um cliente HTTP que consulta outro serviço. A assinatura é síncrona, não recebe token de cancelamento e não trata o status da resposta. É o padrão que aparece em quase todo projeto que cresceu antes de virar assíncrono.

PedidosClient.csantes
public sealed class PedidosClient
{
    private readonly HttpClient _cliente = new()
    {
        BaseAddress = new Uri("https://api.exemplo.com/v1/")
    };

    public Pedido Obter(int id)
    {
        var resposta = _cliente
            .GetAsync($"/pedidos/{id}")
            .Result;

        var json = resposta.Content
            .ReadAsStringAsync()
            .Result;

        return JsonSerializer.Deserialize<Pedido>(json);
    }
}

Compilado com <Nullable>enable</Nullable>: zero erros, um aviso. O aviso está no return e é o item 6 da lista abaixo. Repare também na barra inicial em /pedidos e no new HttpClient() dentro da classe. Os dois são problemas reais, tratados nos itens 1 e 7.

02 · O que a leitura encontrou

Oito problemas no código

Nenhum destes itens eu escrevi por achar. Cada um tem compilação, execução ou documentação oficial por trás, contra um servidor local que responde 200, 404 e 500.

  1. new HttpClient() por instância

    Um HttpClient por instância da classe significa um pool de conexões e uma resolução de DNS por instância. A documentação da Microsoft é explícita em dois pontos: o cliente só resolve DNS quando cria a conexão e não respeita o TTL do servidor, então mudança de endereço não é percebida; e porta TCP não é liberada na hora do fechamento, então taxa alta de criação de cliente pode esgotar as portas do sistema operacional. A recomendação é cliente de vida longa com PooledConnectionLifetime, ou cliente curto criado pelo IHttpClientFactory.

    Microsoft Learn, Guidelines for using HttpClient "HttpClient only resolves DNS entries when a connection is created. It doesn't track any time to live (TTL) durations" "TCP ports aren't released immediately after connection closure. If the rate of requests is high, the operating system limit of available ports might be exhausted."
  2. O status HTTP nunca era validado

    GetAsync não lança em resposta de erro, ele devolve o status. Sem EnsureSuccessStatusCode(), o corpo do erro (uma página HTML de 500) segue para o desserializador. O sintoma que aparece no log não tem nenhuma relação com a causa.

    execução, GET que responde 500 GetAsync sem EnsureSuccessStatusCode -> nao lancou. StatusCode=500 corpo recebido: <html><body>Internal Server Error</body></html> JsonSerializer.Deserialize -> JsonException: '<' is an invalid start of a value. Path: $ | LineNumber: 0
  3. Bloqueio de thread do pool sob concorrência

    .Result segura uma thread do pool durante todo o round-trip de rede, sem fazer nada. Não é deadlock no ASP.NET Core, porque não existe SynchronizationContext para capturar. Deadlock por bloqueio acontece onde há SynchronizationContext: ASP.NET clássico, WinForms, WPF. O que acontece aqui é starvation: o pool injeta threads novas devagar, e sob carga o trabalho bloqueado pode crescer mais rápido que a injeção. Sync over async é, na própria documentação da Microsoft, a causa mais comum de starvation no thread pool.

    Microsoft Learn, Debug ThreadPool starvation, com Bombardier 125 conexoes concorrentes, 10s, contra um endpoint com sync-over-async: Latency Avg 3.48s endpoint equivalente, com codigo pior: Latency Avg 15.42s mesmo cenario no endpoint assincrono: Latency Avg 532.58ms

    Os três números são do tutorial de diagnóstico da Microsoft, com a ferramenta Bombardier e 125 conexões concorrentes. É latência média, não p95. O que importa não é o valor absoluto: é a distância entre o mesmo trabalho feito bloqueando e o mesmo trabalho feito assíncrono.

    A partir do .NET 6 a heurística do pool reage mais rápido a APIs bloqueantes, o que encurta a duração do episódio. Não elimina o problema, só reduz o tempo em que ele aparece.

  4. Nenhum CancellationToken

    Se o cliente desiste ou estoura o timeout, o servidor continua trabalhando até terminar. GetAsync e GetStringAsync aceitam token; o código não passava, e a assinatura pública também não permitia passar.

  5. A exceção chegava embrulhada em AggregateException

    Quem chama .Result vê AggregateException. Quem usa await vê a exceção original. Todo catch (HttpRequestException) já existente deixa de pegar o erro quando alguém troca a chamada.

    execução .Result -> catch(Exception) pega: AggregateException InnerException = HttpRequestException await -> catch(Exception) pega: HttpRequestException HttpRequestException.StatusCode = 404 NotFound
  6. O aviso de nulidade CS8603

    JsonSerializer.Deserialize<T> devolve T?. A função promete Pedido não nulo e devolve o que vier, inclusive nulo.

    dotnet build warning CS8603: Possible null reference return.
  7. Barra inicial na URI descarta o caminho do BaseAddress

    Com BaseAddress terminando em /v1/, a URI "/pedidos/1" resolve para o caminho sem o /v1/. A versão relativa sem barra inicial mantém o caminho base. O erro não estoura: a chamada vai para a URL errada e volta 404.

    execução BaseAddress = http://127.0.0.1:5099/v1/ new Uri("/pedidos/1", BaseAddress) -> http://127.0.0.1:5099/pedidos/1 new Uri("pedidos/1", BaseAddress) -> http://127.0.0.1:5099/v1/pedidos/1
  8. Desserialização sem camelCase devolve objeto vazio

    JsonSerializer.Deserialize sem opções não casa camelCase, que é o padrão de API JSON. Nada estoura: o objeto volta com valores padrão. Com os defaults web, ou com ReadFromJsonAsync, o mapeamento funciona.

    execução Deserialize<Pedido>(json) -> Pedido { Id = 0, Cliente = , Total = 0 } Deserialize com JsonSerializerDefaults.Web -> Pedido { Id = 1, Cliente = ACME, Total = 10.5 } ReadFromJsonAsync<Pedido>() -> Pedido { Id = 1, Cliente = ACME, Total = 10.5 }

03 · O resultado

O depois, arquivo por arquivo

A refatoração atravessa cinco arquivos, mais o teste. Removi a camada de serviço duplicada: se ela só repassa a chamada, não tem valor. Ela só volta quando houver regra, cache ou mapeamento de verdade.

PedidosClient.cscliente
public sealed class PedidosClient(HttpClient http, ILogger<PedidosClient> logger) : IPedidosClient
{
    private const int LimiteCorpoErro = 512;

    public async Task<Pedido?> ObterAsync(int id, CancellationToken ct)
    {
        // Relativa sem barra inicial: preserva o /v1/ do BaseAddress.
        // ResponseHeadersRead: nao carrega o corpo inteiro antes de decidir.
        using var resposta = await http.GetAsync(
            $"pedidos/{id}", HttpCompletionOption.ResponseHeadersRead, ct);

        if (resposta.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        if (!resposta.IsSuccessStatusCode)
        {
            var corpo = await resposta.Content.ReadAsStringAsync(ct);
            logger.LogWarning(
                "Falha ao obter pedido {PedidoId}. Status {Status}. Corpo: {Corpo}",
                id, (int)resposta.StatusCode,
                corpo.Length > LimiteCorpoErro ? corpo[..LimiteCorpoErro] : corpo);
        }

        resposta.EnsureSuccessStatusCode();

        return await resposta.Content.ReadFromJsonAsync<Pedido>(ct)
            ?? throw new InvalidOperationException(
                $"Corpo vazio ou invalido para o pedido {id}.");
    }
}
Contratoassinatura
// A assinatura declara duas coisas: que existe I/O
// e que a operacao pode ser cancelada.
// Task<Pedido?> deixa claro que "nao encontrado" e um resultado esperado.
public interface IPedidosClient
{
    Task<Pedido?> ObterAsync(int id, CancellationToken ct);
}
PedidosController.csborda
[ApiController]
public sealed class PedidosController(IPedidosClient pedidos) : ControllerBase
{
    // O token vem do ASP.NET Core: e o RequestAborted da requisicao.
    [HttpGet("pedidos/{id:int}")]
    public async Task<ActionResult<Pedido>> Obter(int id, CancellationToken ct)
    {
        var pedido = await pedidos.ObterAsync(id, ct);
        return pedido is null ? NotFound() : Ok(pedido);
    }
}
PedidosApiOptions.csconfiguração
public sealed class PedidosApiOptions
{
    [Required]
    public Uri BaseAddress { get; set; } = default!;
}
appsettings.jsonconfiguração
{
  "PedidosApi": {
    "BaseAddress": "https://api.exemplo.com/v1/"
  }
}
UpstreamExceptionHandler.cserros
// Falha do servico remoto nao e erro interno nosso:
// 502 quando o upstream falha, 504 quando estoura o tempo.
public sealed class UpstreamExceptionHandler(IProblemDetailsService problemDetails)
    : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext contexto, Exception excecao, CancellationToken ct)
    {
        var status = excecao switch
        {
            TimeoutRejectedException => StatusCodes.Status504GatewayTimeout,
            HttpRequestException => StatusCodes.Status502BadGateway,
            _ => 0
        };

        if (status == 0)
        {
            return false;
        }

        contexto.Response.StatusCode = status;

        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = contexto,
            Exception = excecao,
            ProblemDetails =
            {
                Status = status,
                Title = "Falha ao consultar o servico de pedidos"
            }
        });
    }
}

using Polly.Timeout; para TimeoutRejectedException.

Program.csregistro
builder.Services
    .AddOptions<PedidosApiOptions>()
    .Bind(builder.Configuration.GetSection("PedidosApi"))
    .ValidateDataAnnotations()
    .Validate(o => o.BaseAddress is { IsAbsoluteUri: true } uri
                    && uri.AbsoluteUri.EndsWith('/'),
        "BaseAddress deve ser uma URI absoluta terminando com /, senao o caminho base e descartado.")
    .ValidateOnStart();

builder.Services
    .AddHttpClient<IPedidosClient, PedidosClient>((sp, http) =>
    {
        var opcoes = sp.GetRequiredService<IOptions<PedidosApiOptions>>().Value;
        http.BaseAddress = opcoes.BaseAddress;

        // Redundante hoje: o proprio AddStandardResilienceHandler ja desliga
        // o timeout do HttpClient. Fica explicito para documentar a intencao.
        http.Timeout = Timeout.InfiniteTimeSpan;
    })
    .AddStandardResilienceHandler(o =>
    {
        o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3);
        o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
        o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(10);
    });

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<UpstreamExceptionHandler>();

// ...
app.UseExceptionHandler();

Pacote: Microsoft.Extensions.Http.Resilience. O retry automático só é seguro aqui porque a chamada é um GET idempotente. Em POST, não use retry sem chave de idempotência, ou use options.Retry.DisableForUnsafeHttpMethods(). Os três valores acima respeitam as regras de validação do pacote: TotalRequestTimeout maior que AttemptTimeout, e SamplingDuration de pelo menos o dobro do AttemptTimeout.

PedidosClientTests.csteste
public sealed class PedidosClientTests
{
    private sealed class FakeHandler(
        Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> resposta)
        : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken ct) => resposta(request, ct);
    }

    private static PedidosClient Criar(FakeHandler handler) =>
        new(new HttpClient(handler) { BaseAddress = new Uri("https://teste/v1/") },
            NullLogger<PedidosClient>.Instance);

    [Fact]
    public async Task Retorna_pedido_em_200_com_camelCase_e_url_com_v1()
    {
        Uri? uri = null;
        var cliente = Criar(new FakeHandler((req, _) =>
        {
            uri = req.RequestUri;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(
                    """{"id":1,"cliente":"ACME","total":10.5}""",
                    Encoding.UTF8, "application/json")
            });
        }));

        var pedido = await cliente.ObterAsync(1, CancellationToken.None);

        Assert.Equal("https://teste/v1/pedidos/1", uri!.ToString());
        Assert.Equal("ACME", pedido!.Cliente);
    }

    [Fact]
    public async Task Retorna_nulo_em_404()
    {
        var cliente = Criar(new FakeHandler((_, _) =>
            Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound))));

        Assert.Null(await cliente.ObterAsync(1, CancellationToken.None));
    }

    [Fact]
    public async Task Lanca_HttpRequestException_com_status_em_500()
    {
        var cliente = Criar(new FakeHandler((_, _) =>
            Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
            {
                Content = new StringContent("<html>erro</html>")
            })));

        var erro = await Assert.ThrowsAsync<HttpRequestException>(
            () => cliente.ObterAsync(1, CancellationToken.None));

        Assert.Equal(HttpStatusCode.InternalServerError, erro.StatusCode);
    }

    [Fact]
    public async Task Propaga_cancelamento()
    {
        using var cts = new CancellationTokenSource();
        var cliente = Criar(new FakeHandler(async (_, ct) =>
        {
            await Task.Delay(Timeout.Infinite, ct);
            return new HttpResponseMessage(HttpStatusCode.OK);
        }));

        cts.CancelAfter(50);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => cliente.ObterAsync(1, cts.Token));
    }
}
verificação desta página, feita antes de publicar dotnet build (TreatWarningsAsErrors=true) Build succeeded. 0 Warning(s) 0 Error(s) dotnet test Passed! - Failed: 0, Passed: 9, Skipped: 0, Total: 9 aplicacao no ar contra um upstream local GET /pedidos/1 -> 200 {"id":1,"cliente":"ACME","total":10.5} GET /pedidos/404 -> 404 ProblemDetails "Not Found" GET /pedidos/500 -> 502 ProblemDetails "Falha ao consultar o servico de pedidos"
o mesmo 500, visto do lado do upstream [stub] 500 GET /v1/pedidos/500 chamada #1 [stub] 500 GET /v1/pedidos/500 chamada #2 [stub] 500 GET /v1/pedidos/500 chamada #3 [stub] 500 GET /v1/pedidos/500 chamada #4 warn: Polly[0] Resilience event occurred. EventName: 'OnRetry' warn: Gx.Pedidos.Api.PedidosClient[0] Falha ao obter pedido 500. Status 500. Corpo: <html><body>Internal Server Error</body></html>
configuração errada derruba o startup, não a primeira requisição PedidosApi__BaseAddress="http://127.0.0.1:5099/v1" (sem a barra final) fail: Microsoft.Extensions.Hosting.Internal.Host[11] Hosting failed to start Microsoft.Extensions.Options.OptionsValidationException: BaseAddress deve ser uma URI absoluta terminando com /, senao o caminho base e descartado.

04 · Depois que entra no ar

Observabilidade

O cliente registra status e corpo do erro com ILogger. O log sai uma vez por requisição, depois das tentativas, com o status e o corpo do erro truncado em 512 caracteres. Isso é o que transforma "deu erro" em "o upstream devolveu 500 com este corpo".

O resilience handler registra cada tentativa e cada evento. As linhas abaixo são do log real da aplicação, na categoria Polly, com o nome do pipeline e o número da tentativa.

log da aplicação info: Polly[3] Execution attempt. Source: 'IPedidosClient-standard//Standard-Retry', Result: '200', Handled: 'False', Attempt: '0' warn: Polly[3] Execution attempt. ... Result: '500', Handled: 'True', Attempt: '1' warn: Polly[0] Resilience event occurred. EventName: 'OnRetry' fail: Polly[3] Execution attempt. ... Result: '500', Handled: 'True', Attempt: '3'

Em métrica, o pipeline publica instrumentos no medidor Polly. Os nomes abaixo foram lidos do processo em execução, não copiados de documentação.

instrumentos medidos no processo Polly :: resilience.polly.pipeline.duration :: ms Polly :: resilience.polly.strategy.attempt.duration :: ms Polly :: resilience.polly.strategy.events System.Net.Http :: http.client.request.duration :: s System.Net.Http :: http.client.active_requests

Tudo isso é System.Diagnostics.Metrics, então sai para o OpenTelemetry sem adaptador. Sem essa camada não dá para saber se o handler está protegendo o sistema ou escondendo um problema que já aconteceu três vezes.

05 · As escolhas

Por que assim e não de outro jeito

Cada decisão abaixo tem um custo. Escrever o custo junto é o que separa uma escolha de engenharia de uma preferência pessoal.

Em cenários de alta performance, use o source generator do System.Text.Json com JsonSerializerContext para evitar reflexão.

06 · A divisão do trabalho

Onde a IA ajuda e onde a decisão continua minha

Onde a IA ajuda

  • 01
    Localizar o padrão espalhado. Encontrar todos os pontos com .Result, .Wait() e .GetAwaiter().GetResult() num repositório grande, agrupados por projeto e por camada.
  • 02
    Mapear impacto. Subir a cadeia de chamadores de cada assinatura que muda: interface, implementação, serviço, controller, teste. É o trabalho chato e o mais fácil de errar à mão.
  • 03
    Levantar candidatos. As duas armadilhas dos itens 7 e 8 apareceram como suspeita durante a varredura. Suspeita, não conclusão.
  • 04
    Reduzir o tempo de execução. Repetir a mesma transformação em vinte arquivos, escrever o teste que reproduz o bug e gerar a documentação do runbook a partir do código.

Onde a engenharia continua sendo minha

  • 01
    Verificar cada afirmação. Todo número desta página saiu de compilação, de teste ou de aplicação rodando. A documentação oficial aparece citada como documentação, nunca como medição minha.
  • 02
    Decidir o significado. Se 404 é exceção de domínio ou resultado é regra de negócio, e o modelo não sabe qual é a sua.
  • 03
    Escolher e assumir o custo. Trocar GetStringAsync por GetAsync cria uma obrigação nova para quem mantém o código. Essa conta é minha.
  • 04
    Definir o escopo da entrega. A refatoração total pode não caber nesta semana. O que muda agora e o que fica documentado para depois é decisão de quem responde pelo sistema.
  • 05
    Revisar o diff e responder por ele. Sugestão aceita sem leitura não é ganho de produtividade, é dívida com juros.

07 · Conclusão

O que sobra deste caso

A IA encurtou o tempo entre descobrir que esse padrão existia no código e saber o tamanho real do problema. Esse intervalo é onde projetos ficam parados por meses, porque ninguém tem uma semana livre para varrer um repositório inteiro só para dimensionar a dívida.

O que ela não fez: escolher a estratégia, definir o contrato, decidir o que o 404 significa, escrever o teste de regressão e revisar o diff. E a parte mais útil deste caso não veio da leitura do código: veio de rodar. Foi executando que apareceu o InvalidOperationException na validação da configuração, e foi executando que o retry deixou de ser promessa e virou quatro chamadas contadas no upstream.

A IA acelera a descoberta e a execução. A decisão, o teste e a revisão continuam comigo.

Fontes

  1. Microsoft Learn. HttpClient.GetStringAsync, comportamento de EnsureSuccessStatusCode e lançamento em status fora de 200 a 299. learn.microsoft.com
  2. Microsoft Learn. Debug ThreadPool starvation, heurística do pool, injeção de threads e o cenário de latência com 125 conexões concorrentes. learn.microsoft.com
  3. Stephen Cleary. ASP.NET Core SynchronizationContext, a razão de não haver deadlock por bloqueio no ASP.NET Core. blog.stephencleary.com
  4. dotnet/aspnetcore issue #4253. Taxa de injeção de threads do pool em resposta a trabalho bloqueante. github.com
  5. Microsoft Learn. Guidelines for using HttpClient: resolução de DNS, PooledConnectionLifetime e esgotamento de portas. learn.microsoft.com
  6. Microsoft Learn. Build resilient HTTP apps: estratégias e valores padrão do AddStandardResilienceHandler, e como desabilitar retry para métodos não idempotentes. learn.microsoft.com
  7. dotnet/extensions. Código-fonte de AddStandardResilienceHandler, incluindo a linha que desabilita o HttpClient.Timeout. github.com