Introduction
Hey all ! ๐ซก
A little post to share some thoughts about the Task.Run() method, and the problems you could encounter especially in a .NET API using a GlobalExceptionHandler. โ
What actually happen when you need to start a Task as “fire and forget”, and you have an ExceptionHandler configured globally ?
This kind of case can happen frequently, for example if you need to await a particular business process, and start some tasks after that process that does not need to be awaited by the calling context.
For example, let’s say you have an OrderService that implements this method:
public async Task AddOrderAsync(OrderModel orderModel, CancellationToken)
{
await _orderRepository.AddOrderAsync(OrderModel orderModel, CancellationToken cancellationToken);
_ = Task.Run(async () =>
{
var notificationModel = _mapper.Map<NotificationModel>(orderModel);
await _notificationProvider(notificationModel, cancellationToken);
});
}
And you have an ExceptionHandler that catches every exceptions of your API:
public class CustomExceptionHandler : IExceptionHandler
{
private readonly ILogger<CustomExceptionHandler> _logger;
public CustomExceptionHandler(ILogger<CustomExceptionHandler> logger)
{
_logger = logger;
}
public async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
_logger.LogError(exception, "An unhandled exception occurred.");
var response = context.Response;
response.ContentType = "application/json";
response.StatusCode = exception switch
{
BusinessException => (int)HttpStatusCode.BadRequest,
HttpRequestException => (int) ex.StatusCode
_ => (int)HttpStatusCode.InternalServerError
};
var errorResponse = new
{
StatusCode = response.StatusCode,
Message = exception.Message,
Details = exception.InnerException?.Message
};
var errorJson = JsonSerializer.Serialize(errorResponse);
await response.WriteAsync(errorJson);
}
}
The exception potentially thrown by your awaited code (here the OrderRepository) will be catched correctly by the handler, but everything thrown inside the Task.Run as fire and forget (here the notificationProvider) will not !
Why is the exception not captured ?
When a task is executed asynchronously without await or explicit handling, it runs independently of the calling context. If an exception is thrown within that task, it does not automatically propagate to the main thread or the global exception handler.
In .NET, exceptions in unawaited tasks (fire-and-forget) are captured by the unobserved exception handler (TaskScheduler.UnobservedTaskException), but this only happens if the task is not awaited and its exception is not observed. ๐
It will not kill your application as the built-in unobserved handler will catch it, but it is out of our hands and we don’t want the exception to reach such part of the framework, especially if your strategy consists in throwing exceptions for errors globally.
(Yeah, we are not in a use case where we use the Result pattern, and this is not the point of this example nor blog post ๐)
So what should I do in such case ?
The best practice consists in encapsulating your task inside a try-catch clause, in order to handle those exceptons locally:
public async Task AddOrderAsync(OrderModel orderModel, CancellationToken cancellationToken)
{
await _orderRepository.AddOrderAsync(orderModel, cancellationToken);
_ = Task.Run(async () =>
{
try
{
var notificationModel = _mapper.Map<NotificationModel>(orderModel);
await _notificationProvider(notificationModel, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occured in fire and forget task: {ExceptionMessage}", ex.Message);
// Add some more custom logic if needed
}
});
}
Conclusion
Using Task.Run() can be a powerful tool, but it comes with its own set of nuances, especially when working with global exception handling strategies. As explained above, exceptions thrown in fire-and-forget tasks won’t be captured by your ExceptionHandler, which can lead to unexpected behavior or unhandled errors.
To avoid these pitfalls, always ensure that fire-and-forget tasks are properly encapsulated in a try-catch block to handle exceptions locally. This approach not only keeps your application robust but also ensures that errors are logged and managed in a good way ๐
As always, Happy Hacking ๐จโ๐ปโค๏ธ