Unhandled TaskCancelledException when request is aborted by client in ASP.NET Core MVC

Viewed 822

ASP.NET Core MVC provides approach to handle situations when request is aborted by the client. Framework passes CancellationToken that can be accessed via HttpContext.RequestAborted property, or can be bound into controller's action.

In terms of .NET, this approach looks pretty clear, consistent and natural. What doesn't look natural and logical to me is that framework, which initializes, populates and 'cancels' this access token doesn't handle appropriate TaskCancelledException.

So, if

  1. I create a new project from the "ASP.NET Core Web API" template,

  2. Add an action with CancellationToken argument, something like this:

     [HttpGet("Delay")]
     public async Task<IActionResult> GetDelayAsync(CancellationToken cancellationToken)
     {
         await Task.Delay(30_000, cancellationToken);
         return Ok();
     }
    
  3. And then send request via postman and cancel it before completion

Then the application records this error in the log:

fail: Microsoft.AspNetCore.Server.Kestrel[13]

Connection id "0HMCHB3SQHQQR", Request id "0HMCHB3SQHQQR:00000002": An unhandled exception was thrown by the application.

System.Threading.Tasks.TaskCanceledException: A task was canceled.
<<>>

My expectation is that exception in this particular case exception is handled and absorbed by asp.net, with no "fail" records in logs.

Error-wise behavior should be the same as with synchronous action:

    [HttpGet("Delay")]
    public IActionResult GetDelay()
    {
        Thread.Sleep(30_000);
        return Ok();
    }

This implementation doesn't record any errors in logs when request is aborted.

Technically exception can be absorbed and hided by exception filter, but this approach looks weird and overcomplicated. At least because this is routine situation, and writing code for any application doesn't make any sense. Also, I want to hide "exception caused by aborted request when client isn't interested in response" and behavior related to other unhandled TaskCancelledException should remain as is...

I'm wondering how and when it's supposed to properly handle and absorb exception when request is aborted by client?

There are number of articles how to access cancellation token, however I was unable to find any explicit statement that answers my question.

2 Answers

From https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/task-cancellation:

If you are waiting on a Task that transitions to the Canceled state, a System.Threading.Tasks.TaskCanceledException exception (wrapped in an AggregateException exception) is thrown. Note that this exception indicates successful cancellation instead of a faulty situation. Therefore, the task's Exception property returns null.

That's why this block does not throw (there's no task awaited that is tied to a cancellation token):

[HttpGet("Delay")]
        public IActionResult GetDelay(CancellationToken cancellationToken)
        {
            Thread.Sleep(30_000);
            return Ok();
        }

I stumbled upon the same issue you described in your post. Genuinely speaking, middleware might not be the worst approach. I found good example in Ocelot API gateway on Github.

Pay attention it will return HTTP 499 Client Closed Request afterwards. You may modify it in way that no logs will be written.

/// <summary>
/// Catches all unhandled exceptions thrown by middleware, logs and returns a 500.
/// </summary>
public class ExceptionHandlerMiddleware : OcelotMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IRequestScopedDataRepository _repo;

    public ExceptionHandlerMiddleware(RequestDelegate next,
        IOcelotLoggerFactory loggerFactory,
        IRequestScopedDataRepository repo)
            : base(loggerFactory.CreateLogger<ExceptionHandlerMiddleware>())
    {
        _next = next;
        _repo = repo;
    }

    public async Task Invoke(HttpContext httpContext)
    {
        try
        {
            httpContext.RequestAborted.ThrowIfCancellationRequested();
            var internalConfiguration = httpContext.Items.IInternalConfiguration();
            TrySetGlobalRequestId(httpContext, internalConfiguration);
            Logger.LogDebug("ocelot pipeline started");
            await _next.Invoke(httpContext);
        }
        catch (OperationCanceledException) when (httpContext.RequestAborted.IsCancellationRequested)
        {
            Logger.LogDebug("operation canceled");
            if (!httpContext.Response.HasStarted)
            {
                httpContext.Response.StatusCode = 499;
            }
        }
        catch (Exception e)
        {
            Logger.LogDebug("error calling middleware");
            var message = CreateMessage(httpContext, e);
            Logger.LogError(message, e);
            SetInternalServerErrorOnResponse(httpContext);
        }
        Logger.LogDebug("ocelot pipeline finished");
    }

    private void TrySetGlobalRequestId(HttpContext httpContext, IInternalConfiguration configuration)
    {
        var key = configuration.RequestId;
        if (!string.IsNullOrEmpty(key) && httpContext.Request.Headers.TryGetValue(key, out var upstreamRequestIds))
        {
            httpContext.TraceIdentifier = upstreamRequestIds.First();
        }
        _repo.Add("RequestId", httpContext.TraceIdentifier);
    }

    private void SetInternalServerErrorOnResponse(HttpContext httpContext)
    {
        if (!httpContext.Response.HasStarted)
        {
            httpContext.Response.StatusCode = 500;
        }
    }

    private string CreateMessage(HttpContext httpContext, Exception e)
    {
        var message =
            $"Exception caught in global error handler, exception message: {e.Message}, exception stack: {e.StackTrace}";
        if (e.InnerException != null)
        {
            message =
                $"{message}, inner exception message {e.InnerException.Message}, inner exception stack {e.InnerException.StackTrace}";
        }
        return $"{message} RequestId: {httpContext.TraceIdentifier}";
    }
}

If you use multiple middlewares it should be first on the invocation list (It's .NET 6)

app.UseMiddleware(typeof(ExceptionHandlerMiddleware));

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();
Related