Fallback Strategies with Polly
A fallback policy specifies an alternative action when the primary operation fails. Instead of returning an error or raising an exception, a fallback returns a cached value, a default response, or a gracefully degraded result. Fallbacks transform failures into partial successes: when a recommendation service is down, serve cached recommendations instead of nothing. Properly deployed fallbacks can maintain service availability for 80-95% of requests even during degraded conditions.
What Is Graceful Degradation?
Graceful degradation means continuing to serve users with reduced functionality rather than failing completely. A real estate site might show property listings from cache when the database is slow, or an e-commerce platform might disable personalization when the recommendation engine is unavailable.
Fallback policies enable graceful degradation by defining predetermined alternatives:
Primary operation fails → Fallback triggered → Return cached/default value
Basic Fallback: Return a Default Value
using Polly;
using System;
using System.Net.Http;
using System.Threading.Tasks;
var fallbackPolicy = Policy<string>
.Handle<HttpRequestException>()
.Or<TimeoutException>()
.FallbackAsync<string>(
fallbackValue: "[Service Unavailable - Cached Data]",
onFallbackAsync: async (outcome, context) =>
{
Console.WriteLine($"Fallback triggered. Reason: {outcome.Exception?.Message}");
await Task.CompletedTask;
}
);
using var client = new HttpClient();
var result = await fallbackPolicy.ExecuteAsync(async () =>
{
return await client.GetStringAsync("https://api.example.com/status");
});
Console.WriteLine($"Result: {result}");
// Output: "Result: [Service Unavailable - Cached Data]" (if API fails)
When primary operation fails:
FallbackAsynccatches the exception.onFallbackAsynccallback logs the failure.- The fallback value is returned instead of throwing.
Fallback with Custom Logic (Async Factory)
Use an async factory function to compute the fallback dynamically:
using Polly;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
// Simulated in-memory cache
var cache = new Dictionary<string, string>
{
{ "lastKnownStatus", "System operational (as of 2 hours ago)" }
};
var fallbackPolicy = Policy<string>
.Handle<HttpRequestException>()
.FallbackAsync<string>(
fallbackAction: async (context) =>
{
Console.WriteLine("Primary failed. Querying cache...");
await Task.Delay(10); // Simulate cache lookup
var cachedValue = cache.TryGetValue("lastKnownStatus", out var value)
? value
: "Status unknown";
return $"[Cached] {cachedValue}";
}
);
using var client = new HttpClient();
var result = await fallbackPolicy.ExecuteAsync(async () =>
{
var response = await client.GetStringAsync("https://api.example.com/status");
cache["lastKnownStatus"] = response; // Update cache on success
return response;
});
Console.WriteLine(result);
Execution:
- Primary operation tries to fetch fresh status from API.
- If API fails, fallback queries the cache asynchronously.
- User gets cached data instead of error.
Combining Fallback with Circuit Breaker and Retry
A realistic resilience strategy chains multiple policies:
using Polly;
using System;
using System.Net.Http;
using System.Threading.Tasks;
var retryPolicy = Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync<HttpResponseMessage>(
retryCount: 2,
sleepDurationProvider: attempt =>
TimeSpan.FromMilliseconds(100 * attempt)
);
var circuitBreakerPolicy = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync<HttpResponseMessage>(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30)
);
var fallbackPolicy = Policy<HttpResponseMessage>
.Handle<Exception>()
.FallbackAsync<HttpResponseMessage>(
fallbackAction: async (context) =>
{
Console.WriteLine("All resilience policies exhausted. Returning fallback response.");
await Task.CompletedTask;
return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("[Cached/Degraded Response]")
};
}
);
// Wrap: fallback (outer) → circuit breaker → retry (inner)
var combinedPolicy = Policy.WrapAsync(
fallbackPolicy,
circuitBreakerPolicy,
retryPolicy
);
using var client = new HttpClient();
var response = await combinedPolicy.ExecuteAsync(async () =>
await client.GetAsync("https://api.example.com/data")
);
Console.WriteLine($"Status: {response.StatusCode}");
Execution flow:
- Retry fires, attempting up to 2 times.
- Circuit breaker checks state and allows or blocks the call.
- Primary operation runs (HTTP GET).
- If all retries fail and circuit is open, fallback triggers.
- Fallback returns a synthetic OK response with cached data.
Stale-While-Revalidate Pattern
A production pattern: return cached data immediately while revalidating in the background:
using Polly;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
public class CacheWithRevalidation
{
private readonly Dictionary<string, (string data, DateTime cachedAt)> _cache = new();
private readonly TimeSpan _cacheMaxAge = TimeSpan.FromMinutes(5);
public async Task<string> FetchWithFallbackAsync(string url)
{
var primaryPolicy = Policy<string>
.Handle<HttpRequestException>()
.TimeoutAsync<string>(TimeSpan.FromSeconds(3))
.FallbackAsync<string>(
fallbackAction: async (context) =>
{
// Try cache first
if (_cache.TryGetValue(url, out var cached))
{
Console.WriteLine($"Serving stale cache from {cached.cachedAt:t}");
// Revalidate in background (fire-and-forget)
_ = RevalidateInBackgroundAsync(url);
return cached.data;
}
return "[No cached data available]";
}
);
using var client = new HttpClient();
var result = await primaryPolicy.ExecuteAsync(async () =>
{
var response = await client.GetStringAsync(url);
_cache[url] = (response, DateTime.Now);
return response;
});
return result;
}
private async Task RevalidateInBackgroundAsync(string url)
{
try
{
await Task.Delay(100); // Simulate revalidation delay
using var client = new HttpClient();
var freshData = await client.GetStringAsync(url);
_cache[url] = (freshData, DateTime.Now);
Console.WriteLine("Cache revalidated in background");
}
catch (Exception ex)
{
Console.WriteLine($"Background revalidation failed: {ex.Message}");
}
}
}
// Usage
var cacheService = new CacheWithRevalidation();
var result = await cacheService.FetchWithFallbackAsync("https://api.example.com/data");
Console.WriteLine($"Result: {result}");
Benefit: Users get fast cached responses (page loads in <50ms) while the system quietly updates the cache in the background. Best for non-critical data (recommendations, trending lists, weather).
Fallback Selector: Choose Based on Failure Type
Use a fallback selector to return different values depending on the failure:
using Polly;
using System;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading.Tasks;
var fallbackPolicy = Policy<string>
.Handle<TimeoutException>()
.FallbackAsync<string>(
fallbackValue: "[Timeout: Last known value from cache]",
onFallbackAsync: async (outcome, context) =>
{
Console.WriteLine("Timeout detected. Returning cached value.");
await Task.CompletedTask;
}
)
.Or<SocketException>()
.FallbackAsync<string>(
fallbackValue: "[Network Error: Check connectivity]",
onFallbackAsync: async (outcome, context) =>
{
Console.WriteLine("Network error. Advising retry later.");
await Task.CompletedTask;
}
)
.Or<OperationCanceledException>()
.FallbackAsync<string>(
fallbackValue: "[Request Cancelled]",
onFallbackAsync: async (outcome, context) =>
{
Console.WriteLine("Request was cancelled.");
await Task.CompletedTask;
}
);
using var client = new HttpClient();
var result = await fallbackPolicy.ExecuteAsync(async () =>
{
return await client.GetStringAsync("https://api.example.com/data");
});
Console.WriteLine(result);
Result: Different fallback values for different failure modes, allowing tailored user messaging.
Testing Fallback Policies
Unit tests verify fallback behavior:
using Polly;
using Xunit;
using System;
using System.Threading.Tasks;
public class FallbackTests
{
[Fact]
public async Task FallbackReturnedOnPrimaryFailure()
{
var policy = Policy<string>
.Handle<Exception>()
.FallbackAsync<string>("Fallback Response");
var result = await policy.ExecuteAsync(() =>
throw new Exception("Primary operation failed")
);
Assert.Equal("Fallback Response", result);
}
[Fact]
public async Task PrimaryResultReturnedOnSuccess()
{
var policy = Policy<string>
.Handle<Exception>()
.FallbackAsync<string>("Fallback Response");
var result = await policy.ExecuteAsync(() =>
Task.FromResult("Primary Success")
);
Assert.Equal("Primary Success", result);
}
[Fact]
public async Task FallbackCallbackInvokedOnFailure()
{
var callbackInvoked = false;
var policy = Policy<string>
.Handle<Exception>()
.FallbackAsync<string>(
fallbackAction: async (context) =>
{
callbackInvoked = true;
await Task.CompletedTask;
return "Fallback";
}
);
await policy.ExecuteAsync(() =>
throw new Exception("Fail")
);
Assert.True(callbackInvoked);
}
}
Key Takeaways
- Fallback policies provide alternative responses when primary operations fail, enabling graceful degradation.
- Strategies include: default static values, async factories that query cache, or stale-while-revalidate background updates.
- Combine with circuit breaker and retry: Fallback catches failures after retries are exhausted and circuit breaker is open.
- Stale-while-revalidate is a production pattern: serve cached data immediately, revalidate in background.
- Selector-based fallbacks allow different responses for different failure types (timeout vs. network error).
Frequently Asked Questions
Should fallback always return cached data?
No. Fallback can return a static default (e.g., "Service temporarily unavailable"), a degraded response (e.g., recommendations from cache instead of personalized), or null if handling the absence is acceptable. Choose based on your user experience goals.
Can a fallback itself fail?
Yes. If the fallback async factory throws, the exception propagates. Wrap the fallback factory in a try-catch or another Polly policy to handle fallback failures.
How do I maintain cache freshness?
Use a background job or reactive stream to update cache periodically. Monitor cache age and warn users of stale data. For critical data, cache invalidation strategies (TTL, event-based) are essential.
Should I use fallback or return null?
Fallback is better for user-facing operations (return something useful). Null is acceptable for internal plumbing where the caller explicitly expects null on failure. Fallback improves perceived availability.