Cancellation belongs to an operation owner
Cancellation is a cooperative request to stop work associated with an owner. The caller creates a CancellationTokenSource, passes its token into SendAsync and also supplies it to content reading. This preserves a cancellation path across the application boundary rather than making the caller wait for work it no longer wants. HttpClient and its handlers may use linked internal tokens, so the transport's received token need not be the same token value as the caller originally supplied. The important contract is that the owning cancellation can reach operations that honor it.
Observe a pre-canceled request without timing guesses
The sample cancels lifetime before SendAsync is invoked. The awaited operation produces OperationCanceledException, which the caller catches and reports as cancelled. CancelHandler checks its supplied token before incrementing Completed or creating a response. The final line is completed:0. There is no timer race: the source is already canceled before the call starts. This is a deterministic observation of pre-canceled behavior. Completed counts only handler work beyond its explicit guard; the zero value does not establish that every possible internal method or handler entry was skipped.
Pass cancellation through transport and content
The source, handler and client have visible owners. disposeHandler:false means the handler's separate using declaration remains responsible for its lifetime. A request is created inside the protected scope, and any successfully returned response would be disposed there too. In the demonstrated path no usable response escapes the canceled await. Source disposal is cleanup, not a replacement for calling Cancel. The same caller token is also provided to ReadAsStringAsync in the success path so later content consumption does not silently lose the intended cancellation capability.
Canceled does not mean nothing ever happened
Do not interpret cancellation as proof that a remote mutation was rolled back or never started. This offline sample has no remote server, and its handler does not perform a side effect before checking cancellation. A production request can be canceled after a service has accepted or completed work. Cancellation and timeout policies also overlap with HttpClient's own behavior, so classify failures using the relevant owner state and exception information rather than assuming every OperationCanceledException means a user clicked Cancel. This lesson establishes one deliberately controlled cancellation scenario, not every possible interruption phase.
Practice: distinguish entry from completed work
Add a separate Entered counter at the beginning of CancelHandler.SendAsync, leaving Completed after ThrowIfCancellationRequested. Compare both observations for a pre-canceled call and a normal call. Predict only the guarantees supported by each counter: successful handler work must remain zero for the canceled case, while framework behavior determines whether entry itself occurs. Avoid turning a handler-entry detail into a public application requirement unless that detail is actually part of your adapter's contract. Keep a fresh handler for each independent scenario so prior calls do not contaminate the result.
Practice: cancel a controlled content reader
Build a custom asynchronous readable stream whose first chunk is released by a TaskCompletionSource and whose later read explicitly observes the supplied cancellation token. Start content consumption, release the first chunk, then request cancellation before releasing another chunk. Verify a canceled outcome and cleanup of the owned response. Use the completion signals to establish ordering rather than Task.Delay or a public slow service. As a hint, keep cancellation registration owned and disposed by the controlled operation; do not leave callbacks attached to a source after the reader has finished.
Review cancellation evidence at the right boundary
The baseline's cancelled and completed:0 lines are useful because their scope is precise. They show that a pre-canceled operation does not complete the handler's guarded successful work. They do not measure partial network delivery, a server transaction or rollback. Keep owner cancellation visible through request sending and body consumption, and test later phases independently when the application requires them. In a real adapter, report cancellation separately from a malformed response or an HTTP failure so the caller can decide whether to abandon, reconcile or explicitly retry the operation.
- Forward the owning cancellation into every operation that should honor it.
- A canceled wait does not prove a remote side effect was undone.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.IO;
using System.IO.Compression;
using System.Buffers.Binary;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using var handler = new CancelHandler(); using var client = new HttpClient(handler, disposeHandler: false); using var lifetime = new CancellationTokenSource(); lifetime.Cancel();
try { using var request = new HttpRequestMessage(HttpMethod.Get, "https://offline.invalid/value"); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, lifetime.Token); Console.WriteLine(await response.Content.ReadAsStringAsync(lifetime.Token)); }
catch (OperationCanceledException) { Console.WriteLine("cancelled"); }
Console.WriteLine("completed:" + handler.Completed);
sealed class CancelHandler : HttpMessageHandler {
public int Completed { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) { token.ThrowIfCancellationRequested(); Completed++; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("ready") }); }
}
Expected output
cancelled completed:0
Observe cancellation as its own outcome, and do not assume a remote operation is undone when the caller stops waiting.