Timeouts y cancellation tokens de HttpClient en C#

Timeouts frente a cancelaciones, un helper de timeout por petición, el hueco de ResponseHeadersRead y tests contra una API lenta.

Ejemplos ejecutados por última vez contra la API el

Toda llamada HTTP necesita un límite de tiempo, y el código que gestiona cuando se alcanza necesita tantas pruebas como el camino feliz. Para probarlo necesitas un servidor que sea lento cuando quieras. Añade ?delay=N a cualquier petición en https://api.testingapis.com y la respuesta espera N segundos (hasta 10) antes de enviarse. Es gratuito y no necesita clave. Esta guía muestra qué lanza HttpClient con cada tipo de timeout y cancelación, cómo distinguirlos, un hueco en HttpClient.Timeout que atrapa al código de streaming, y tests de xUnit. Todo se ejecutó en .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

Qué se lanza y cómo distinguirlo

HttpClient tiene una propiedad Timeout, de 100 segundos salvo que la cambies, y todos sus métodos aceptan además un CancellationToken. Las tres formas en que una petición puede pararse antes de tiempo lanzan una TaskCanceledException. Solo HttpClient.Timeout deja una marca, con una TimeoutException como excepción 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); }
}
Salida
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
Qué paró la peticiónExcepciónCómo reconocerla
Se agotó HttpClient.TimeoutTaskCanceledExceptionLa excepción interna es una TimeoutException
Saltó tu propio token de timeoutTaskCanceledExceptionTu CancellationTokenSource tiene IsCancellationRequested activado
Quien llama cancelóTaskCanceledExceptionEl token de quien llama tiene IsCancellationRequested activado

Un timeout es un fallo que hay que notificar o reintentar; quien cancela (un usuario que abandona la página, una petición de ASP.NET abortada) normalmente no necesita nada. Registrar los dos como errores llena tus logs de ruido.

Un timeout por petición que los mantiene separados

HttpClient.Timeout es un único valor para todas las peticiones de ese cliente. Cuando unas llamadas necesitan menos tiempo que otras, enlaza un timeout al token de quien llama. El filtro de excepciones comprueba qué token saltó: si no fue el de quien llama, fue el 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");
}
Salida
Timed out: No response from /api/Products/1?delay=5 within 2 s.
Cancelled by the caller: nothing to report

El hueco: HttpClient.Timeout se detiene en las cabeceras

Con HttpCompletionOption.ResponseHeadersRead, que se usa para streaming y descargas grandes, GetAsync vuelve en cuanto llegan las cabeceras. HttpClient.Timeout deja de contar ahí. Si luego el cuerpo llega con cuentagotas, nada detiene la lectura. /api/stream/dripinicia sesión para verlo envía las cabeceras al momento y el cuerpo despacio:

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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Salida
Headers after 0.2s
Body read after 6.1s, past the 2 s timeout

El timeout de dos segundos no paró una lectura de seis. Pasa un token también a la lectura, y entonces sí:

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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Tests de xUnit para timeouts

Comprueban que el cliente se rinde a tiempo, que sigue esperando una respuesta que solo es lenta y que detiene un cuerpo lento cuando se le pide. Pasan con xUnit 2.9 en .NET 10. El rango de tiempo del primer test es amplio a propósito: las máquinas de CI y las redes varían, y lo importante es "cerca de dos segundos, no 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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Otros endpoints lentos

?delay se queda en 10 segundos. Para esperas más largas, latencia aleatoria o cuerpos lentos, los miembros con sesión iniciada pueden usar los endpoints de fallos: /api/chaos/timeout?seconds=30inicia sesión para verlo espera hasta 60 segundos, /api/chaos/random-latencyinicia sesión para verlo varía la espera y /api/stream/dripinicia sesión para verlo (usado arriba) controla lo despacio que llega el cuerpo. Mira errores cuando quieras para el resto.

Qué comprobar en tu cliente

  • Cada llamada tiene un límite de tiempo, más corto que los 100 segundos por defecto cuando hay usuarios esperando.
  • Los timeouts y las cancelaciones de quien llama se distinguen, y solo los timeouts se registran como fallos.
  • Las lecturas en streaming pasan un token, porque HttpClient.Timeout no las cubre.
  • Un timeout muestra al usuario un error y una forma de reintentar, nunca un spinner eterno.
  • Los reintentos tras un timeout tienen su propio límite, para no castigar más a un servicio lento.