< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.AsyncResponseRetry
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/AsyncResponseRetry.cs
Line coverage
100%
Covered lines: 25
Uncovered lines: 0
Coverable lines: 25
Total lines: 80
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExecuteAsync()100%44100%
IsTransientOrThrow(...)100%11100%
Backoff(...)100%11100%

File(s)

/_/src/AsyncResponse.Core/AsyncResponseRetry.cs

#LineLine coverage
 1namespace AsyncResponse;
 2
 3internal static class AsyncResponseRetry
 4{
 5    /// <summary>Runs this background operation until cancellation is requested.</summary>
 6    public static async Task<T> ExecuteAsync<T>(
 7        Func<CancellationToken, Task<T>> action,
 8        Func<Exception, bool> isTransient,
 9        int maxAttempts,
 10        TimeSpan baseDelay,
 11        TimeSpan maxDelay,
 12        CancellationToken cancellationToken,
 13        TimeProvider? timeProvider = null)
 14    {
 633115        ArgumentNullException.ThrowIfNull(action);
 633116        ArgumentNullException.ThrowIfNull(isTransient);
 633117        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxAttempts);
 18
 633119        var attempt = 0;
 20        while (true)
 21        {
 644122            cancellationToken.ThrowIfCancellationRequested();
 644123            attempt++;
 24
 25            try
 26            {
 644127                return await action(cancellationToken).ConfigureAwait(false);
 28            }
 29            // The filter deliberately tests only the cheap, throw-free attempt count. isTransient
 30            // is caller-supplied and runs in the BODY below: the CLR swallows an exception thrown
 31            // inside an exception filter and evaluates the filter as false, so a predicate that
 32            // faults (a null-deref on ex.InnerException, say) would silently reclassify every
 33            // retryable fault as permanent and burn the whole retry budget with its own bug
 34            // invisible in every log and trace.
 19135            catch (Exception ex) when (attempt < maxAttempts)
 36            {
 16537                if (!IsTransientOrThrow(isTransient, ex))
 5338                    throw;
 39
 11040                await Task.Delay(Backoff(attempt, baseDelay, maxDelay), timeProvider ?? TimeProvider.System, cancellatio
 11041            }
 42        }
 625043    }
 44
 45    /// <summary>
 46    /// Evaluates the caller's transience predicate OUTSIDE an exception filter, so a predicate
 47    /// that throws surfaces instead of being silently read as "not transient". Both failures are
 48    /// raised together: the predicate's own fault (the bug to fix) and the exception it was
 49    /// judging (the reason the retry ran at all), so neither is lost. Only reachable when the
 50    /// predicate itself is broken; a well-behaved one returns and this is a plain call.
 51    /// </summary>
 52    private static bool IsTransientOrThrow(Func<Exception, bool> isTransient, Exception ex)
 53    {
 54        try
 55        {
 16556            return isTransient(ex);
 57        }
 258        catch (Exception predicateFailure)
 59        {
 260            throw new AggregateException(
 261                "The retry policy's isTransient predicate threw while classifying a fault. The predicate's own failure a
 262                "exception it was judging are both attached; fix the predicate — until then no fault can be classified a
 263                ex,
 264                predicateFailure);
 65        }
 16366    }
 67
 68    /// <summary>Computes the retry backoff delay: exponential with half-jitter.</summary>
 69    public static TimeSpan Backoff(int completedAttempts, TimeSpan baseDelay, TimeSpan maxDelay)
 70    {
 41271        var multiplier = 1 << Math.Min(Math.Max(completedAttempts, 1) - 1, 10);
 41272        var milliseconds = Math.Min(maxDelay.TotalMilliseconds, baseDelay.TotalMilliseconds * multiplier);
 73
 74        // Half-jitter: keep at least half the exponential step so backoff still backs off, and
 75        // randomize the rest — a broker blip fails many waiters across many replicas at once, and
 76        // un-jittered exponential delays would send them all reconnecting in lockstep waves.
 41277        milliseconds = milliseconds / 2 + Random.Shared.NextDouble() * (milliseconds / 2);
 41278        return TimeSpan.FromMilliseconds(Math.Max(1, milliseconds));
 79    }
 80}