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
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.CancellationTokenwith 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 anOperationCanceledExceptioncarrying 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:
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
OperationCanceledExceptionassociated with the previous/caller token, while continuing to throwTimeoutRejectedExceptionwhen the timeout itself fires.Expected behavior
If cancellation originates from the token passed to
ExecuteAsync(..., cancellationToken), theOperationCanceledExceptionescapingExecuteAsyncshould carry that same caller token, or Polly should otherwise document that callers must not rely onOperationCanceledException.CancellationTokenidentity 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
TimeoutRejectedExceptionmakes 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
ExecuteAsyncincorrectly carries the internal callback token rather than the caller token passed toExecuteAsync.Steps to reproduce
Here's a minimal test (using TUnit) that reproduces the issue.
The final assertion fails because
ex.CancellationTokenis not equal tocts.Token.Polly version
8.6.6
.NET Version
net10.0