Engenharia assistida por IA
.Result a uma decisão de arquiteturaEste 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.
01 · O ponto de partida
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.
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
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.
new HttpClient() por instânciaUm 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.
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.
.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.
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.
CancellationTokenSe 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.
AggregateExceptionQuem 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.
JsonSerializer.Deserialize<T> devolve T?. A função
promete Pedido não nulo e devolve o que vier, inclusive nulo.
BaseAddressCom 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.
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.
03 · O resultado
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.
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}."); } }
// 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); }
[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); } }
public sealed class PedidosApiOptions { [Required] public Uri BaseAddress { get; set; } = default!; }
{
"PedidosApi": {
"BaseAddress": "https://api.exemplo.com/v1/"
}
}
// 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.
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.
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)); } }
04 · Depois que entra no ar
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.
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.
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
Cada decisão abaixo tem um custo. Escrever o custo junto é o que separa uma escolha de engenharia de uma preferência pessoal.
Task e token até a borda
Custo: mudança em cascata nos arquivos que tocam a chamada.GetAsync com ResponseHeadersRead e validação manual, em vez de
GetFromJsonAsync
Motivo: preciso ler e registrar o corpo do erro. Custo: esquecer
EnsureSuccessStatusCode() passa a ser possível, e o teste de 500 cobre isso. Se
eu não precisasse do corpo do erro, GetFromJsonAsync seria mais simples.ProblemDetails
Custo: mais um componente (IExceptionHandler) e uma decisão sobre o que o cliente
da minha API deve ver.HttpClient.Timeout é redundante hoje, porque o próprio
AddStandardResilienceHandler já faz isso, e o teste confirma o valor efetivo.
Fica explícita para documentar a intenção.BaseAddress em configuração, validada no startup
Custo: um arquivo a mais. Benefício: erro de configuração derruba o deploy, não a primeira
requisição. A validação checa IsAbsoluteUri antes de tocar em
AbsoluteUri, senão um valor relativo derruba com
InvalidOperationException no meio da validação, em vez de mensagem de
configuração.ReadFromJsonAsync no lugar de Deserialize
Resolve camelCase e, com o ??, o aviso CS8603.ConfigureAwait(false) na camada de aplicação
Em biblioteca que roda em hosts diferentes, continua valendo.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
.Result, .Wait() e
.GetAwaiter().GetResult() num repositório grande, agrupados por projeto e
por camada.GetStringAsync por GetAsync cria uma obrigação nova para quem
mantém o código. Essa conta é minha.07 · Conclusão
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
HttpClient.GetStringAsync, comportamento de
EnsureSuccessStatusCode e lançamento em status fora de 200 a 299.
learn.microsoft.comHttpClient: resolução de DNS,
PooledConnectionLifetime e esgotamento de portas.
learn.microsoft.comAddStandardResilienceHandler, e como desabilitar retry para métodos não
idempotentes.
learn.microsoft.comAddStandardResilienceHandler,
incluindo a linha que desabilita o HttpClient.Timeout.
github.com