Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Using CancellationTokens in ASP.NET Core minimal APIs

$
0
0

This post is an update to a 5 year old post about using CancellationTokens in MVC controller actions.

In this post I show how you can use a CancellationToken in your ASP.NET Core minimal API endpoint handlers 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 going to cover any of the details of async, await, Tasks or CancellationTokens in this post, I'm just going to look at how you can inject a CancellationToken into your minimal APIs, 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.

Users won't typically be hitting a minimal API directly like they would be hitting a Razor Page or MVC action, but the equivalent could easily happen inside a SPA app. Hammering the "submit" button in your Angular app may be effectively doing the same thing.

That's all well and good for the user, but what about your poor server? If the API 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 minimal APIs - even though the user has refreshed the browser, which cancels the original request, your endpoint handler won't know that the value it's computing is going to be thrown away at the end of it!

In this post, I assume you have an endpoint handler that can take some time to complete, before sending a response to the user. While that handler 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 endpoints are generally a bad idea. If you find yourself with many long running endpoint handlers 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 minimal API app. 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.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/slowtest", () =>
{
    app.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.";

    app.Logger.LogInformation(message);

    return message;
});

app.Run();

If you hit the URL /slowtest then the request will run for 10s, and eventually returns the message:

Browser image

If you check the logs, you can see the whole handler executed as expected:

info: Handler[0]
      Starting to do slow work
info: Handler[0]
      Finished slow delay of 10 seconds.

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 handler executes to completion twice - once for the first (cancelled) request, and once for the second (refresh) request:

2 Log messages

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, or the side effects don't matter, 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 handlers using model binding.

Using CancellationTokens in your minimal API handlers

CancellationTokens 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 endpoint handlers.

Lets consider the previous example again. You have a long-running handler (which for example, is generating a read-only report by calling out to a number of other APIs). As it as an expensive method, you want to stop executing the action as soon as possible if the request is cancelled by the user.

The following code shows how you can hook into the central CancellationTokenSource for the request, by injecting a CancellationToken into the endpoint handler, and passing the parameter to the Task.Delay call. The minimal API infrastructure automatically binds any CancellationToken parameters in a handler method to the HttpContext.RequestAborted token.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/slowtest", (CancellationToken token, ILogger<Handler> logger) =>
{
    app.Logger.LogInformation("Starting to do slow work");

    // slow async action, e.g. call external api
    await Task.Delay(10_000, token);

    var message = "Finished slow delay of 10 seconds.";

    app.Logger.LogInformation(message);

    return message;
});

app.Run();

With this small change you can test out the scenario again. You make an initial request, which starts the long-running handler, and then 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.

Logs throwing exception

Shortly after the request is cancelled by the user refreshing the browser, the original request is aborted with a TaskCancelledException which propagates 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 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, you check the cancellation token and throw if cancellation has been requested. This lets you add cancellation to an otherwise long-running synchronous process.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/slowtest", (CancellationToken token)
    {
        app.Logger.LogInformation("Starting to do slow work");

        for(var i=0; i<10; i++)
        {
            token.ThrowIfCancellationRequested();
            // slow non-cancellable work
            Thread.Sleep(1000);
        }

        var message = "Finished slow delay of 10 seconds.";

        app.Logger.LogInformation(message);

        return message;
    }
});

app.Run();

Now if you cancel the request the call to ThrowIfCancelletionRequested() throws an OperationCanceledException, which again propogates up the filter pipeline and up the middleware pipeline.

Tip: You don't have to use ThrowIfCancellationRequested(). You could check the value of IsCancellationRequested and exit the action gracefully, but as Stephen Cleary discusses in this series on cancellation, throwing is generally the best practice.

Typically, exceptions in endpoint handlers 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 (or a Problem Details reponse in .NET 7). 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 may want to catch these exceptions. A good candidate for catching cancellation exceptions from your endpoint handlers is either a middleware component or an endpoint filter. In this case I'm going to use middleware.

Catching cancellations with middleware

WebApplication automatically adds developer exception handling middleware to the middleware pipeline in development, but it's common to add alternative exception handling middleware in production.

For this example, I'm going to create a simple piece of exception handling middleware that only catches OperationCanceledExceptions. It handles the exception, logs it, and creates a simple response so that it just wind ups the request as quick as possible. The actual response generated 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.

class OperationCanceledMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<OperationCanceledMiddleware> _logger;
    public OperationCanceledMiddleware(
        RequestDelegate next, 
        ILogger<OperationCanceledMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch(OperationCanceledException)
        {
            _logger.LogInformation("Request was cancelled");
            context.Response.StatusCode = 409;
        }
    }
}

This middleware is very simple. All we care about are the OperationCanceledException exceptions, and if we get one, we just write a log message, mark the exception as handled, and return a 409 Client Closed Request response. Obviously we could log more (the URL would be an obvious start), but you get the idea.

Note that we are handling OperationCanceledException. The Task.Delay method throws a TaskCancelledException when cancelled, but that derives from OperationCanceledException, so we'll catch both types with this filter.

I chose to go with a 409 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!

To add the middleware, call UseMiddleware<>() on WebApplication:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseMiddleware<OperationCanceledMiddleware>();  // <-- Add this middleware
app.MapGet("/slowtest", (CancellationToken token) =>
{
    // Not shown for brevity
});

app.Run();

Now if the user refreshes their browser mid request, the request will still be cancelled, but you are back to a nice log message, instead of uninteresting exceptions propagating further up the middleware pipeline.

Cancellation handled

Obviously there's a caveat here - this middleware catches all OperationCanceledExceptions. If that's not something you want, then it might be best not to use this middleware. Alternatively, you could incorporate this behaviour into the generic ExceptionHandlerMiddleware

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 continues to generate a response anyway, even though Kestrel won't send it to the client. If you have a long running endpoint handler, 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 is 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 these exceptions using a middleware or an endpoint filer. 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.


Viewing all articles
Browse latest Browse all 743

Trending Articles