Timeouts e cancellation tokens do HttpClient em C#
Timeouts versus cancelamentos, um helper de timeout por requisição, a brecha do ResponseHeadersRead e testes contra uma API lenta.
Exemplos executados pela última vez contra a API em
Toda chamada HTTP precisa de um limite de tempo, e o código que trata quando ele estoura precisa de tanto teste quanto o caminho feliz. Para testar, você precisa de um servidor que fique lento quando você quiser. Adicione ?delay=N a qualquer requisição em https://api.testingapis.com e a resposta espera N segundos (até 10) antes de ser enviada. É gratuito e não precisa de chave. Este guia mostra o que o HttpClient lança em cada tipo de timeout e cancelamento, como diferenciá-los, uma brecha no HttpClient.Timeout que pega código de streaming, e testes xUnit. Tudo rodou no .NET 10.
$ time curl -s -o /dev/null -w "%{http_code}\n" "https://api.testingapis.com/api/Products/1?delay=5"
200
real 0m5.223s
user 0m0.015s
sys 0m0.000s/api/Products/1?delay=2O que é lançado e como diferenciar
O HttpClient tem uma propriedade Timeout, de 100 segundos a menos que você mude, e todo método também aceita um CancellationToken. As três formas de uma requisição parar antes da hora lançam uma TaskCanceledException. Só o HttpClient.Timeout se identifica, com uma TimeoutException como exceção interna:
using System.Diagnostics;
const string Base = "https://api.testingapis.com";
var watch = Stopwatch.StartNew();
void Log(string what, Exception ex) => Console.WriteLine(
$"{what,-22} {watch.Elapsed.TotalSeconds:F1}s {ex.GetType().Name}, inner {ex.InnerException?.GetType().Name}");
Console.WriteLine($"Default timeout: {new HttpClient().Timeout}");
// 1. HttpClient.Timeout
using (var http = new HttpClient { BaseAddress = new Uri(Base), Timeout = TimeSpan.FromSeconds(2) })
{
watch.Restart();
try { await http.GetStringAsync("/api/Products/1?delay=5"); }
catch (TaskCanceledException ex) { Log("HttpClient.Timeout", ex); Console.WriteLine($" {ex.Message}"); }
}
using (var http = new HttpClient { BaseAddress = new Uri(Base) })
{
// 2. A per-request timeout with a CancellationTokenSource
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
watch.Restart();
try { await http.GetStringAsync("/api/Products/1?delay=5", timeout.Token); }
catch (TaskCanceledException ex) { Log("token timeout", ex); }
// 3. The caller cancels first (a user leaves the page, a request is aborted)
using var caller = new CancellationTokenSource(TimeSpan.FromSeconds(1));
watch.Restart();
try { await http.GetStringAsync("/api/Products/1?delay=5", caller.Token); }
catch (TaskCanceledException ex) { Log("caller cancelled", ex); }
}Default timeout: 00:01:40
HttpClient.Timeout 2.0s TaskCanceledException, inner TimeoutException
The request was canceled due to the configured HttpClient.Timeout of 2 seconds elapsing.
token timeout 2.0s TaskCanceledException, inner TaskCanceledException
caller cancelled 1.0s TaskCanceledException, inner TaskCanceledException| O que parou a requisição | Exceção | Como reconhecer |
|---|---|---|
| O HttpClient.Timeout estourou | TaskCanceledException | A exceção interna é uma TimeoutException |
| O seu próprio token de timeout disparou | TaskCanceledException | O seu CancellationTokenSource está com IsCancellationRequested |
| Quem chamou cancelou | TaskCanceledException | O token de quem chamou está com IsCancellationRequested |
Um timeout é uma falha a reportar ou repetir; quem cancelou (um usuário saindo da página, uma requisição ASP.NET abortada) geralmente não precisa de nada. Registrar os dois como erro enche os seus logs de ruído.
Um timeout por requisição que separa os dois
O HttpClient.Timeout é um único valor para todas as requisições daquele cliente. Quando algumas chamadas precisam de menos tempo que outras, ligue um timeout ao token de quem chamou. O filtro de exceção confere qual token disparou: se não foi o de quem chamou, foi o timeout.
public static class Http
{
/// GET with a time limit. A timeout becomes a TimeoutException; the caller's own
/// cancellation stays an OperationCanceledException, so the two can be handled differently.
public static async Task<string> GetStringWithTimeoutAsync(
HttpClient http, string url, TimeSpan timeout, CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
try
{
return await http.GetStringAsync(url, cts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"No response from {url} within {timeout.TotalSeconds} s.");
}
}
}using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
try
{
await Http.GetStringWithTimeoutAsync(http, "/api/Products/1?delay=5", TimeSpan.FromSeconds(2), CancellationToken.None);
}
catch (TimeoutException ex)
{
Console.WriteLine($"Timed out: {ex.Message}");
}
using var leaving = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try
{
await Http.GetStringWithTimeoutAsync(http, "/api/Products/1?delay=5", TimeSpan.FromSeconds(4), leaving.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelled by the caller: nothing to report");
}Timed out: No response from /api/Products/1?delay=5 within 2 s.
Cancelled by the caller: nothing to reportA brecha: o HttpClient.Timeout para nos cabeçalhos
Com HttpCompletionOption.ResponseHeadersRead, usado em streaming e downloads grandes, o GetAsync volta assim que os cabeçalhos chegam. O HttpClient.Timeout para de contar ali. Se depois o corpo chega a conta-gotas, nada interrompe a leitura. /api/stream/dripentre para ver envia os cabeçalhos na hora e o corpo devagar:
using System.Diagnostics;
using var http = new HttpClient
{
BaseAddress = new Uri("https://api.testingapis.com"),
Timeout = TimeSpan.FromSeconds(2),
};
var watch = Stopwatch.StartNew();
// Headers arrive at once; the 6 bytes of body take 6 seconds.
using var response = await http.GetAsync("/api/stream/drip?durationMs=6000&bytes=6",
HttpCompletionOption.ResponseHeadersRead);
Console.WriteLine($"Headers after {watch.Elapsed.TotalSeconds:F1}s");
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Body read after {watch.Elapsed.TotalSeconds:F1}s, past the 2 s timeout");Headers after 0.2s
Body read after 6.1s, past the 2 s timeoutO timeout de dois segundos não parou uma leitura de seis. Passe um token também para a leitura, e aí ele para:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
using var response = await http.GetAsync("/api/stream/drip?durationMs=6000&bytes=6",
HttpCompletionOption.ResponseHeadersRead, cts.Token);
var body = await response.Content.ReadAsStringAsync(cts.Token); // throws after 2 sTestes xUnit para timeouts
Eles conferem se o cliente desiste na hora certa, se ainda espera uma resposta que só está lenta e se para um corpo lento quando mandado. Passam com xUnit 2.9 no .NET 10. A faixa de tempo do primeiro teste é larga de propósito: máquinas de CI e redes variam, e o que importa é "perto de dois segundos, não cinco".
using System.Diagnostics;
using Xunit;
public class TimeoutTests
{
private static readonly Uri Api = new("https://api.testingapis.com");
[Fact]
public async Task Gives_up_after_the_configured_timeout()
{
using var http = new HttpClient { BaseAddress = Api, Timeout = TimeSpan.FromSeconds(2) };
var watch = Stopwatch.StartNew();
var ex = await Assert.ThrowsAsync<TaskCanceledException>(() => http.GetStringAsync("/api/Products/1?delay=5"));
Assert.IsType<TimeoutException>(ex.InnerException);
Assert.InRange(watch.Elapsed.TotalSeconds, 1.5, 4.5); // gave up near 2 s, didn't wait 5
}
[Fact]
public async Task Waits_for_a_slow_response_within_the_timeout()
{
using var http = new HttpClient { BaseAddress = Api, Timeout = TimeSpan.FromSeconds(10) };
var json = await http.GetStringAsync("/api/Products/1?delay=2");
Assert.Contains("\"id\"", json);
}
[Fact]
public async Task Stops_reading_a_slow_body_when_cancelled()
{
using var http = new HttpClient { BaseAddress = Api };
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
using var response = await http.GetAsync("/api/stream/drip?durationMs=6000&bytes=6",
HttpCompletionOption.ResponseHeadersRead, cts.Token);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => response.Content.ReadAsStringAsync(cts.Token));
}
}Outros endpoints lentos
O ?delay para em 10 segundos. Para travamentos mais longos, latência aleatória ou corpos lentos, membros conectados podem usar os endpoints de falha: /api/chaos/timeout?seconds=30entre para ver espera até 60 segundos, /api/chaos/random-latencyentre para ver varia a espera e /api/stream/dripentre para ver (usado acima) controla a lentidão com que o corpo chega. Veja erros quando quiser para o resto.
O que verificar no seu cliente
- Toda chamada tem um limite de tempo, menor que os 100 segundos padrão quando há usuários esperando.
- Timeouts e cancelamentos de quem chamou são diferenciados, e só os timeouts são registrados como falha.
- Leituras em streaming passam um token, já que o
HttpClient.Timeoutnão as cobre. - Um timeout mostra ao usuário um erro e um jeito de tentar de novo, nunca um spinner infinito.
- Retries depois de um timeout têm limite próprio, para não castigar ainda mais um serviço lento.