Introduction
Polly is the standard resilience library for .NET. A common pitfall is configuring a retry policy to handle specific exception types while the actual exception thrown is wrapped in an AggregateException. The policy does not match the outer AggregateException type and does not retry, causing immediate failure instead of the expected retry behavior.
Symptoms
- Retry policy fires only once despite configuration for 3 retries
AggregateExceptionlogged instead of the inner exception being retried- Works for direct exceptions but not for task-based operations
- EF Core operations fail without retry
Parallel.ForEachAsyncfailures are not retried
Example problematic code: ```csharp var retryPolicy = Policy .Handle<HttpRequestException>() // Does NOT match AggregateException .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
// When the HttpRequestException is wrapped: try { await retryPolicy.ExecuteAsync(async () => { // This throws HttpRequestException wrapped in AggregateException await Parallel.ForEachAsync(urls, async (url, ct) => { await httpClient.GetAsync(url, ct); }); }); } catch (AggregateException ex) { // Policy did not retry because it only handles HttpRequestException // The outer exception is AggregateException } ```
Common Causes
Parallel.ForEachAsyncwraps exceptions inAggregateException- Task-based operations aggregate multiple failures
- EF Core batches multiple operations and wraps failures
- Async method returns
Taskthat faults withAggregateException - Policy handles outer type but inner type is the actual error
Step-by-Step Fix
- 1.Use HandleInner to catch nested exceptions:
- 2.```csharp
- 3.var retryPolicy = Policy
- 4..Handle<HttpRequestException>()
- 5..OrInner<HttpRequestException>() // Also check inner exceptions
- 6..WaitAndRetryAsync(3, retryAttempt =>
- 7.TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
- 8.onRetry: (exception, timespan, attempt, context) =>
- 9.{
- 10.var actualException = exception is AggregateException agg
- 11.? agg.InnerException
- 12.: exception;
- 13._logger.LogWarning(actualException,
- 14."Retry attempt {Attempt} after {Delay}s", attempt, timespan.TotalSeconds);
- 15.});
- 16.
` - 17.Handle AggregateException directly:
- 18.```csharp
- 19.var retryPolicy = Policy
- 20..Handle<HttpRequestException>()
- 21..Or<AggregateException>(ex =>
- 22.ex.InnerException is HttpRequestException ||
- 23.ex.InnerExceptions.Any(inner => inner is HttpRequestException))
- 24..WaitAndRetryAsync(3, retryAttempt =>
- 25.TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
- 26.
` - 27.Use the modern ResiliencePipeline (.NET 8+):
- 28.```csharp
- 29.builder.Services.AddResiliencePipeline("default", builder =>
- 30.{
- 31.builder.AddRetry(new HttpRetryStrategyOptions
- 32.{
- 33.MaxRetryAttempts = 3,
- 34.BackoffType = DelayBackoffType.Exponential,
- 35.UseJitter = true,
- 36.// Handles inner exceptions automatically
- 37.ShouldHandle = new PredicateBuilder()
- 38..Handle<HttpRequestException>()
- 39..HandleResult(r => !r.IsSuccessStatusCode)
- 40.});
builder.AddTimeout(TimeSpan.FromSeconds(30)); builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio = 0.5, SamplingDuration = TimeSpan.FromSeconds(30), MinimumThroughput = 10 }); });
// Usage var pipeline = serviceProvider.GetRequiredService<ResiliencePipeline>(); await pipeline.ExecuteAsync(async ct => { await httpClient.GetAsync("https://api.example.com", ct); }, cancellationToken); ```
- 1.Unwrap AggregateException before passing to policy:
- 2.```csharp
- 3.async Task<T> ExecuteWithUnwrap<T>(Func<Task<T>> action)
- 4.{
- 5.try
- 6.{
- 7.return await action();
- 8.}
- 9.catch (AggregateException ex)
- 10.{
- 11.// Flatten and re-throw the first inner exception
- 12.ex.Handle(inner => { throw inner; });
- 13.throw; // Should never reach here
- 14.}
- 15.}
var result = await retryPolicy.ExecuteAsync( () => ExecuteWithUnwrap(async () => { return await ParallelProcessAsync(); })); ```
Prevention
- Use
HandleInner<T>()in addition toHandle<T>()for nested exceptions - Consider migrating to
ResiliencePipeline(.NET 8+) which handles this better - Add logging in the
onRetrydelegate to verify retries are actually happening - Test retry policies by mocking transient failures
- Use
Polly.Contrib.WaitAndRetryfor advanced backoff strategies - Combine retry with circuit breaker to prevent retry storms