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.

Terminal
$ 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
GET/api/Products/1?delay=2

O 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:

Program.cs
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); }
}
Saída
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çãoExceçãoComo reconhecer
O HttpClient.Timeout estourouTaskCanceledExceptionA exceção interna é uma TimeoutException
O seu próprio token de timeout disparouTaskCanceledExceptionO seu CancellationTokenSource está com IsCancellationRequested
Quem chamou cancelouTaskCanceledExceptionO 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.

Http.cs
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.");
        }
    }
}
Program.cs
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");
}
Saída
Timed out: No response from /api/Products/1?delay=5 within 2 s.
Cancelled by the caller: nothing to report

A 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:

Program.cs
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");
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita
Saída
Headers after 0.2s
Body read after 6.1s, past the 2 s timeout

O timeout de dois segundos não parou uma leitura de seis. Passe um token também para a leitura, e aí ele para:

C#
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 s
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

Testes 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".

TimeoutTests.cs
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));
    }
}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

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.Timeout nã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.