Timeouts et cancellation tokens de HttpClient en C#

Timeouts contre annulations, un helper de timeout par requête, la faille de ResponseHeadersRead et des tests contre une API lente.

Exemples exécutés pour la dernière fois contre l’API le

Chaque appel HTTP a besoin d’une limite de temps, et le code qui gère son dépassement mérite autant de tests que le cas nominal. Pour le tester, il faut un serveur lent à la demande. Ajoutez ?delay=N à n’importe quelle requête sur https://api.testingapis.com et la réponse attend N secondes (jusqu’à 10) avant d’être envoyée. Gratuit et sans clé. Ce guide montre ce que lève HttpClient pour chaque type de timeout et d’annulation, comment les distinguer, une faille de HttpClient.Timeout qui piège le code de streaming, et des tests xUnit. Tout a tourné sur .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

Ce qui est levé, et comment faire la différence

HttpClient a une propriété Timeout, 100 secondes sauf si vous la changez, et chaque méthode accepte aussi un CancellationToken. Les trois façons dont une requête peut s’arrêter avant la fin lèvent une TaskCanceledException. Seul HttpClient.Timeout se signale, avec une TimeoutException comme exception interne :

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); }
}
Sortie
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
Ce qui a arrêté la requêteExceptionComment la reconnaître
HttpClient.Timeout est écouléTaskCanceledExceptionL’exception interne est une TimeoutException
Votre propre token de timeout s’est déclenchéTaskCanceledExceptionVotre CancellationTokenSource a IsCancellationRequested activé
L’appelant a annuléTaskCanceledExceptionLe token de l’appelant a IsCancellationRequested activé

Un timeout est un échec à signaler ou à relancer ; un appelant qui annule (un utilisateur qui quitte la page, une requête ASP.NET interrompue) n’a généralement besoin de rien. Consigner les deux comme des erreurs remplit vos logs de bruit.

Un timeout par requête qui les distingue

HttpClient.Timeout est une seule valeur pour toutes les requêtes de ce client. Quand certains appels ont besoin de moins de temps que d’autres, liez un timeout au token de l’appelant. Le filtre d’exception vérifie quel token s’est déclenché : si ce n’est pas celui de l’appelant, c’est le 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");
}
Sortie
Timed out: No response from /api/Products/1?delay=5 within 2 s.
Cancelled by the caller: nothing to report

La faille : HttpClient.Timeout s’arrête aux en-têtes

Avec HttpCompletionOption.ResponseHeadersRead, utilisé pour le streaming et les gros téléchargements, GetAsync rend la main dès que les en-têtes arrivent. HttpClient.Timeout cesse de compter à ce moment. Si le corps arrive ensuite au compte-gouttes, rien n’interrompt la lecture. /api/stream/dripconnectez-vous pour voir envoie ses en-têtes tout de suite et son corps lentement :

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");
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit
Sortie
Headers after 0.2s
Body read after 6.1s, past the 2 s timeout

Le timeout de deux secondes n’a pas arrêté une lecture de six secondes. Passez aussi un token à la lecture, et ça marche :

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
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit

Des tests xUnit pour les timeouts

Ils vérifient que le client abandonne à temps, qu’il attend malgré tout une réponse simplement lente, et qu’il arrête un corps lent quand on le lui demande. Ils passent avec xUnit 2.9 sur .NET 10. La plage de temps du premier test est large exprès : les machines de CI et les réseaux varient, et l’idée est "près de deux secondes, pas cinq".

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));
    }
}
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit

D’autres points d’accès lents

?delay s’arrête à 10 secondes. Pour des blocages plus longs, une latence aléatoire ou des corps lents, les membres connectés peuvent utiliser les points d’accès de panne : /api/chaos/timeout?seconds=30connectez-vous pour voir attend jusqu’à 60 secondes, /api/chaos/random-latencyconnectez-vous pour voir fait varier l’attente, et /api/stream/dripconnectez-vous pour voir (utilisé plus haut) règle la lenteur du corps. Voir les erreurs à la demande pour le reste.

Ce qu’il faut vérifier dans votre client

  • Chaque appel a une limite de temps, plus courte que les 100 secondes par défaut quand des utilisateurs attendent.
  • Les timeouts et les annulations de l’appelant sont distingués, et seuls les timeouts sont consignés comme échecs.
  • Les lectures en streaming reçoivent un token, puisque HttpClient.Timeout ne les couvre pas.
  • Un timeout affiche une erreur et un moyen de réessayer, jamais un indicateur de chargement sans fin.
  • Les nouvelles tentatives après un timeout ont leur propre limite, pour ne pas accabler un service lent.