How it works
A retry policy should describe which failures may be temporary and how many attempts are allowed. The sample uses a dedicated TransientFailure type instead of retrying every exception. The first two calls fail through that category, and the third returns forty-two within the configured three-attempt budget.
The catch filter stops handling the transient exception once the final attempt is reached. If that attempt also fails, its exception propagates to the caller. The loop does not silently return a default result or run forever. maxAttempts includes the initial call, which is an important detail to make clear at the API boundary.
This local exercise omits delays to keep output deterministic and avoid pretending to model network behavior. A production retry policy may need backoff, jitter, a total time budget, cancellation, and provider-specific guidance. Those choices should be based on the actual operation rather than added mechanically to every failure path.
Retries can repeat side effects. Reissuing a payment, append operation, or command may duplicate work even when the first attempt's response was lost. Before retrying, determine whether the operation is idempotent or carries a stable idempotency key recognized by its owner. A transient exception label alone cannot guarantee safe repetition.
Practice with maxAttempts equal to one and with an operation that always fails. Count calls and verify that the final failure is observed. Then throw a different exception category and confirm that the policy does not retry it. Keep the test operation's call counter outside the retry implementation so behavior is easy to inspect.
Finally, add cancellation and an explicit delay strategy as separate dependencies if the real workflow requires them. Test the attempt budget, failure classification, and cancellation boundary independently. A useful retry helper makes a deliberate recovery policy visible; it should not conceal persistent defects or give callers the impression that every operation eventually succeeds.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;
int calls = 0;
int result = await Retry(() => {
calls++;
return calls < 3 ? Task.FromException<int>(new TransientFailure()) : Task.FromResult(42);
}, 3);
Console.WriteLine(result);
Console.WriteLine(calls);
static async Task<int> Retry(Func<Task<int>> action, int maxAttempts) {
if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts));
for (int attempt = 1; ; attempt++) {
try { return await action(); }
catch (TransientFailure) when (attempt < maxAttempts) { }
}
}
class TransientFailure : Exception { }
Expected output
42 3
The filter permits another attempt only for the named failure type and while a budget remains.