In this post I'll show how you can use a CancellationToken
in your ASP.NET Core action method to stop execution when a user cancels a request from their browser. This can be useful if you have long running requests that you don't want to continue using up resources when a user clicks "stop" or "refresh" in their browser.
I'm not really going to cover any of the details of async
, await
, Task
s or CancellationToken
s in this post, I'm just going to look at how you can inject a CancellationToken
into your action methods, and use that to detect when a user has cancelled a request.
Long running requests and cancellation
Have you ever been on a website where you've made a request for a page, and it just sits there, supposedly loading? Eventually you get board and click the "Stop" button, or maybe hammer F5 to reload the page. Users expect a page to load pretty much instantly these days, and when it doesn't, a quick refresh can be very tempting.
That's all well and good for the user, but what about your poor server? If the action method the user is hitting takes a long time to run, then refreshing five times will fire off 5 requests. Now you're doing 5 times the work. That's the default behaviour in MVC - even though the user has refreshed the browser, which cancels the original request, your MVC action won't know that the value it's computing is going to be thrown away at the end of it!
In this post, we'll assume you have an MVC action that can take some time to complete, before sending a response to the user. While that action is processing, the user might cancel the request directly, or refresh the page (which effectively cancels the original request, and initiates a new one).
I'm ignoring the fact that long running actions are generally a bad idea. If you find yourself with many long running actions in your app, you might be better off considering a solution based on CQRS and messaging queues, so you can quickly return a response to the user, and can process the result of the action on a background thread.
For example, consider the following MVC controller. This is a toy example, that simply waits for 10s before returning a message to the user, but the Task.Delay()
could be any long-running process, such as generating a large report to return to the user.
public class SlowRequestController : Controller
{
private readonly ILogger _logger;
public SlowRequestController(ILogger<SlowRequestController> logger)
{
_logger = logger;
}
[HttpGet("/slowtest")]
public async Task<string> Get()
{
_logger.LogInformation("Starting to do slow work");
// slow async action, e.g. call external api
await Task.Delay(10_000);
var message = "Finished slow delay of 10 seconds.";
_logger.LogInformation(message);
return message;
}
}
If we hit the URL /slowtest
then the request will run for 10s, and eventually will return the message:
If we check the logs, you can see the whole action executed as expected:
So now, what happens if the user refreshes the browser, half way through the request? The browser never receives the response from the first request, but as you can see from the logs, the action method executes to completion twice - once for the first (cancelled) request, and once for the second (refresh) request:
Whether this is correct behaviour will depend on your app. If the request modifies state, then you may not want to halt execution mid-way through a method. On the other hand, if the request has no side-effects, then you probably want to stop the (presumably expensive) action as soon as you can.
ASP.NET Core provides a mechanism for the web server (e.g. Kestrel) to signal when a request has been cancelled using a CancellationToken
. This is exposed as HttpContext.RequestAborted
, but you can also inject it automatically into your actions using model binding.
Using CancellationTokens
in your MVC Actions
CancellationToken
s are lightweight objects that are created by a CancellationTokenSource
. When a CancellationTokenSource
is cancelled, it notifies all the consumers of the CancellationToken
. This allows one central location to notify all of the code paths in your app that cancellation was requested.
When cancelled, the IsCancellationRequested
property of the cancellation token will be set to True
, to indicate that the CancellationTokenSource
has been cancelled. Depending on how you are using the token, you may or may not need to check this property yourself. I'll touch on this a little more in the next section, but for now, let's see how to use a CancellationToken
in our action methods.
Lets consider the previous example again. We have a long-running action method (which for example, is generating a read-only report by calling out to a number of other APIs). As it as an expensive method, we want to stop executing the action as soon as possible if the request is cancelled by the user.
The following code shows how we can hook into the central CancellationTokenSource
for the request, by injecting a CancellationToken
into the action method, and passing the parameter to the Task.Delay
call:
public class SlowRequestController : Controller
{
private readonly ILogger _logger;
public SlowRequestController(ILogger<SlowRequestController> logger)
{
_logger = logger;
}
[HttpGet("/slowtest")]
public async Task<string> Get(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting to do slow work");
// slow async action, e.g. call external api
await Task.Delay(10_000, cancellationToken);
var message = "Finished slow delay of 10 seconds.";
_logger.LogInformation(message);
return message;
}
}
MVC will automatically bind any CancellationToken
parameters in an action method to the HttpContext.RequestAborted
token, using the CancellationTokenModelBinder
. This model binder is registered automatically when you call services.AddMvc()
(or services.AddMvcCore()
) in Startup.ConfigureServices()
.
With this small change, we can test out our scenario again. We'll make an initial request, which starts the long-running action, and then we'll reload the page. As you can see from the logs below, the first request never completes. Instead the Task.Delay
call throws a TaskCancelledException
when it detects that the CancellationToken.IsCancellationRequested
property is true
, immediately halting execution.
Shortly after the request is cancelled by the user refreshing the browser, the original request is aborted with a TaskCancelledException
which propagates back through the MVC filter pipeline, and back up the middleware pipeline.
In this scenario, the Task.Delay()
method keeps an eye on the CancellationToken
for you, so you never need to manually check if the token has been cancelled yourself. Depending on your scenario, you may be able to rely on framework methods like these to check the state of the CancellationToken
, or you may have to watch for cancellation requests yourself.
Checking the cancellation state
If you're calling a built in method that supports cancellation tokens, like Task.Delay()
or HttpClient.SendAsync()
, then you can just pass in the token, and let the inner method take care of actually cancelling (throwing) for you.
In other cases, you may have some synchronous work you're doing, which you want to be able to be able to cancel. For example, imagine you're building a report to calculate all of the commission due to a company's employees. You're looping over every employee, and then looping over each sale they've made.
A simple solution to be able to cancel this report generation mid-way would be to check the CancellationToken
inside the for
loop, and abandon ship if the user cancels the request. The following example represents this kind of situation by looping 10 times, and performing some synchronous (non-cancellable) work, represented by the call to Thread.Sleep()
. At the start of each loop, we check the cancellation token and throw if cancellation has been requested. This lets us add cancellation to an otherwise long-running synchronous process.
public class SlowRequestController : Controller
{
private readonly ILogger _logger;
public SlowRequestController(ILogger<SlowRequestController> logger)
{
_logger = logger;
}
[HttpGet("/slowtest")]
public async Task<string> Get(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting to do slow work");
for(var i=0; i<10; i++)
{
cancellationToken.ThrowIfCancellationRequested();
// slow non-cancellable work
Thread.Sleep(1000);
}
var message = "Finished slow delay of 10 seconds.";
_logger.LogInformation(message);
return message;
}
}
Now if you cancel the request the call to ThrowIfCancelletionRequested()
will throw an OperationCanceledException
, which again will propogate up the filter pipeline and up the middleware pipeline.
Tip: You don't have to use
ThrowIfCancellationRequested()
. You could check the value ofIsCancellationRequested
and exit the action gracefully. This article contains some general best practice patterns for working with cancellation tokens.
Typically, exceptions in action methods are bad, and this exception is treated no differently. If you're using the ExceptionHandlerMiddleware
or DeveloperExceptionMiddleware
in your pipeline, these will attempt to handle the exception, and generate a user-friendly error message. Of course, the request has been cancelled, so the user will never see this message!
Rather than filling your logs with exception messages from cancelled requests, you will probably want to catch these exceptions. A good candidate for catching cancellation exceptions from your MVC actions is an ExceptionFilter
.
Catching cancellations with an ExceptionFilter
ExceptionFilters
are an MVC concept that can be used to handle exceptions that occur either in your action methods, or in your action filters. If you're not familiar with the filter pipeline, I recommend checking out the documentation.
You can apply ExceptionFilter
s at the action level, at the controller level (in which case they apply to every action in the controller), or at the global level (in which case they apply to every action in your app). Typically they're implemented as attributes, so you can decorate your action methods with them.
For this example, I'm going to create a simple ExceptionFilter
and add it to the global filters. We'll handle the exception, log it, and create a simple response so that we can just wind up the request as quick as possible. The actual response (Result
) we generate doesn't really matter, as it's never getting sent to the browser, so our goal is to handle the exception in as tidy a way as possible.
public class OperationCancelledExceptionFilter : ExceptionFilterAttribute
{
private readonly ILogger _logger;
public OperationCancelledExceptionFilter(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<OperationCancelledExceptionFilter>();
}
public override void OnException(ExceptionContext context)
{
if(context.Exception is OperationCanceledException)
{
_logger.LogInformation("Request was cancelled");
context.ExceptionHandled = true;
context.Result = new StatusCodeResult(400);
}
}
}
This filter is very simple. It derives from the base ExceptionFilterAttribute
for simplicity, and overrides the OnException
method. This provides an ExceptionContext
object with information about the exception, the action method being executed, the ModelState
- all sorts of interesting stuff!
All we care about are the OperationCanceledException
exceptions, and if get one, we just write a log message, mark the exception as handled, and return a 400
result. Obviously we could log more (the URL would be an obvious start), but you get the idea.
Note that we are handling
OperationCanceledException
. TheTask.Delay
method throws aTaskCancelledException
when cancelled, but that derives fromOperationCanceledException
, so we'll catch both types with this filter.
I'm not going to argue about whether this should be a 200
/400
/500
status code result. The request is cancelled and the client will never see it, so it really doesn't matter that much. I chose to go with a 400
result, but you have to be aware that if you have any middleware in place to catch errors like this, such as the StatusCodeMiddleware
, then it could end up catching the response and doing pointless extra work to generate a "friendly" error page. On the other hand, if you return a 200
, be careful if you have middleware that might cache the response to this "successful" request!
Muhammad Rehan Saeed suggest using 499 Client Closed Request, as it's used by Nginx for a similar purpose. That seems as good an option as any to me!
To hook up the exception filter globally, you add it in the call to services.AddMvc()
in Startup.ConfigureServices
:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
options.Filters.Add<OperationCancelledExceptionFilter>();
});
}
}
Now if the user refreshes their browser mid request, the request will still be cancelled, but we are back to a nice log message, instead of exceptions propagating all the way up our middleware pipeline.
Summary
Users can cancel requests to your web app at any point, by hitting the stop or reload button on your browser. Typically, your app will continue to generate a response anyway, even though Kestrel won't send it to the user. If you have a long running action method, then you may want to detect when a request is cancelled, and stop execution.
You can do this by injecting a CancellationToken
into your action method, which will be automatically bound to the HttpContext.RequestAborted
token for the request. You can check this token for cancellation as usual, and pass it to any asynchronous methods that support it. If the request is cancelled, an OperationCanceledException
or TaskCanceledException
will be thrown.
You can easily handle this exceptions using an ExceptionFilter
, applied to the action or controller directly, or alternatively applied globally. The response won't be sent to the user's browser, so this isn't essential, but you can use it to tidy up your logs, and short circuit the pipeline in as efficient manner as possible.
Thanks to @purekrome for requesting this post and even providing the code outline!