Skip to content

[Bug]: Timeout strategy does not propagate correct CancellationToken #3086

Description

@DaRosenberg

Describe the bug

This is similar to the stale issue #934, but since it's been 4 years and we are now talking about v8 resilience pipelines, I am choosing to file a new issue.

When a timeout strategy is present in a pipeline, Polly replaces the ResilienceContext.CancellationToken with an internal timeout token before invoking the user callback. It also registers the caller's original token so that cancelling the caller token cancels the internal timeout token. If the caller token is cancelled, downstream code observes cancellation on Polly's internal token and throws an OperationCanceledException carrying that internal token. Polly then propagates that original exception without restoring or translating the token back to the caller's original token.

This makes it difficult for calling code to distinguish caller-initiated cancellation from other failures (for example timeouts) by inspecting OperationCanceledException.CancellationToken.

Consider the following very common pattern:

try
{
	await pipeline.ExecuteAsync(callback, callerCancellationToken);
}
catch (OperationCanceledException ex) when (ex.CancellationToken == callerCancellationToken)
{
	throw;
}
catch (Exception ex)
{
	throw new ApplicationException("Operation failed.", ex);
}

In this case the cancellation really did come from callerCancellationToken, but the exception filter fails because the exception carries Polly's internal token instead. This behavior leaks an implementation detail of the timeout strategy into the exception observed by the caller. The caller's cancellation token caused the operation to cancel, but the escaping exception does not carry that token.

That is especially problematic for libraries that need to distinguish cancellation from genuine operation failures. A very common pattern is to let caller cancellation pass through unchanged while wrapping other exceptions with domain-specific context. If token identity is not preserved across Polly's timeout strategy, that pattern becomes unreliable even though the cancellation originated upstream.

The correct behavior would be for timeout strategies to translate upstream-triggered internal-token cancellation back into an OperationCanceledException associated with the previous/caller token, while continuing to throw TimeoutRejectedException when the timeout itself fires.

Expected behavior

If cancellation originates from the token passed to ExecuteAsync(..., cancellationToken), the OperationCanceledException escaping ExecuteAsync should carry that same caller token, or Polly should otherwise document that callers must not rely on OperationCanceledException.CancellationToken identity after executing through strategies such as timeout.

Note: Timeouts internal to Polly are different. If the timeout strategy itself causes cancellation while the caller token is not cancelled, the current behavior of throwing TimeoutRejectedException makes sense. The surprising case is caller-token cancellation routed through Polly's internal linked timeout token.

Actual behavior

When caller cancellation causes Polly's internal timeout token to be cancelled, the exception escaping ExecuteAsync incorrectly carries the internal callback token rather than the caller token passed to ExecuteAsync.

Steps to reproduce

Here's a minimal test (using TUnit) that reproduces the issue.

using Polly;

public class PollyCancellationTokenTests
{
	[Test]
	public async Task CallerCancellationThroughTimeoutStrategyPropagatesOriginalTokenAsync()
	{
		var pipeline =
			new ResiliencePipelineBuilder()
				.AddTimeout(TimeSpan.FromMinutes(1))
				.Build();

		using var cts = new CancellationTokenSource();

		var ex = await Assert
			.That(async () =>
			{
				await pipeline.ExecuteAsync(async cancellationToken =>
				{
					await cts.CancelAsync(); // Simulate cancellation request from upstream caller
					cancellationToken.ThrowIfCancellationRequested(); // Simulate cancellation response from downstream code
				}, cts.Token);
			})
			.Throws<OperationCanceledException>();

		await Assert
			.That(ex.CancellationToken)
			.IsEqualTo(cts.Token)
			.Because("CancellationToken that triggered the cancellation should be on the resulting exception.");
	}
}

The final assertion fails because ex.CancellationToken is not equal to cts.Token.

Expected to be equal to System.Threading.CancellationToken
but received System.Threading.CancellationToken

at Assert.That(ex.CancellationToken).IsEqualTo(cts.Token)

Polly version

8.6.6

.NET Version

net10.0

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions