Who cancels what
Understand the difference between the caller's cancellation token and the attempt token to avoid the most common NResilience integration bug.
TL;DR: In a resilient call, there are two different cancellation tokens: the one provided by the caller and the one provided by the executor for the current attempt. Passing the wrong one to your dependency is a silent bug that disables timeouts and deadlines.
The "Wrong Token" Bug
If you've used a resilience library before, you're used to the standard pattern: you pass a CancellationToken into a method, and that token governs the entire operation.
But in a resilient loop, the rules change. You aren't just making one call; you're potentially making several. This introduces a critical distinction: the whole call vs. one attempt.
If you pass the caller's token into a request that is supposed to be bounded by an AttemptTimeout, you've just created a bug. The caller's token won't fire when the attempt times out - it only fires when the entire operation is cancelled. Your "timeout" will be ignored, and your request will hang until the underlying socket gives up.
The RunAsync Signature
NResilience makes this distinction explicit in the RunAsync signature:
await policy.RunAsync(
attempt =>
{
// Use 'attempt' here - it is the token for this one request
return http.GetAsync(url, attempt);
},
callerToken); // This is the token for the whole operation
There are two tokens here, and they govern different things.
1. The Caller's Token (callerToken)
This is the token you pass into RunAsync. It governs the entire logical operation. If this token is cancelled:
- The current attempt is cancelled (the attempt token is linked to it).
- All future retries are abandoned.
- The call throws
OperationCanceledException- even withTryRunAsync, because caller cancellation is a request to stop, not a failure to classify.
2. The Attempt Token (attempt)
This is the token provided by the executor inside the callback. It governs only the current attempt. This token is cancelled when:
- The
AttemptTimeoutis reached. - The
Deadlineexpires. - The caller's token is cancelled (the attempt token is linked to the caller's).
If this token fires, the executor knows the attempt failed. It classifies the timeout as Transient and moves to the next attempt.
Why this matters: The Zombie Request
Imagine you have an AttemptTimeout of 2 seconds.
The Wrong Way:
You ignore the attempt token and pass the callerToken into your HttpClient call. The 2 seconds pass. NResilience's executor sees the timeout and moves on to the next attempt. But because you didn't pass the attempt token to the HttpClient, the first request is still running in the background. It's a zombie.
If you have 3 attempts, you now have up to 3 abandoned requests in flight against the dependency, all of them hanging.
The Right Way:
You pass the attempt token provided by the callback. When the 2 seconds are up, the executor cancels that specific token. The HttpClient sees the cancellation, aborts the request immediately, and cleans up the socket. The executor then starts the next attempt with a fresh token.
One more safeguard: if a callback overruns its ceiling anyway - because it ignores every token - the library raises an OrphanedWork event when the work finally returns, and analyzers NRES001 and NRES002 catch the wrong-token mistake at compile time.
Summary
When writing your resilient callbacks, remember:
- Outer Token: Controls the entire operation.
- Inner Token: Controls the current attempt.
Always pass the inner token to your dependency. If you don't, your timeouts and deadlines are just suggestions that the library will track, but the dependency will ignore.
The wrong token is one of the most common integration bugs, and it fails silently. The analyzers that catch real bugs lists the compiler diagnostics that catch these mistakes before they reach production.