Using request timeouts middleware in web API
Last year a wrote a blog post about restricting a REST API endpoint total response time but potentially continuing the processing in the background. I implemented it fully by hand without any external dependencies. But the code could be simplified by taking advantage of the request timeouts middleware.
The core of my original solution was a method with the following structure:
public async Task<Guid> CreateAsync()
{
var submission = new Submission(Guid.NewGuid(), null, null);
var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(1));
Task[] tasks =
[
ProcessAsync(submission, cancellationTokenSource.Token),
Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None),
];
await Task.WhenAny(tasks);
return submission.Id;
}
The same timeout period was specified in two places:
- the
CancellationTokenSourceand in turn its cancellation token which is passed into the actual worker methodProcessAsync, and - the
Task.Delaycall which defines how long we wait for the worker method to complete.
The request timeouts middleware hooks into the HttpContext.RequestAborted cancellation token and makes sure that it requests cancellation not only when the underlying connection is aborted but also when the specified timeout expires.
So instead of creating the cancellation token inside the wrapper method, it should now be passes into the method from the controller:
public async Task<Guid> CreateAsync(CancellationToken cancellationToken)
{
var submission = new Submission(Guid.NewGuid(), null, null);
Task[] tasks =
[
ProcessAsync(submission, cancellationToken),
Task.Delay(TimeSpan.FromSeconds(1), CancellationToken.None),
];
await Task.WhenAny(tasks);
return submission.Id;
}
But even after this change, we still rely on a separate Task.Delay call to stop waiting for the ProcessAsync method to complete. We don't want that because the delay value is defined independently from the request timeout configuration.
Fortunately, there is a simpler way to stop waiting for ProcessAsync to complete and keep it running in the background which I could have used even in my original solution:
public async Task<Guid> CreateAsync(CancellationToken cancellationToken)
{
var submission = new Submission(Guid.NewGuid(), null, null);
try
{
await ProcessAsync(submission, cancellationToken)
.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
// The request timeout elapsed,
// but processing may continue in the background
}
return submission.Id;
}
The task returned by WaitAsync will complete either when the original task (i.e., the ProcessAsync method) completes or when the cancellation token requests the cancellation, whichever comes first. Which matches exactly what I achieved in the original implementation using Task.Delay.
Notice how I need to ignore the OperationCanceledException thrown when awaiting the WaitAsync method. Otherwise the exception would have been thrown from this method instead of the value being returned.
Of course, there is some setup required to use the request timeouts middleware:
builder.Services.AddRequestTimeouts();
// ...
app.UseRequestTimeouts();
And in the controller, the cancellation token has to be retrieved and passed along:
[HttpPost]
[RequestTimeout(1000)]
public async Task<IActionResult> Create()
{
var submissionId = await submissionsService.CreateAsync(
HttpContext.RequestAborted
);
// ...
}
In this particular case, I specified the request timeout with an attribute on the action method, but I could also do it at the controller level or with a global default.
I have a fully working sample project in my GitHub repository. The tests cover all three possible outcomes:
- the worker method completes before the timeout expires,
- the timeout expires before the worker method reaches the point of no return, so the method aborts,
- the timeout expires after the worker method reaches the point of no return, so the method continues in the background.
You might wonder why I haven't used this approach in my old blog post. For some reason, I have only recently learned about the request timeouts middleware although it has been available since .NET 8. That's also why I decided to now revisit the topic once more.
