Skip to main content

Combining Polly Policies: Advanced Resilience

Real microservices rarely use a single Polly policy in isolation. Combining retry, circuit breaker, timeout, bulkhead, and fallback policies creates powerful resilience strategies that handle multiple failure modes. However, policy composition order is critical: wrapping policies in the wrong order can create race conditions, infinite retries, or timeout conflicts. This guide teaches composition patterns used in production systems.

Policy Wrapping Order Matters

When you wrap multiple policies, they nest like layers of an onion. The outermost policy executes first; the innermost policy executes last. Order determines execution flow and timing:

Execution: Outer → Middle → Inner → Target Operation

Consider a real scenario: you retry a network call, but each retry has its own timeout, and you fallback if all retries fail and the circuit is open. The correct nesting is:

var fallback = Policy<T>.Handle<Exception>().FallbackAsync(...);
var circuitBreaker = Policy<T>.Handle<Exception>().CircuitBreakerAsync(...);
var timeout = Policy.TimeoutAsync<T>(duration);
var retry = Policy<T>.Handle<Exception>().WaitAndRetryAsync(...);

// Wrap order: fallback → circuit breaker → timeout → retry
var combined = Policy.WrapAsync(fallback, circuitBreaker, timeout, retry);

Execution order for a request:

  1. Fallback policy checks if the wrapped policy throws; if yes, returns fallback.
  2. Circuit breaker checks state (Closed, Open, Half-Open).
  3. Timeout enforces a per-retry deadline.
  4. Retry attempts the operation (and repeats the timeout/circuit breaker checks).

The "Timeout per Retry" Pattern

Each retry should have its own timeout, not a global timeout for all retries. Use this composition:

using Polly;
using System;
using System.Net.Http;
using System.Threading.Tasks;

var retryPolicy = Policy
.Handle<OperationCanceledException>()
.Or<HttpRequestException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 100),
onRetryAsync: async (outcome, span, attemptNumber, context) =>
{
Console.WriteLine($"Retry {attemptNumber} after {span.TotalMilliseconds}ms");
await Task.CompletedTask;
}
);

var timeoutPolicy = Policy
.TimeoutAsync<HttpResponseMessage>(
TimeSpan.FromSeconds(5),
timeoutStrategy: TimeoutStrategy.Optimistic
);

// Correct order: timeout wraps retry
var combined = Policy.WrapAsync(timeoutPolicy, retryPolicy);

using var client = new HttpClient();
var response = await combined.ExecuteAsync(async (cancellationToken) =>
await client.GetAsync("https://api.example.com/data", cancellationToken)
);

Timing:

  • Attempt 1: 5-second timeout, fails, waits 100ms.
  • Attempt 2: 5-second timeout, fails, waits 200ms.
  • Attempt 3: 5-second timeout, succeeds.

Total time: ~15-20 seconds maximum. Each attempt gets a fresh 5-second window.

Wrong order (don't do this):

// WRONG: retry wraps timeout
var wrong = Policy.WrapAsync(retryPolicy, timeoutPolicy);
// This gives only one global 5-second timeout for all 3 retries combined.

Bulkhead + Circuit Breaker + Timeout + Retry + Fallback

A production-grade resilience policy for a microservice call:

using Polly;
using System;
using System.Net.Http;
using System.Threading.Tasks;

// 1. Bulkhead isolation (limit concurrent calls to this service)
var bulkheadPolicy = Policy.BulkheadAsync<HttpResponseMessage>(
maxParallelization: 16,
maxQueuingActions: 10,
onBulkheadRejectedAsync: async (context) =>
{
Console.WriteLine("Bulkhead rejected request; service at capacity.");
await Task.CompletedTask;
}
);

// 2. Retry for transient failures
var retryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TimeoutException>()
.WaitAndRetryAsync<HttpResponseMessage>(
retryCount: 2,
sleepDurationProvider: attempt =>
TimeSpan.FromMilliseconds(100 * attempt),
onRetryAsync: async (outcome, span, attemptNumber, context) =>
{
Console.WriteLine($"Retry {attemptNumber}");
await Task.CompletedTask;
}
);

// 3. Timeout per retry
var timeoutPolicy = Policy
.TimeoutAsync<HttpResponseMessage>(
TimeSpan.FromSeconds(5),
timeoutStrategy: TimeoutStrategy.Optimistic
);

// 4. Circuit breaker for sustained failures
var circuitBreakerPolicy = Policy
.Handle<HttpRequestException>()
.Or<TimeoutException>()
.CircuitBreakerAsync<HttpResponseMessage>(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30),
onBreakAsync: async (outcome, duration) =>
{
Console.WriteLine($"Circuit opened for {duration.TotalSeconds}s");
await Task.CompletedTask;
}
);

// 5. Fallback for final failures
var fallbackPolicy = Policy<HttpResponseMessage>
.Handle<Exception>()
.FallbackAsync<HttpResponseMessage>(
fallbackAction: async (context) =>
{
Console.WriteLine("Fallback: returning cached response");
await Task.CompletedTask;
return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("[Cached Data from Last Known Good State]")
};
}
);

// Wrap all policies: bulkhead → fallback → circuit breaker → timeout → retry
var resilientPolicy = Policy.WrapAsync(
bulkheadPolicy,
fallbackPolicy,
circuitBreakerPolicy,
timeoutPolicy,
retryPolicy
);

using var client = new HttpClient();
try
{
var response = await resilientPolicy.ExecuteAsync(async (cancellationToken) =>
{
return await client.GetAsync("https://api.example.com/data", cancellationToken);
});
Console.WriteLine($"Success: {response.StatusCode}");
}
catch (BulkheadRejectedException)
{
Console.WriteLine("Request rejected due to bulkhead limit");
}

Execution flow:

  1. Bulkhead grants a slot or queues/rejects.
  2. Fallback wraps the next layer.
  3. Circuit breaker checks state.
  4. Timeout enforces 5 seconds per attempt.
  5. Retry repeats up to 2 times.
  6. Target: HTTP GET.
  7. If all fail, fallback returns cached response.

Policy Composition with Policy.WrapAsync

The Policy.WrapAsync method accepts multiple policies in order:

var combined = Policy.WrapAsync(
outerPolicy,
middlePolicy,
...,
innerPolicy
);

Tip: Document the wrapping order in a comment for future maintainers.

// Resilience policy wrapping order (outer to inner):
// 1. Fallback (catch all failures)
// 2. Circuit Breaker (protect against sustained failures)
// 3. Timeout (prevent hanging)
// 4. Retry (handle transient glitches)
var resilientPolicy = Policy.WrapAsync(
fallbackPolicy,
circuitBreakerPolicy,
timeoutPolicy,
retryPolicy
);

Testing Combined Policies

Unit tests verify behavior of combined policies:

using Polly;
using Xunit;
using System;
using System.Threading.Tasks;

public class CombinedPoliciesTests
{
[Fact]
public async Task RetryThenFallbackOnAllFailures()
{
var retryCount = 0;
var retryPolicy = Policy
.Handle<Exception>()
.WaitAndRetryAsync(retryCount: 2, sleepDurationProvider: _ => TimeSpan.Zero);

var fallbackPolicy = Policy<string>
.Handle<Exception>()
.FallbackAsync<string>("Fallback");

var combined = Policy.WrapAsync(fallbackPolicy, retryPolicy);

var result = await combined.ExecuteAsync(() =>
{
retryCount++;
return Task.FromException<string>(new Exception("Always fails"));
});

// Retried twice (2 retries + 1 initial = 3 attempts), then fallback triggered
Assert.Equal(3, retryCount);
Assert.Equal("Fallback", result);
}

[Fact]
public async Task TimeoutPerRetryApplied()
{
var attempts = 0;
var retryPolicy = Policy
.Handle<OperationCanceledException>()
.WaitAndRetryAsync(retryCount: 2, sleepDurationProvider: _ => TimeSpan.Zero);

var timeoutPolicy = Policy
.TimeoutAsync(TimeSpan.FromMilliseconds(100));

var combined = Policy.WrapAsync(timeoutPolicy, retryPolicy);

// Simulate slow operation that times out on each retry
await Assert.ThrowsAsync<OperationCanceledException>(
() => combined.ExecuteAsync(async (cancellationToken) =>
{
attempts++;
await Task.Delay(200, cancellationToken);
})
);

// Each retry got its own timeout window
Assert.Equal(1, attempts); // First attempt timed out; retries did not proceed
}
}

Common Pitfalls and How to Avoid Them

PitfallProblemSolution
Wrong wrap orderCircuit breaker closes before retry exhaustedDocument order; use outermost-first wrapping
Single timeout for all retriesEffective timeout = total time, not per-retryTimeout wraps retry (timeout outer)
Missing bulkheadResource exhaustion from one slow serviceAdd bulkhead as outermost layer
Retry on non-transient failuresWastes time and masks real bugsOnly retry on HttpRequestException, TimeoutException
No fallbackUser sees error instead of degraded responseAdd fallback for user-facing operations

Key Takeaways

  • Policy composition order is critical: Outermost executes first, innermost executes last.
  • Recommended order: Bulkhead (outermost) → Fallback → Circuit Breaker → Timeout → Retry (innermost).
  • Timeout per retry: Timeout should wrap retry so each attempt gets its own deadline.
  • Test composition: Verify behavior with combined policies; single-policy tests are insufficient.
  • Document your wrapping: Comments explaining the order aid future maintenance.

Frequently Asked Questions

What if my fallback itself fails?

The fallback exception propagates. Wrap the fallback factory in try-catch or nest another fallback. For critical paths, ensure fallback never throws (return safe defaults or empty collections).

Can I use the same policy instance for multiple calls?

Yes, policies are thread-safe and reusable. Create a single instance (static field or singleton) and reuse it across requests. This is more efficient than creating new policies per call.

Should all microservice calls use the same policy?

No. Different endpoints have different characteristics. A database query needs a faster timeout than an external API. Create separate policies per logical service boundary.

Can I nest policies other than WrapAsync?

WrapAsync is the standard composition. Advanced scenarios use policy registries (named policies) or dependency injection to manage multiple policies. Polly Resilience Pipeline (.NET 8+) provides a newer abstraction.

Further Reading