Cancellation Tokens in ASP.NET APIs

Overview

Background

If you're just looking for the code, it's here: https://github.com/dahlsailrunner/async-vs-sync

The most recent post I did was all about the improvements that asynchronous code can provide a website when it experiences higher load levels. That led to the notion of cancellation tokens, which I was initially pretty confused about.

An oversimplified summary, but one that might provide some good initial benefits, is that they can be useful when you have longer-running API methods that do read-only operations - things like dashboard API calls, or pages with lots of content that is resource-intensive to retrieve.

When to Use Cancellation Tokens

As a starting point, use cancellation tokens when you have resource-intensive read operations that take a long time to run ("long time" is relative, but generally more than at least a second or two).

Use Case for Experimentation

I created an API method GET /LongRead in a sample project. The "logic" method is shown here, and it just iterates 10 times calling a repository method asynchronously.

1public async Task<string> GetSequentialLongQueryAsync(CancellationToken token = default)
2{
3    var resultBuilder = new StringBuilder();
4    for (var i = 1; i <= 10; i++)
5    {
6        resultBuilder.Append(await _carvedRockRepository.GetSequentialLongQuery(i, token));
7    }
8    return resultBuilder.ToString();
9}

And here's the repository method - which doesn't make an actual DB query but does await Task.Delay for a second and passes a cancellation token:

1public async Task<string> GetSequentialLongQuery(int sequenceNumber, CancellationToken token = default)
2{
3    await Task.Delay(1000, token); // simulates long single query
4    Log.Information($"Query {sequenceNumber} completed.");
5    return $"Query {sequenceNumber} completed.\n";
6}

Both of the above methods include a CancellationToken parameter with a default value. This makes the parameter optional for the caller but allows for cancellation support.

Cancellation Token as an API Method Parameter

It's worth mentioning the controller action on the API - here's the code:

1[HttpGet]
2public async Task<string> Get(bool includeCancellation, CancellationToken token)
3{
4    if (!includeCancellation)
5    {
6        token = CancellationToken.None;
7    }
8    return await _longReadLogic.GetSequentialLongQueryAsync(token);
9}

In the above code, includeCancellation parameter is the only actual querystring parameter for the API, and it's only present to enable experimentation. You can choose to use or not use the cancellation token support by setting the parameter to true or false respectively.

The CancellationToken parameter is something that is not passed by a caller directly but rather set by the browser or caller to enable the API to know if a request has been canceled.

So invoking this method is a simple GET call: GET https://localhost:7213/LongRead?includeCancellation=true is an example that will actually use a cancellaton token.

Testing Cancellation Requests

Actually cancelling a request can be done in a few different ways:

  • Use a tool like Postman or Insomnia to make the request and cancel the request while it's running
  • Use the Swagger UI from a browser that isn't hooked to your IDE and either close the tab/browser while the request is running or navigate to a new URL in the same tab -- in either case the browser sends a cancellation request. (If your Swagger UI browser is connected to the IDE while debugging closing the browser will stop the debugger)
  • Use the REST Client extension in VS Code (click the spinning "Waiting" icon) and cancel the request while it's running (this feature appears to be missing within Visual Studio and Rider HTTP file support as I write this)
Canceled Requests in Real Life

In most cases, a request cancellation will be triggered from the browser or HTTP client that's making the API call when someone navigates away from the page that initiated the request or simply closes their browser.

The short video (animated GIF) below shows a cancellation request from Insomnia that uses cancellation in the first request and notice that the "queries" stop executing and a 499 Client Closed Request response is returned from the API. The second request does NOT use the cancellation token, and when the request is canceled the queries continue to run and a 200 response is returned.

cancellation

Cancellation Response Handling

I have a global exception and response handler in most of my APIs that will return a ProblemDetails response when an error occurs in an API, and without any special handling, a canceled request like the ones we saw would return a 500 Server Error response and the logging would log an unhandled exception of type TaskCanceledException.

Cancellations should be considered more normal processing in this case, so I didn't want to return a 200 OK or a 500 Server Error, but rather a 499 Client Closed Request.

Kristian Hellang's useful ProblemDetails middleware makes this pretty easy (note the mapping for the TaskCanceledException):

 1builder.Services.AddProblemDetails(opts => 
 2{
 3    opts.IncludeExceptionDetails = (_, _) => false;
 4    
 5    opts.OnBeforeWriteDetails = (_, details) => {
 6        if (details.Status == 500)
 7        {
 8            details.Detail = "An error occurred in our API. Use the trace id when contacting us.";
 9        }
10    };
11    opts.MapToStatusCode<TaskCanceledException>(499);  // 499 Client Closed Request
12    opts.MapToStatusCode<Exception>(StatusCodes.Status500InternalServerError);
13});

Concluding Thoughts

Cancellation tokens - in this initial use case, anyway - can help you avoid wasting resources executing code/queries/remote calls in APIs where the caller is no longer waiting for a response. This may be especially useful in larger, resource-intensive pages where dashboard-like content is loaded or other such situations.

Using cancellation tokens "everywhere", though, is not something to be done automatically. It may not make sense in very short read operations, and for any kind of updates or transactions with multiple update steps, more caution should be applied and those are more complex topics for potentially another post here.

But applying cancellation tokens to longer read-only operations may give you some significant savings in compute / IO resources in circumstances like the ones I've described in this article.

Happy coding!