Adding a default timeout to CancellationToken parameters in ASP.NET Core

ASP.NET Core allows us to inject a CancellationToken in actions:

[HttpGet]
public Task<ActionResult> GetLoggedInUser(CancellationToken cancellationToken) { ... }

This cancellation token is actually bound to HttpContext.RequestAborted, and it is signalled when the request is cancelled.

We can flow this token down to any async method that accepts it and help coordinate their cancellation if the request is cancelled by the user (due to page navigation, for example). This prevents wasting time doing heavy work, and having to throw it all away for a client that's already gone.

Problem

How can we add a default timeout to this token, so that the operation is cancelled either when the request is cancelled or it takes too long?

Model binding

MVC framework performs model binding on action parameters by parsing the request into usable data structures. It is used for reading the request body serialized as JSON to a class, or extracting a form field, or reading file stream into an IFormFile, among other things.

CancellationTokens are bound to HttpContext.RequestAborted by CancellationTokenModelBinder class. We'll replace it with our own implementation that will set the token to expire with a timeout.

But first, how do we join cancellation tokens together?

Combining cancellation tokens

We can merge multiple cancellation tokens into one with CancellationTokenSource.CreateLinkedTokenSource method. Here we create a timeout token, and link it to another:

var cancellationToken = // ...

var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));

var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);

Time to put it to use.

Building a model binder for cancellation tokens

Looking at the source code for CancellationTokenModelBinder, it doesn't do much, but we'll still use it as our starting point. We'll subclass it, but with a twist. Because it doesn't mark BindModelAsync method as virtual we can't override it with an override keyword. By implementing the IModelBinder interface that the base class implements, we can get over this limitation and still "override" it without actually using override.

After letting the base class perform the binding, we'll take the bound token, and replace it with a new one.

Since we've replaced the token object, we'll have to rebuild the validation state. In fact, why not just copy over what ASP.NET Core team has put there because I don't know enough about to disagree with them.

private class TimeoutCancellationTokenModelBinder : CancellationTokenModelBinder, IModelBinder
{
    public new async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        await base.BindModelAsync(bindingContext);
        if (bindingContext.Result.Model is CancellationToken cancellationToken)
        {
            // combine the default token with a timeout
            var timeoutCts = new CancellationTokenSource();
            timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
            var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);

            // We need to force boxing now, so we can insert the same reference to the boxed CancellationToken
            // in both the ValidationState and ModelBindingResult.
            //
            // DO NOT simplify this code by removing the cast.
            var model = (object)combinedCts.Token;
            bindingContext.ValidationState.Clear();
            bindingContext.ValidationState.Add(model, new ValidationStateEntry() { SuppressValidation = true });
            bindingContext.Result = ModelBindingResult.Success(model);
        }
    }
}

We also need a provider to introduce this model binder to MVC pipeline:

public class TimeoutCancellationTokenModelBinderProvider : IModelBinderProvider
{
    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        if (context?.Metadata.ModelType != typeof(CancellationToken))
        {
            return null;
        }

        return new TimeoutCancellationTokenModelBinder();
    }

    private class TimeoutCancellationTokenModelBinder : CancellationTokenModelBinder, IModelBinder
    {
        // ...
    }
}

Replacing model binders

MVC has many model binders in place to handle all kinds of parsing and binding for various types of parameters and sources. We need to put ours in the first place to run it before all others. We'll also remove the original cancellation token binder in order to prevent both binders from working over themselves.

services.Configure<MvcOptions>(options =>
{
    options.ModelBinderProviders.RemoveType<CancellationTokenModelBinderProvider>();
    options.ModelBinderProviders.Insert(0, new TimeoutCancellationTokenModelBinderProvider());
});

Usage

Let's put it to practice. We'll try to run a long async task that takes 6 seconds. The CancellationToken parameter will be bound by our binder and it is set to expire after 5 seconds. After 5 seconds, it will throw an TaskCanceledException and cancel the execution.

[HttpGet("")]
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
    await Task.Delay(TimeSpan.FromSeconds(6), cancellationToken);
    return Ok("hey");
}

Now let's get rid of the hardcoded values.

Making it configurable

We've set the timeout to 5 seconds, but it'd be better if it were configurable. We'll use the options pattern for this. Let's create a class to encapsulate the options.

public class TimeoutOptions
{
    public int TimeoutSeconds { get; set; } = 10; // seconds
    public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
}

We can configure the timeout in ConfigureServices:

services.Configure<TimeoutOptions>(configuration => { configuration.TimeoutSeconds = 10; });

and take this as a parameter in the binder provider.

public class TimeoutCancellationTokenModelBinderProvider : IModelBinderProvider
{
    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        // ...

        // resolve the configuration from the container
        var config = context.Services.GetRequiredService<IOptions<TimeoutOptions>>().Value;
        // pass it down to the binder
        return new TimeoutCancellationTokenModelBinder(config);
    }

    private class TimeoutCancellationTokenModelBinder : CancellationTokenModelBinder, IModelBinder
    {
        // then inject it to the binder
        private readonly TimeoutOptions _options;

        public TimeoutCancellationTokenModelBinder(TimeoutOptions options)
        {
            _options = options;
        }

        public new async Task BindModelAsync(ModelBindingContext bindingContext)
        {
            // ...
            if (/* ... */)
            {
                // use the configured timeout value
                var timeoutCts = new CancellationTokenSource();
                timeoutCts.CancelAfter(_options.Timeout);
                // ...
            }
        }
    }
}

That's it. Thanks for reading.

Last updated: