< Summary

Information
Class: ArturRios.Util.FlowControl.Retry
Assembly: ArturRios.Util
File(s): /home/runner/work/dotnet-util/dotnet-util/src/FlowControl/Retry.cs
Line coverage
96%
Covered lines: 60
Uncovered lines: 2
Coverable lines: 62
Total lines: 254
Line coverage: 96.7%
Branch coverage
100%
Covered branches: 18
Total branches: 18
Branch coverage: 100%
Method coverage
100%
Covered methods: 14
Fully covered methods: 12
Total methods: 14
Method coverage: 100%
Full method coverage: 85.7%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
get_New()100%11100%
MaxAttempts(...)100%11100%
DelayMilliseconds(...)100%11100%
JitteredBackoff(...)100%11100%
When(...)100%11100%
When()100%22100%
Execute(...)100%11100%
Execute(...)100%66100%
ExecuteAsync()100%1180%
<ExecuteAsync()100%1166.66%
ExecuteAsync()100%66100%
DelayBefore(...)100%22100%
IsWorthRetrying(...)100%22100%

File(s)

/home/runner/work/dotnet-util/dotnet-util/src/FlowControl/Retry.cs

#LineLine coverage
 1using ArturRios.Util.FlowControl.Waiter;
 2
 3namespace ArturRios.Util.FlowControl;
 4
 5/// <summary>
 6/// Provides a simple retry mechanism for executing actions or functions with optional fixed delay between attempts.
 7/// </summary>
 8/// <remarks>
 9/// <para>
 10/// Configure using <see cref="MaxAttempts"/>, <see cref="DelayMilliseconds"/> and <see cref="When"/>, then call
 11/// one of the <c>Execute</c> or <c>ExecuteAsync</c> overloads. The last exception is rethrown once the attempts
 12/// are exhausted.
 13/// </para>
 14/// <para>
 15/// <see cref="MaxAttempts"/> counts total executions, not additional ones: <c>MaxAttempts(3)</c> runs the
 16/// operation at most three times, so at most two retries follow the first attempt.
 17/// </para>
 18/// <para>
 19/// A configured instance is immutable during execution and may be reused and shared across threads. The
 20/// fluent setters mutate the instance, so finish configuring before handing it to another thread.
 21/// </para>
 22/// </remarks>
 23public class Retry
 24{
 5625    private Func<Exception, bool> _shouldRetry = _ => true;
 26    private int _delayMilliseconds;
 2927    private int _maxAttempts = 1;
 28    private int? _backoffCeilingMilliseconds;
 29
 30    /// <summary>
 31    /// Creates a new <see cref="Retry"/> instance. Syntactic sugar for <c>new Retry()</c>.
 32    /// </summary>
 2933    public static Retry New => new();
 34
 35    /// <summary>
 36    /// Sets the total number of times the operation may run before the last exception is rethrown.
 37    /// </summary>
 38    /// <param name="maxAttempts">Total number of executions; must be greater than zero. Defaults to 1, meaning no retry
 39    /// <returns>The configured <see cref="Retry"/> instance.</returns>
 40    /// <exception cref="ArgumentOutOfRangeException"><paramref name="maxAttempts"/> is less than one.</exception>
 41    public Retry MaxAttempts(int maxAttempts)
 42    {
 2043        ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 1);
 44
 1845        _maxAttempts = maxAttempts;
 46
 1847        return this;
 48    }
 49
 50    /// <summary>
 51    /// Sets a fixed delay (in milliseconds) to wait after a failed attempt before retrying.
 52    /// </summary>
 53    /// <param name="delayMilliseconds">Delay duration in milliseconds; must not be negative.</param>
 54    /// <returns>The configured <see cref="Retry"/> instance.</returns>
 55    /// <exception cref="ArgumentOutOfRangeException"><paramref name="delayMilliseconds"/> is negative.</exception>
 56    /// <remarks>Mutually exclusive with <see cref="JitteredBackoff"/>; the last call configured wins.</remarks>
 57    public Retry DelayMilliseconds(int delayMilliseconds)
 58    {
 759        ArgumentOutOfRangeException.ThrowIfNegative(delayMilliseconds);
 60
 661        _delayMilliseconds = delayMilliseconds;
 662        _backoffCeilingMilliseconds = null;
 63
 664        return this;
 65    }
 66
 67    /// <summary>
 68    /// Waits with exponential backoff and jitter between attempts instead of a fixed delay, using the same
 69    /// schedule as <see cref="JitteredWaiter"/>.
 70    /// </summary>
 71    /// <param name="maxWaitMilliseconds">
 72    /// Ceiling applied to a single wait. Defaults to <see cref="JitteredWaiter.DefaultMaxWaitMilliseconds"/>
 73    /// (30 seconds); must be greater than zero.
 74    /// </param>
 75    /// <returns>The configured <see cref="Retry"/> instance.</returns>
 76    /// <exception cref="ArgumentOutOfRangeException"><paramref name="maxWaitMilliseconds"/> is not positive.</exception
 77    /// <remarks>
 78    /// Prefer this over <see cref="DelayMilliseconds"/> when several callers retry the same failing
 79    /// dependency: a fixed delay synchronizes them into repeated thundering herds.
 80    /// </remarks>
 81    public Retry JitteredBackoff(int maxWaitMilliseconds = JitteredWaiter.DefaultMaxWaitMilliseconds)
 82    {
 583        ArgumentOutOfRangeException.ThrowIfLessThan(maxWaitMilliseconds, 1);
 84
 385        _backoffCeilingMilliseconds = maxWaitMilliseconds;
 386        _delayMilliseconds = 0;
 87
 388        return this;
 89    }
 90
 91    /// <summary>
 92    /// Restricts retrying to the exceptions matched by <paramref name="predicate"/>. Anything else is
 93    /// rethrown on the first attempt.
 94    /// </summary>
 95    /// <param name="predicate">Returns true for exceptions worth retrying.</param>
 96    /// <returns>The configured <see cref="Retry"/> instance.</returns>
 97    /// <exception cref="ArgumentNullException"><paramref name="predicate"/> is <c>null</c>.</exception>
 98    /// <remarks>
 99    /// By default every exception is retried except <see cref="OperationCanceledException"/>, which always
 100    /// propagates immediately: retrying a canceled operation defeats the cancellation.
 101    /// </remarks>
 102    public Retry When(Func<Exception, bool> predicate)
 103    {
 3104        ArgumentNullException.ThrowIfNull(predicate);
 105
 2106        _shouldRetry = predicate;
 107
 2108        return this;
 109    }
 110
 111    /// <summary>
 112    /// Restricts retrying to exceptions assignable to <typeparamref name="TException"/>.
 113    /// </summary>
 114    /// <typeparam name="TException">The exception type worth retrying.</typeparam>
 115    /// <returns>The configured <see cref="Retry"/> instance.</returns>
 6116    public Retry When<TException>() where TException : Exception => When(exception => exception is TException);
 117
 118    /// <summary>
 119    /// Executes an <see cref="Action"/> applying the configured retry strategy.
 120    /// </summary>
 121    /// <param name="action">The action to execute.</param>
 122    /// <exception cref="ArgumentNullException"><paramref name="action"/> is <c>null</c>.</exception>
 123    /// <exception cref="Exception">Rethrows the last exception encountered after all attempts fail.</exception>
 124    public void Execute(Action action)
 125    {
 14126        ArgumentNullException.ThrowIfNull(action);
 127
 13128        Execute(() =>
 13129        {
 32130            action();
 13131
 1132            return true;
 13133        });
 1134    }
 135
 136    /// <summary>
 137    /// Executes a function applying the configured retry strategy and returns its result.
 138    /// </summary>
 139    /// <typeparam name="T">Return type of the function.</typeparam>
 140    /// <param name="func">The function to invoke.</param>
 141    /// <returns>The value returned by <paramref name="func"/>.</returns>
 142    /// <exception cref="ArgumentNullException"><paramref name="func"/> is <c>null</c>.</exception>
 143    /// <exception cref="Exception">Rethrows the last exception encountered after all attempts fail.</exception>
 144    public T Execute<T>(Func<T> func)
 145    {
 16146        ArgumentNullException.ThrowIfNull(func);
 147
 15148        var attemptsLeft = _maxAttempts;
 15149        var attempt = 0;
 150
 151        while (true)
 152        {
 153            try
 154            {
 36155                return func();
 156            }
 33157            catch (Exception exception)
 158            {
 33159                attemptsLeft--;
 160
 33161                if (attemptsLeft <= 0 || !IsWorthRetrying(exception))
 162                {
 12163                    throw;
 164                }
 165
 21166                var delay = DelayBefore(attempt++);
 167
 21168                if (delay > 0)
 169                {
 8170                    Thread.Sleep(delay);
 171                }
 21172            }
 173        }
 3174    }
 175
 176    /// <summary>
 177    /// Executes an asynchronous operation applying the configured retry strategy.
 178    /// </summary>
 179    /// <param name="operation">The operation to execute.</param>
 180    /// <param name="cancellationToken">Cancels the delay between attempts and prevents further attempts.</param>
 181    /// <exception cref="ArgumentNullException"><paramref name="operation"/> is <c>null</c>.</exception>
 182    /// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was canceled.</exception>
 183    /// <exception cref="Exception">Rethrows the last exception encountered after all attempts fail.</exception>
 184    public async Task ExecuteAsync(Func<CancellationToken, Task> operation, CancellationToken cancellationToken = defaul
 185    {
 5186        ArgumentNullException.ThrowIfNull(operation);
 187
 4188        await ExecuteAsync(async token =>
 4189        {
 9190            await operation(token).ConfigureAwait(false);
 4191
 0192            return true;
 4193        }, cancellationToken).ConfigureAwait(false);
 0194    }
 195
 196    /// <summary>
 197    /// Executes an asynchronous operation applying the configured retry strategy and returns its result.
 198    /// </summary>
 199    /// <typeparam name="T">Return type of the operation.</typeparam>
 200    /// <param name="operation">The operation to execute.</param>
 201    /// <param name="cancellationToken">Cancels the delay between attempts and prevents further attempts.</param>
 202    /// <returns>The value returned by <paramref name="operation"/>.</returns>
 203    /// <exception cref="ArgumentNullException"><paramref name="operation"/> is <c>null</c>.</exception>
 204    /// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was canceled.</exception>
 205    /// <exception cref="Exception">Rethrows the last exception encountered after all attempts fail.</exception>
 206    public async Task<T> ExecuteAsync<T>(Func<CancellationToken, Task<T>> operation, CancellationToken cancellationToken
 207    {
 6208        ArgumentNullException.ThrowIfNull(operation);
 209
 5210        var attemptsLeft = _maxAttempts;
 5211        var attempt = 0;
 212
 213        while (true)
 214        {
 13215            cancellationToken.ThrowIfCancellationRequested();
 216
 217            try
 218            {
 12219                return await operation(cancellationToken).ConfigureAwait(false);
 220            }
 221            catch (Exception exception)
 222            {
 11223                attemptsLeft--;
 224
 11225                if (attemptsLeft <= 0 || !IsWorthRetrying(exception))
 226                {
 2227                    throw;
 228                }
 229
 9230                var delay = DelayBefore(attempt++);
 231
 9232                if (delay > 0)
 233                {
 6234                    await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 235                }
 5236            }
 237        }
 1238    }
 239
 240    /// <summary>
 241    /// Returns how long to wait before the retry that follows a zero-based failed attempt.
 242    /// </summary>
 243    private int DelayBefore(int attempt) =>
 30244        _backoffCeilingMilliseconds is { } ceiling ? Backoff.DelayFor(attempt, ceiling) : _delayMilliseconds;
 245
 246    /// <summary>
 247    /// Decides whether an exception should trigger another attempt.
 248    /// </summary>
 249    /// <remarks>
 250    /// Cancellation is never retried, whatever the configured predicate says.
 251    /// </remarks>
 252    private bool IsWorthRetrying(Exception exception) =>
 32253        exception is not OperationCanceledException && _shouldRetry(exception);
 254}