Skip to main content

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:

  • FallbackAsync catches the exception.
  • onFallbackAsync callback 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:

  1. Retry fires, attempting up to 2 times.
  2. Circuit breaker checks state and allows or blocks the call.
  3. Primary operation runs (HTTP GET).
  4. If all retries fail and circuit is open, fallback triggers.
  5. 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.

Further Reading