Introduction
When an HttpClient request is cancelled (either explicitly via CancellationToken or implicitly via Timeout), it throws OperationCanceledException or TaskCanceledException. This is not a network error: it is an expected cancellation that should be handled differently from other failures. Mishandling cancellation leads to incorrect error logging, unnecessary retries, and resource leaks.
Symptoms
System.Threading.Tasks.TaskCanceledException: The operation was canceledSystem.OperationCanceledException: The operation was canceled- Cancellation logged as an error instead of expected behavior
- Timeout fires and request is cancelled mid-stream
- Retry logic retries cancelled requests unnecessarily
Example error:
``
System.Threading.Tasks.TaskCanceledException:
The operation was canceled.
---> System.IO.IOException: Unable to read data from the transport connection:
Operation canceled.
---> System.Net.Sockets.SocketException (125): Operation canceled
at System.Net.Http.HttpClient.SendAsync(HttpRequestMessage, CancellationToken)
Common Causes
HttpClient.Timeout(default 100 seconds) expiresCancellationTokenSource.CancelAfter()fires- ASP.NET Core request cancelled by client (browser navigation)
- Retry policy does not distinguish cancellation from failure
- Hosted service cancelled during shutdown
Step-by-Step Fix
- 1.Distinguish cancellation from other errors:
- 2.```csharp
- 3.public async Task<ApiResponse> GetDataAsync(CancellationToken ct = default)
- 4.{
- 5.try
- 6.{
- 7.using var response = await _httpClient.GetAsync("/api/data", ct);
- 8.response.EnsureSuccessStatusCode();
- 9.return await response.Content.ReadFromJsonAsync<ApiResponse>(ct);
- 10.}
- 11.catch (OperationCanceledException)
- 12.{
- 13.// This is expected - do not log as error, do not retry
- 14._logger.LogDebug("Request was cancelled");
- 15.throw; // Re-throw for the caller to handle
- 16.}
- 17.catch (HttpRequestException ex)
- 18.{
- 19.// This is a real network error - log and potentially retry
- 20._logger.LogError(ex, "HTTP request failed");
- 21.throw;
- 22.}
- 23.}
- 24.
` - 25.Configure appropriate timeouts:
- 26.```csharp
- 27.builder.Services.AddHttpClient("api", client =>
- 28.{
- 29.client.BaseAddress = new Uri("https://api.example.com/");
- 30.client.Timeout = TimeSpan.FromSeconds(30); // Not the default 100s
- 31.client.DefaultRequestHeaders.Add("Accept", "application/json");
- 32.})
- 33..ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
- 34.{
- 35.PooledConnectionLifetime = TimeSpan.FromMinutes(5),
- 36.MaxConnectionsPerServer = 10
- 37.});
- 38.
` - 39.Handle cancellation in retry policies (Polly):
- 40.```csharp
- 41.var retryPolicy = HttpPolicyExtensions
- 42..HandleTransientHttpError() // 5xx, 408, network errors
- 43..OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
- 44..WaitAndRetryAsync(3, retryAttempt =>
- 45.TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))
- 46.// Do NOT retry on cancellation
- 47..Handle<OperationCanceledException>(ex => !(ex is TaskCanceledException))
- 48..WaitAndRetryAsync(3, retryAttempt =>
- 49.TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
// Usage var result = await retryPolicy.ExecuteAsync( async (ct) => await httpClient.GetAsync("/data", ct), cancellationToken); ```
- 1.Link cancellation tokens for combined timeouts:
- 2.```csharp
- 3.public async Task<ApiResponse> GetDataWithTimeoutAsync(CancellationToken ct = default)
- 4.{
- 5.// Combine the caller's token with a 30-second timeout
- 6.using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- 7.cts.CancelAfter(TimeSpan.FromSeconds(30));
try { return await _httpClient.GetFromJsonAsync<ApiResponse>( "/api/data", cts.Token); } catch (OperationCanceledException) { if (ct.IsCancellationRequested) { _logger.LogDebug("Request cancelled by caller"); } else { _logger.LogWarning("Request timed out after 30 seconds"); } throw; } } ```
Prevention
- Always catch
OperationCanceledExceptionseparately from other exceptions - Set
HttpClient.Timeoutto a value appropriate for your use case - Use
CancellationTokenSource.CreateLinkedTokenSourcefor combined timeouts - Do not retry on cancellation: it is intentional, not a transient failure
- Use
IHttpClientFactoryfor proper HttpClient lifecycle management - Pass
CancellationTokenthrough the entire call chain - Handle ASP.NET Core
HttpContext.RequestAbortedfor request-scoped cancellation