Skip to main content

Timeout Policies in Polly: Configuration Best Practices

A timeout policy enforces a maximum execution time on an operation. If an operation exceeds the limit, Polly cancels it and throws a TimeoutRejectedException, freeing blocked threads. Timeout policies are critical for preventing cascading thread pool exhaustion in microservices where a downstream service might hang for hours. Polly offers two timeout strategies: pessimistic (hard cancellation) and optimistic (cooperative cancellation via CancellationToken).

Pessimistic Timeout (Hard Timeout)

Pessimistic timeout forcibly aborts the operation after the duration, regardless of whether the operation respects cancellation. It uses a timer that throws an exception:

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

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

using var client = new HttpClient();
try
{
var response = await timeoutPolicy.ExecuteAsync(async () =>
{
// This call must complete within 5 seconds or throw TimeoutRejectedException
return await client.GetAsync("https://slow-api.example.com/data");
});
Console.WriteLine("Response received");
}
catch (Polly.Timeout.TimeoutRejectedException)
{
Console.WriteLine("Request timed out after 5 seconds");
}

How it works:

  • Polly starts a 5-second timer.
  • If the operation completes before 5 seconds, the result is returned.
  • If the operation is still running at 5 seconds, Polly throws TimeoutRejectedException.
  • The underlying operation may still be running; only the Polly policy aborts.

Real-world scenario: A remote API hangs without responding. Your thread waits indefinitely. With a pessimistic timeout of 5 seconds, after 5 seconds Polly throws, your code catches it, logs it, and moves on. The HTTP request continues in the background, but your thread is no longer blocked.

Caveat: If the operation doesn't respect the exception, the underlying work continues. This wastes server resources but unblocks your thread.

Optimistic Timeout (Cooperative Cancellation)

Optimistic timeout issues a CancellationToken to the operation, requesting cancellation. The operation must check the token and exit gracefully. This is the preferred approach for async operations:

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

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

using var client = new HttpClient();
try
{
var response = await timeoutPolicy.ExecuteAsync(
async (cancellationToken) =>
{
// HttpClient respects CancellationToken; operation cancels cleanly
return await client.GetAsync(
"https://api.example.com/data",
cancellationToken
);
}
);
Console.WriteLine("Response received");
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation cancelled due to timeout");
}

Why optimistic is better:

  • The operation can clean up gracefully: close connections, release resources, flush buffers.
  • No orphaned background work; the operation genuinely stops.
  • OperationCanceledException is thrown instead of a custom exception (standard .NET pattern).

Timeout with Fallback Strategy

Combine timeout with fallback to return a cached or degraded response instead of failing:

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

var timeoutPolicy = Policy
.TimeoutAsync<string>(TimeSpan.FromSeconds(3));

var fallbackPolicy = Policy<string>
.Handle<OperationCanceledException>()
.FallbackAsync(
fallbackAction: async (context) =>
{
Console.WriteLine("Timeout occurred. Returning cached data.");
await Task.CompletedTask;
return "Cached: [Last known data from 5 minutes ago]";
}
);

// Wrap: timeout first, fallback catches the timeout exception
var combinedPolicy = Policy.WrapAsync(timeoutPolicy, fallbackPolicy);

var result = await combinedPolicy.ExecuteAsync(async () =>
{
using var client = new HttpClient();
var response = await client.GetStringAsync("https://slow-api.example.com/data");
return response;
});

Console.WriteLine(result);

Result: If the API takes more than 3 seconds, timeout fires, fallback catches it, and the user gets cached data instead of an error.

Timeout Configuration Patterns for Real Services

Different services need different timeouts. Use this table to guide configuration:

Service TypeTypical TimeoutReasoning
Local database query2-5 secondsRuns in-process or on LAN; should be fast.
Internal microservice5-10 secondsSame data center; some network latency.
Third-party API10-30 secondsOver the internet; higher latency and variability.
Batch/reporting job60+ secondsIntentionally long operations; may process large datasets.
Message queue operation5-15 secondsEnqueue/dequeue is fast; processing is not timed here.

Best practice: Measure your 95th and 99th percentile response times, then set timeout to ~2-3× the 95th percentile. This catches genuine hangs without timing out slow-but-legitimate requests.

Testing Timeouts

Unit tests verify that timeouts fire at the expected duration:

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

public class TimeoutTests
{
[Fact]
public async Task TimeoutFiresAfterDuration()
{
var policy = Policy
.TimeoutAsync(
TimeSpan.FromMilliseconds(100),
timeoutStrategy: TimeoutStrategy.Optimistic
);

var stopwatch = Stopwatch.StartNew();

await Assert.ThrowsAsync<OperationCanceledException>(
() => policy.ExecuteAsync(async (cancellationToken) =>
{
await Task.Delay(1000, cancellationToken); // Waits 1 second
})
);

stopwatch.Stop();

// Timeout should fire at ~100ms, not 1000ms
Assert.InRange(stopwatch.ElapsedMilliseconds, 80, 150);
}

[Fact]
public async Task NoTimeoutIfOperationCompletesFast()
{
var policy = Policy
.TimeoutAsync<string>(
TimeSpan.FromSeconds(5),
timeoutStrategy: TimeoutStrategy.Optimistic
);

var result = await policy.ExecuteAsync(async (cancellationToken) =>
{
await Task.Delay(100, cancellationToken);
return "Completed";
});

Assert.Equal("Completed", result);
}
}

Timeout with Retry: Ordering Matters

When combining timeout and retry, the order matters:

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

// Wrap in this order: timeout wraps retry
var retryPolicy = Policy
.Handle<OperationCanceledException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromMilliseconds(100)
);

var timeoutPolicy = Policy
.TimeoutAsync(
TimeSpan.FromSeconds(2),
timeoutStrategy: TimeoutStrategy.Optimistic
);

// Timeout (outer) → Retry (inner)
var combined = Policy.WrapAsync(timeoutPolicy, retryPolicy);

await combined.ExecuteAsync(async (cancellationToken) =>
{
// Each retry has a 2-second timeout
await Task.Delay(500, cancellationToken);
Console.WriteLine("Operation succeeded");
});

Why this order? Each retry attempt gets its own timeout window. If you reverse the order, the total timeout would apply to all retries combined, which is usually not what you want.

Key Takeaways

  • Pessimistic timeout forcibly aborts; use only if the operation doesn't support cancellation.
  • Optimistic timeout (preferred) passes a CancellationToken; the operation cancels cleanly.
  • Timeout duration should be 2-3× your 95th percentile response time to avoid false positives.
  • Combine with fallback to return degraded but useful responses instead of errors.
  • Order matters when combining timeout with retry: timeout should wrap retry so each attempt times out independently.

Frequently Asked Questions

Should I set a timeout on every HTTP call?

Yes. Always set a timeout on external calls (HTTP, database, message queue). Timeouts protect against hanging connections and thread pool exhaustion. The default HttpClient timeout is infinite, which is dangerous; override it explicitly.

What is the difference between timeout and CancellationToken.CancelAfter?

CancellationToken.CancelAfter cancels a token after a duration; you must pass it to the operation. Polly timeout is simpler and works with any async method; Polly handles the token internally. Use Polly timeout for consistency across your codebase.

Can a timeout be rolled back or recovered?

No. Once a timeout fires, the operation is cancelled or aborted. Use fallback policies to handle the cancellation gracefully. The underlying work may still be running, so cleanup is important in your code.

Should timeout be the same for all requests or vary by endpoint?

Vary by endpoint. Endpoints that query a single row should timeout faster (2-5 seconds) than batch exports (60+ seconds). Document timeout values in your API contract or configuration.

Further Reading