HttpClient provides a convenient way to make web requests in .NET. But if you want to cancel all pending requests using CancelPendingRequests or use the Timeout functionality, you must be aware of the gotchas.

I recently encountered some strange behaviour where some requests were not cancelled when CancelPendingRequests was called and some did not time out after the Timeout had elapsed. I couldn't find any documentation about this behaviour, so I explored it myself and discovered some strange behaviour.

Gotcha #1: CancelPendingRequests does not cancel all requests

I expect CancelPendingRequests to cancel all current requests on an HttpClient. But it does not. Its behaviour depends on which HttpCompletionOption you use.

If you run the following code on a url containing a large file, it will print Starting Download, and then after 3 seconds print Cancel followed by Cancelled and the download will stop. This is the expected behaviour of CancelPendingRequests.

using(var client = new HttpClient())
{
  Task.Delay(3000).ContinueWith(t => {
      Console.Out.WriteLine("Cancel");
      client.CancelPendingRequests();
  });

  try
  {
      Console.Out.WriteLine("Starting Download");

      using(var result = await client.GetAsync(url, HttpCompletionOption.ResponseContentRead))
      {
          return await result.Content.ReadAsByteArrayAsync();
      }
  }
  catch
  {
      Console.Out.WriteLine("Cancelled");
      return new byte[0];
  }
}

But if you change the HttpCompletionOption to HttpCompletionOption.ResponseHeadersRead and run the code, it will print Starting Download, and then after 3 seconds print Cancel, but it will not print Cancelled and the download will continue. This is unexpected behaviour for CancelPendingRequests.

Gotcha #2: Timeout does not apply to all requests

I expect that a request will be cancelled once the Timeout value has elapsed. But it does not. Like CancelPendingRequests, its behaviour depends on which HttpCompletionOption you use.

If we remove the code that cancels pending requests:

Task.Delay(3000).ContinueWith(t => {
    Console.Out.WriteLine("Cancel");
    client.CancelPendingRequests();
});

And replace it with code to set a timeout:

client.Timeout = TimeSpan.FromSeconds(3);

We get the same results as we did with CancelPendingRequests. The Timeout works as expected when HttpCompletionOption.ResponseContentRead is used and gives the unexpected behaviour when HttpCompletionOption.ResponseHeadersRead is used.

Analysis of behaviour

When you use HttpCompletionOption.ResponseHeadersRead instead of HttpCompletionOption.ResponseContentRead, you're saying "I want more control".

You might use this extra control to stream directly to disk, to abort large downloads early based on the response headers or the response content, or to implement a sliding time out.

As Timeout is global to all requests on an HttpClient, it makes sense to be able to override it on some requests. The requests where you might want to override it are the requests where you take more control, so to some extent it is understandable that this is linked to HttpCompletionOption.ResponseHeadersRead. However, I would expect to see this documented on HttpCompletionOption and Timeout and ideally would like it to be a separate self-documenting parameter to GetAsync for those times when you want more control, but still want a time out.

I cannot think of any reason to opt-out of CancelPendingRequests on some requests. However, I expect both CancelPendingRequests and Timeout may have been difficult to implement or non-performant when you take low-level control over the request using HttpCompletionOption.ResponseHeadersRead. Even so, I feel this behaviour should still have been documented.

Solution: Implement cancellation yourself

Using a CancellationTokenSource you can implement cancellation yourself, but you'll need to read the stream manually too. The following code demonstrates this. When run, it prints Starting Download, and then after 3 seconds prints Cancel followed by Cancelled and the download stops.

using(var client = new HttpClient())
using(var cts = new CancellationTokenSource())
{
    Task.Delay(3000).ContinueWith(t =>
    {
        Console.Out.WriteLine("Cancel");
        cts.Cancel();
    });

    try
    {
        Console.Out.WriteLine("Starting Download");

        using(var result = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token))
        using(var output = new MemoryStream())
        using(var stream = await result.Content.ReadAsStreamAsync())
        using(cts.Token.Register(() => stream.Close()))
        {
            byte[] buffer = new byte[80000];
            int bytesRead;
            while((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) != 0)
            {
                output.Write(buffer, 0, bytesRead);
            }

            return output.ToArray();
        }
    }
    catch
    {
        Console.Out.WriteLine("Cancelled");
        return new byte[0];
    }
}

To duplicate the functionality of CancelPendingRequests, create a CancellationTokenSource with the same lifetime as HttpClient and call Cancel on the CancellationTokenSource when you would call CancelPendingRequests.

To duplicate the functionality of Timeout, create a CancellationTokenSource for each request using the constructor that takes a TimeSpan to define the cancellation delay.

To do both, combine the approaches by creating a linked token source using CancellationTokenSource.CreateLinkedTokenSource. When doing this, remember to dispose the linked token source in addition to the underlying token sources. The linked token source creates a registration with the underlying token sources and therefore failure to dispose the linked token source will create a memory leak if one of the underlying token sources is long lived.

Unfortunately, it is necessary to forcefully close the stream via the token registration, because although you can pass the CancellationToken to stream.ReadAsync, this has no effect. stream.ReadAsync only checks for cancellation when first called and then delegates to lower-level code that does not support cancellation. If this lower-level code is waiting for bytes that never arrive, cancellation may never occur. This lower-level implementation may change in the future, in which case passing the CancellationToken to stream.ReadAsync will be preferable.