Handling timeouts and cancelation tokens frequently leads to redundant code and needless complexity. What if we could make this easier by developing a reusable solution that manages timeouts and cancellations without requiring us to repeatedly use the same boilerplate logic?
The Challenge
When performing async operations, whether it’s processing a single task or a batch of tasks, we typically want to:
Cancel all ongoing tasks immediately if one task fails or times out.
Ensure no unnecessary work is done once cancellation is triggered.
Avoid duplicating cancellation logic in each task.
Without a clean, reusable approach, these requirements can lead to a cluttered codebase with lots of repeated logic.
The Solution
By encapsulating cancellation handling and timeouts into a reusable method, we can:
Centralize Cancellation Logic: No need to repeat the same code for cancellation, timeout, or cleanup in every task.
Eliminate Boilerplate
We no longer need to manually manage callbacks or check for cancellations in every operation.
Reuse the Logics
Write it once, and reuse it everywhere, whether for a single task or a batch of tasks.
Here’s how we can solve it:
we can create a method to handle:
- Timeouts: Automatically cancel the operation if it exceeds the specified duration.
- External Cancellation: Propagate cancellation from external tokens (e.g., batch cancellation).
- Callbacks: Invoke a cleanup callback when the operation is canceled.
Reusable method
Usage Example
Output

Unit Testing this reusable approach
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
public class RunWithTimeoutAsyncTests
{
private readonly Mock<ILogger<AsyncOperationService>> _loggerMock;
private readonly AsyncOperationService _asyncOperationService;
public RunWithTimeoutAsyncTests()
{
_loggerMock = new Mock<ILogger<AsyncOperationService>>();
_asyncOperationService = new AsyncOperationService(_loggerMock.Object);
}
[Fact]
public async Task CompletesSuccessfully()
{
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(100, token);
return 42;
}
int result = await _asyncOperationService.RunWithTimeoutAsync(AsyncFunc);
Assert.Equal(42, result);
}
[Fact]
public async Task ThrowsOnManualCancellation()
{
using var cts = new CancellationTokenSource();
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(1000, token);
return 42;
}
cts.Cancel();
var exception = await Assert.ThrowsAsync<TaskCanceledException>(() =>
_asyncOperationService.RunWithTimeoutAsync(AsyncFunc, externalToken: cts.Token)
);
Assert.True(cts.Token.IsCancellationRequested);
Assert.True(exception.CancellationToken.IsCancellationRequested);
}
[Fact]
public async Task ThrowsOnTimeout()
{
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(1000, token);
return 42;
}
var exception = await Assert.ThrowsAsync<TaskCanceledException>(() =>
_asyncOperationService.RunWithTimeoutAsync(AsyncFunc, timeout: TimeSpan.FromMilliseconds(500))
);
Assert.True(exception.CancellationToken.IsCancellationRequested);
}
[Fact]
public async Task RethrowsOtherExceptions()
{
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(100, token);
throw new InvalidOperationException("Something went wrong!");
}
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
_asyncOperationService.RunWithTimeoutAsync(AsyncFunc)
);
Assert.Equal("Something went wrong!", exception.Message);
}
[Fact]
public async Task HandlesParallelExecution()
{
async Task<int> AsyncFunc1(CancellationToken token)
{
await Task.Delay(100, token);
return 1;
}
async Task<int> AsyncFunc2(CancellationToken token)
{
await Task.Delay(100, token);
return 2;
}
var task1 = _asyncOperationService.RunWithTimeoutAsync(AsyncFunc1);
var task2 = _asyncOperationService.RunWithTimeoutAsync(AsyncFunc2);
var results = await Task.WhenAll(task1, task2);
Assert.Equal(new[] { 1, 2 }, results);
}
[Fact]
public async Task RespectsLinkedCancellation()
{
using var externalCts = new CancellationTokenSource();
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(1000, token);
return 42;
}
externalCts.Cancel();
var exception = await Assert.ThrowsAsync<TaskCanceledException>(() =>
_asyncOperationService.RunWithTimeoutAsync(AsyncFunc, externalToken: externalCts.Token)
);
Assert.True(externalCts.Token.IsCancellationRequested);
Assert.True(exception.CancellationToken.IsCancellationRequested);
}
[Fact]
public async Task WorksWithoutTimeout()
{
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(100, token);
return 42;
}
int result = await _asyncOperationService.RunWithTimeoutAsync(AsyncFunc, timeout: null);
Assert.Equal(42, result);
}
[Fact]
public async Task ThrowsOnNullAsyncFunction()
{
await Assert.ThrowsAsync<NullReferenceException>(() =>
_asyncOperationService.RunWithTimeoutAsync<int>(null!)
);
}
[Fact]
public async Task CallsOnCanceledCallback()
{
using var cts = new CancellationTokenSource();
bool onCanceledCalled = false;
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(1000, token);
return 42;
}
cts.Cancel();
var exception = await Assert.ThrowsAsync<TaskCanceledException>(() =>
_asyncOperationService.RunWithTimeoutAsync(
AsyncFunc,
externalToken: cts.Token,
onCanceled: () => onCanceledCalled = true
)
);
Assert.True(onCanceledCalled);
Assert.True(cts.Token.IsCancellationRequested);
Assert.True(exception.CancellationToken.IsCancellationRequested);
}
[Fact]
public async Task CallsOnCanceledCallbackOnTimeout()
{
bool onCanceledCalled = false;
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(1000, token);
return 42;
}
var exception = await Assert.ThrowsAsync<TaskCanceledException>(() =>
_asyncOperationService.RunWithTimeoutAsync(
AsyncFunc,
timeout: TimeSpan.FromMilliseconds(500),
onCanceled: () => onCanceledCalled = true,
externalToken: CancellationToken.None
)
);
Assert.True(onCanceledCalled);
Assert.True(exception.CancellationToken.IsCancellationRequested);
}
[Fact]
public async Task DoesNotCallOnCanceledCallbackOnSuccess()
{
bool onCanceledCalled = false;
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(100, token);
return 42;
}
int result = await _asyncOperationService.RunWithTimeoutAsync(
AsyncFunc,
onCanceled: () => onCanceledCalled = true
);
Assert.False(onCanceledCalled);
Assert.Equal(42, result);
}
[Fact]
public async Task DoesNotCallOnCanceledCallbackOnOtherExceptions()
{
bool onCanceledCalled = false;
async Task<int> AsyncFunc(CancellationToken token)
{
await Task.Delay(100, token);
throw new InvalidOperationException("Something went wrong!");
}
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
_asyncOperationService.RunWithTimeoutAsync(
AsyncFunc,
onCanceled: () => onCanceledCalled = true
)
);
Assert.False(onCanceledCalled);
Assert.Equal("Something went wrong!", exception.Message);
}
[Fact]
public async Task UpdatesDatabaseInParallel()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: "TestDatabase")
.Options;
using (var dbContext = new AppDbContext(options))
{
dbContext.Products.AddRange(
new Product { Id = 1, Name = "Product 1", IsProcessed = false },
new Product { Id = 2, Name = "Product 2", IsProcessed = false },
new Product { Id = 3, Name = "Product 3", IsProcessed = false }
);
await dbContext.SaveChangesAsync();
}
async Task ProcessProductAsync(int productId, CancellationToken token)
{
using var dbContext = new AppDbContext(options);
var product = await dbContext.Products.FindAsync(productId);
if (product != null)
{
await Task.Delay(100, token);
product.IsProcessed = true;
await dbContext.SaveChangesAsync(token);
}
}
var productIds = new[] { 1, 2, 3 };
var tasks = productIds.Select(productId =>
_asyncOperationService.RunWithTimeoutNonReturningAsync(
async token =>
{
await ProcessProductAsync(productId, token);
},
timeout: TimeSpan.FromSeconds(10)
)
);
await Task.WhenAll(tasks);
using var finalDbContext = new AppDbContext(options);
var processedProducts = await finalDbContext.Products.Where(p => p.IsProcessed).ToListAsync();
Assert.Equal(3, processedProducts.Count);
}
[Fact]
public async Task CompareBehaviors()
{
var productsWithService = new List<Product>
{
new Product { Id = 1, Name = "Product 1", IsProcessed = false },
new Product { Id = 2, Name = "Product 2", IsProcessed = false },
new Product { Id = 3, Name = "Product 3", IsProcessed = false }
};
var productsManual = new List<Product>
{
new Product { Id = 1, Name = "Product 1", IsProcessed = false },
new Product { Id = 2, Name = "Product 2", IsProcessed = false },
new Product { Id = 3, Name = "Product 3", IsProcessed = false }
};
using var batchCtsWithService = new CancellationTokenSource();
using var batchCtsManual = new CancellationTokenSource();
async Task ProcessProductAsync(int productId, CancellationToken token, List<Product> products)
{
var product = products.FirstOrDefault(p => p.Id == productId);
if (product == null)
{
throw new Exception($"Product with ID {productId} not found.");
}
token.ThrowIfCancellationRequested();
if (productId == 2)
{
await Task.Delay(2000);
if (products == productsWithService)
batchCtsWithService.Cancel();
else
batchCtsManual.Cancel();
throw new InvalidOperationException("Simulated failure for product 2");
}
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(50, token);
}
token.ThrowIfCancellationRequested();
product.IsProcessed = true;
}
var productIds = new[] { 1, 2, 3 };
var tasksWithService = productIds.Select(async productId =>
{
try
{
await _asyncOperationService.RunWithTimeoutAsync(
async token =>
{
await ProcessProductAsync(productId, token, productsWithService);
return true;
},
timeout: TimeSpan.FromSeconds(5),
externalToken: batchCtsWithService.Token
);
}
catch (Exception ex)
{
Console.WriteLine($"Error in processing product {productId} (RunWithTimeoutAsync): {ex.Message}");
if (batchCtsWithService.Token.IsCancellationRequested)
{
Console.WriteLine($"Task for product {productId} is cancelled due to batch failure.");
}
throw;
}
}).ToArray();
var tasksManual = productIds.Select(async productId =>
{
try
{
await ProcessProductAsync(productId, batchCtsManual.Token, productsManual);
}
catch (Exception ex)
{
Console.WriteLine($"Error in processing product {productId} (Manual): {ex.Message}");
if (batchCtsManual.Token.IsCancellationRequested)
{
Console.WriteLine($"Task for product {productId} is cancelled due to batch failure.");
}
throw;
}
}).ToArray();
try
{
await Task.WhenAll(tasksWithService);
}
catch (InvalidOperationException)
{
}
try
{
await Task.WhenAll(tasksManual);
}
catch (InvalidOperationException)
{
}
var processedProductsWithService = productsWithService.Where(p => p.IsProcessed).ToList();
var processedProductsManual = productsManual.Where(p => p.IsProcessed).ToList();
Console.WriteLine("Processed Products (RunWithTimeoutAsync):");
foreach (var product in processedProductsWithService)
{
Console.WriteLine($"Product ID: {product.Id}, Name: {product.Name}, IsProcessed: {product.IsProcessed}");
}
Console.WriteLine("Processed Products (Manual):");
foreach (var product in processedProductsManual)
{
Console.WriteLine($"Product ID: {product.Id}, Name: {product.Name}, IsProcessed: {product.IsProcessed}");
}
Assert.Equal(processedProductsWithService.Count, processedProductsManual.Count);
Assert.All(processedProductsWithService, p => Assert.True(processedProductsManual.Any(m => m.Id == p.Id && m.IsProcessed == p.IsProcessed)));
Assert.True(batchCtsWithService.Token.IsCancellationRequested, "Batch cancellation was not triggered as expected (RunWithTimeoutAsync).");
Assert.True(batchCtsManual.Token.IsCancellationRequested, "Batch cancellation was not triggered as expected (Manual).");
}
}
public class AppDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsProcessed { get; set; }
}
Considerations
Custom Logic for Specific Scenarios:
Depending on our specific requirements, We might need to tailor the cancellation logic. For instance, if we have tasks where no result needs to be returned.
- Error Handling: The method focuses on handling cancellations gracefully, but complex workflows may require more specific error handling.
- External Token Propagation: If we’re passing external cancellation tokens, ensure that they are properly managed and not overused.
- Callback Execution: The callback is invoked during the cancellation, so ensure that cleanup logic is lightweight and doesn’t introduce additional side effects or delays.
The source code is Attached. Thanks for reading.
Best and Most Recommended ASP.NET Core 8.0.11 Hosting
Fortunately, there are a number of dependable and recommended web hosts available that can help you gain control of your website’s performance and improve your ASP.NET Core 8.0.11 web ranking. HostForLIFE.eu is highly recommended. In Europe, HostForLIFE.eu is the most popular option for first-time web hosts searching for an affordable plan.
Their standard price begins at only €3.49 per month. Customers are permitted to choose quarterly and annual plans based on their preferences. HostForLIFE.eu guarantees “No Hidden Fees” and an industry-leading ’30 Days Cash Back’ policy. Customers who terminate their service within the first thirty days are eligible for a full refund.
By providing reseller hosting accounts, HostForLIFE.eu also gives its consumers the chance to generate income. You can purchase their reseller hosting account, host an unlimited number of websites on it, and even sell some of your hosting space to others. This is one of the most effective methods for making money online. They will take care of all your customers’ hosting needs, so you do not need to fret about hosting-related matters.