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
  • AggregateException logged instead of the inner exception being retried
  • Works for direct exceptions but not for task-based operations
  • EF Core operations fail without retry
  • Parallel.ForEachAsync failures 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.ForEachAsync wraps exceptions in AggregateException
  • Task-based operations aggregate multiple failures
  • EF Core batches multiple operations and wraps failures
  • Async method returns Task that faults with AggregateException
  • Policy handles outer type but inner type is the actual error

Step-by-Step Fix

  1. 1.Use HandleInner to catch nested exceptions:
  2. 2.```csharp
  3. 3.var retryPolicy = Policy
  4. 4..Handle<HttpRequestException>()
  5. 5..OrInner<HttpRequestException>() // Also check inner exceptions
  6. 6..WaitAndRetryAsync(3, retryAttempt =>
  7. 7.TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
  8. 8.onRetry: (exception, timespan, attempt, context) =>
  9. 9.{
  10. 10.var actualException = exception is AggregateException agg
  11. 11.? agg.InnerException
  12. 12.: exception;
  13. 13._logger.LogWarning(actualException,
  14. 14."Retry attempt {Attempt} after {Delay}s", attempt, timespan.TotalSeconds);
  15. 15.});
  16. 16.`
  17. 17.Handle AggregateException directly:
  18. 18.```csharp
  19. 19.var retryPolicy = Policy
  20. 20..Handle<HttpRequestException>()
  21. 21..Or<AggregateException>(ex =>
  22. 22.ex.InnerException is HttpRequestException ||
  23. 23.ex.InnerExceptions.Any(inner => inner is HttpRequestException))
  24. 24..WaitAndRetryAsync(3, retryAttempt =>
  25. 25.TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
  26. 26.`
  27. 27.Use the modern ResiliencePipeline (.NET 8+):
  28. 28.```csharp
  29. 29.builder.Services.AddResiliencePipeline("default", builder =>
  30. 30.{
  31. 31.builder.AddRetry(new HttpRetryStrategyOptions
  32. 32.{
  33. 33.MaxRetryAttempts = 3,
  34. 34.BackoffType = DelayBackoffType.Exponential,
  35. 35.UseJitter = true,
  36. 36.// Handles inner exceptions automatically
  37. 37.ShouldHandle = new PredicateBuilder()
  38. 38..Handle<HttpRequestException>()
  39. 39..HandleResult(r => !r.IsSuccessStatusCode)
  40. 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. 1.Unwrap AggregateException before passing to policy:
  2. 2.```csharp
  3. 3.async Task<T> ExecuteWithUnwrap<T>(Func<Task<T>> action)
  4. 4.{
  5. 5.try
  6. 6.{
  7. 7.return await action();
  8. 8.}
  9. 9.catch (AggregateException ex)
  10. 10.{
  11. 11.// Flatten and re-throw the first inner exception
  12. 12.ex.Handle(inner => { throw inner; });
  13. 13.throw; // Should never reach here
  14. 14.}
  15. 15.}

var result = await retryPolicy.ExecuteAsync( () => ExecuteWithUnwrap(async () => { return await ParallelProcessAsync(); })); ```

Prevention

  • Use HandleInner<T>() in addition to Handle<T>() for nested exceptions
  • Consider migrating to ResiliencePipeline (.NET 8+) which handles this better
  • Add logging in the onRetry delegate to verify retries are actually happening
  • Test retry policies by mocking transient failures
  • Use Polly.Contrib.WaitAndRetry for advanced backoff strategies
  • Combine retry with circuit breaker to prevent retry storms