15 minlesson

Exponential Backoff

Exponential Backoff

Exponential backoff increases the delay between retries, reducing load on struggling services and improving recovery chances.

Why Exponential Backoff?

Without Backoff

1Attempt 1: 0ms ← fail
2Attempt 2: 0ms ← fail
3Attempt 3: 0ms ← fail
4Attempt 4: 0ms ← fail
5
6Server is hammered with requests while overloaded!

With Exponential Backoff

1Attempt 1: 0ms ← fail
2Attempt 2: 1000ms ← fail (waited 1s)
3Attempt 3: 2000ms ← fail (waited 2s)
4Attempt 4: 4000ms ← success (waited 4s)
5
6Server has time to recover between attempts

Basic Exponential Backoff

csharp
1var policy = Policy
2 .Handle<HttpRequestException>()
3 .WaitAndRetryAsync(
4 5, // 5 retries
5 retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))
6 // Delays: 2s, 4s, 8s, 16s, 32s
7 );

The Thundering Herd Problem

When many clients retry at the same time:

1Time 0: 100 requests → service fails
2Time 2s: 100 retries → service fails again!
3Time 4s: 100 retries → service fails again!

All clients retry in lockstep, overwhelming the recovering service.

Solution: Jitter

Add randomness to spread out retries:

csharp
1var random = new Random();
2
3var policy = Policy
4 .Handle<HttpRequestException>()
5 .WaitAndRetryAsync(
6 5,
7 retryAttempt =>
8 {
9 var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, retryAttempt));
10 var jitter = TimeSpan.FromMilliseconds(random.Next(0, 1000));
11 return baseDelay + jitter;
12 });

Now retries are spread across a time window:

1Client 1: 2.1s, 4.3s, 8.7s
2Client 2: 2.5s, 4.1s, 8.2s
3Client 3: 2.3s, 4.8s, 8.5s

Decorrelated Jitter (Recommended)

AWS-style decorrelated jitter provides better distribution:

csharp
1static class JitterExtensions
2{
3 private static readonly Random Random = new();
4
5 public static TimeSpan DecorrelatedJitter(
6 int retryAttempt,
7 TimeSpan minDelay,
8 TimeSpan maxDelay)
9 {
10 var ceiling = Math.Min(maxDelay.TotalMilliseconds,
11 minDelay.TotalMilliseconds * Math.Pow(2, retryAttempt));
12
13 var delay = Random.NextDouble() * ceiling;
14 return TimeSpan.FromMilliseconds(delay);
15 }
16}
17
18var policy = Policy
19 .Handle<HttpRequestException>()
20 .WaitAndRetryAsync(
21 5,
22 attempt => JitterExtensions.DecorrelatedJitter(
23 attempt,
24 TimeSpan.FromMilliseconds(100),
25 TimeSpan.FromSeconds(30)));

Polly.Contrib.WaitAndRetry

The recommended package for backoff calculations:

bash
1dotnet add package Polly.Contrib.WaitAndRetry
csharp
1using Polly.Contrib.WaitAndRetry;
2
3// Decorrelated jitter backoff
4var delay = Backoff.DecorrelatedJitterBackoffV2(
5 medianFirstRetryDelay: TimeSpan.FromSeconds(1),
6 retryCount: 5);
7
8var policy = Policy
9 .Handle<HttpRequestException>()
10 .WaitAndRetryAsync(delay);

Respecting Retry-After Headers

Many APIs return Retry-After headers:

csharp
1var policy = Policy<HttpResponseMessage>
2 .HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
3 .WaitAndRetryAsync(
4 3,
5 sleepDurationProvider: (retryCount, response, context) =>
6 {
7 // Use Retry-After if provided
8 var retryAfter = response.Result?.Headers.RetryAfter;
9
10 if (retryAfter?.Delta != null)
11 return retryAfter.Delta.Value;
12
13 if (retryAfter?.Date != null)
14 return retryAfter.Date.Value - DateTimeOffset.UtcNow;
15
16 // Fall back to exponential backoff
17 return TimeSpan.FromSeconds(Math.Pow(2, retryCount));
18 },
19 onRetryAsync: (response, timeSpan, retryCount, context) =>
20 {
21 Console.WriteLine($"Rate limited, waiting {timeSpan.TotalSeconds}s");
22 return Task.CompletedTask;
23 });

Maximum Delay Cap

Prevent extremely long waits:

csharp
1var policy = Policy
2 .Handle<HttpRequestException>()
3 .WaitAndRetryAsync(
4 10,
5 retryAttempt =>
6 {
7 var exponentialDelay = TimeSpan.FromSeconds(Math.Pow(2, retryAttempt));
8 var maxDelay = TimeSpan.FromMinutes(1);
9 return exponentialDelay < maxDelay ? exponentialDelay : maxDelay;
10 });
11// Delays: 2s, 4s, 8s, 16s, 32s, 60s, 60s, 60s, 60s, 60s

Complete Example

csharp
1class RateLimitedApiClient
2{
3 private readonly HttpClient _client;
4 private readonly IAsyncPolicy<HttpResponseMessage> _retryPolicy;
5
6 public RateLimitedApiClient()
7 {
8 _client = new HttpClient();
9
10 var backoff = Backoff.DecorrelatedJitterBackoffV2(
11 medianFirstRetryDelay: TimeSpan.FromMilliseconds(500),
12 retryCount: 5);
13
14 _retryPolicy = Policy<HttpResponseMessage>
15 .Handle<HttpRequestException>()
16 .OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
17 .OrResult(r => r.StatusCode >= HttpStatusCode.InternalServerError)
18 .WaitAndRetryAsync(
19 backoff,
20 onRetry: (outcome, timeSpan, retryCount, context) =>
21 {
22 Console.WriteLine(
23 $"[Retry {retryCount}] Waiting {timeSpan.TotalMilliseconds}ms. " +
24 $"Status: {outcome.Result?.StatusCode}");
25 });
26 }
27
28 public async Task<string> GetDataAsync(string endpoint)
29 {
30 var response = await _retryPolicy.ExecuteAsync(
31 () => _client.GetAsync(endpoint));
32
33 response.EnsureSuccessStatusCode();
34 return await response.Content.ReadAsStringAsync();
35 }
36}

Key Takeaways

  • Exponential backoff: delay doubles each retry
  • Jitter prevents thundering herd
  • Decorrelated jitter provides best distribution
  • Use Polly.Contrib.WaitAndRetry for backoff calculations
  • Respect Retry-After headers from APIs
  • Cap maximum delay to prevent very long waits
  • Combine with circuit breaker (next lesson)