|
| 1 | +// Copyright (c) One Identity LLC. All rights reserved. |
| 2 | + |
| 3 | +namespace OneIdentity.SafeguardDotNet.Event; |
| 4 | + |
| 5 | +using System; |
| 6 | + |
| 7 | +/// <summary> |
| 8 | +/// Exponential reconnect backoff with multiplicative ±25% jitter and a 60-second cap. |
| 9 | +/// </summary> |
| 10 | +/// <remarks> |
| 11 | +/// <para> |
| 12 | +/// Every persistent event listener should cap its reconnect frequency so that |
| 13 | +/// sustained appliance downtime or a network partition cannot turn the reconnect |
| 14 | +/// loop into a resource-exhaustion vector against either the calling process or |
| 15 | +/// the appliance. |
| 16 | +/// </para> |
| 17 | +/// <para> |
| 18 | +/// Algorithm: |
| 19 | +/// <code> |
| 20 | +/// delay_n = min(60s, 2^n * 1s) for n = 0, 1, 2, ... |
| 21 | +/// actual_n = delay_n * (0.75 + 0.5 * rng()) // ±25% multiplicative jitter |
| 22 | +/// </code> |
| 23 | +/// where <c>rng()</c> returns a value in <c>[0.0, 1.0)</c>. The internal counter |
| 24 | +/// <c>n</c> advances on every call to <see cref="GetNextDelay"/> and resets to |
| 25 | +/// zero on <see cref="OnSuccess"/>. The cap takes effect at n = 6 (2^6 = 64 > 60). |
| 26 | +/// </para> |
| 27 | +/// <para> |
| 28 | +/// The jitter source is injectable so callers and unit tests can substitute a |
| 29 | +/// deterministic function for the default <see cref="Random"/>-based source. |
| 30 | +/// Values returned outside <c>[0.0, 1.0]</c> are clamped to that range so a |
| 31 | +/// misbehaving RNG cannot produce negative or unbounded delays. |
| 32 | +/// </para> |
| 33 | +/// </remarks> |
| 34 | +internal sealed class ReconnectBackoff |
| 35 | +{ |
| 36 | + /// <summary>Initial delay before the first retry (n = 0), in seconds.</summary> |
| 37 | + public const double InitialDelaySeconds = 1.0; |
| 38 | + |
| 39 | + /// <summary>Maximum delay between retries, in seconds.</summary> |
| 40 | + public const double MaxDelaySeconds = 60.0; |
| 41 | + |
| 42 | + /// <summary>Half-width of the multiplicative jitter band (0.25 = ±25%).</summary> |
| 43 | + public const double JitterFraction = 0.25; |
| 44 | + |
| 45 | + private static readonly Random DefaultRandom = new Random(); |
| 46 | + |
| 47 | + private readonly Func<double> _jitterSource; |
| 48 | + private readonly object _lock = new object(); |
| 49 | + |
| 50 | + private int _attempt; |
| 51 | + |
| 52 | + /// <summary> |
| 53 | + /// Initializes a new instance of the <see cref="ReconnectBackoff"/> class |
| 54 | + /// using a shared thread-safe default jitter source. |
| 55 | + /// </summary> |
| 56 | + public ReconnectBackoff() |
| 57 | + : this(DefaultJitter) |
| 58 | + { |
| 59 | + } |
| 60 | + |
| 61 | + /// <summary> |
| 62 | + /// Initializes a new instance of the <see cref="ReconnectBackoff"/> class |
| 63 | + /// with an injected jitter source. Provided primarily for deterministic |
| 64 | + /// unit testing of the algorithm. |
| 65 | + /// </summary> |
| 66 | + /// <param name="jitterSource"> |
| 67 | + /// A function returning a value in <c>[0.0, 1.0)</c>. Values outside that |
| 68 | + /// range are clamped. Must not be <c>null</c>. |
| 69 | + /// </param> |
| 70 | + public ReconnectBackoff(Func<double> jitterSource) |
| 71 | + { |
| 72 | + _jitterSource = jitterSource ?? throw new ArgumentNullException(nameof(jitterSource)); |
| 73 | + } |
| 74 | + |
| 75 | + /// <summary> |
| 76 | + /// Computes the next delay and advances the internal attempt counter. |
| 77 | + /// </summary> |
| 78 | + /// <returns>A <see cref="TimeSpan"/> with the delay to wait before the next reconnect attempt.</returns> |
| 79 | + public TimeSpan GetNextDelay() |
| 80 | + { |
| 81 | + int currentAttempt; |
| 82 | + lock (_lock) |
| 83 | + { |
| 84 | + currentAttempt = _attempt; |
| 85 | + _attempt++; |
| 86 | + } |
| 87 | + |
| 88 | + var baseSeconds = ComputeBaseDelaySeconds(currentAttempt); |
| 89 | + var raw = _jitterSource(); |
| 90 | + var clamped = ClampUnitInterval(raw); |
| 91 | + var jitterMultiplier = 1.0 - JitterFraction + (2.0 * JitterFraction * clamped); |
| 92 | + return TimeSpan.FromSeconds(baseSeconds * jitterMultiplier); |
| 93 | + } |
| 94 | + |
| 95 | + /// <summary> |
| 96 | + /// Resets the attempt counter so the next call to <see cref="GetNextDelay"/> |
| 97 | + /// returns an n = 0 (≈1 second) delay. Call this immediately after a |
| 98 | + /// successful reconnect. |
| 99 | + /// </summary> |
| 100 | + public void OnSuccess() |
| 101 | + { |
| 102 | + lock (_lock) |
| 103 | + { |
| 104 | + _attempt = 0; |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + private static double ComputeBaseDelaySeconds(int attempt) |
| 109 | + { |
| 110 | + // 2^attempt * InitialDelaySeconds, capped at MaxDelaySeconds. |
| 111 | + // attempt is clamped at 30 to avoid double overflow even though the cap |
| 112 | + // engages long before that point. |
| 113 | + var safeAttempt = ClampAttempt(attempt); |
| 114 | + var doubled = InitialDelaySeconds * Math.Pow(2.0, safeAttempt); |
| 115 | + return doubled > MaxDelaySeconds ? MaxDelaySeconds : doubled; |
| 116 | + } |
| 117 | + |
| 118 | + private static int ClampAttempt(int attempt) |
| 119 | + { |
| 120 | + if (attempt < 0) |
| 121 | + { |
| 122 | + return 0; |
| 123 | + } |
| 124 | + |
| 125 | + if (attempt > 30) |
| 126 | + { |
| 127 | + return 30; |
| 128 | + } |
| 129 | + |
| 130 | + return attempt; |
| 131 | + } |
| 132 | + |
| 133 | + private static double ClampUnitInterval(double value) |
| 134 | + { |
| 135 | + if (value < 0.0) |
| 136 | + { |
| 137 | + return 0.0; |
| 138 | + } |
| 139 | + |
| 140 | + if (value > 1.0) |
| 141 | + { |
| 142 | + return 1.0; |
| 143 | + } |
| 144 | + |
| 145 | + return value; |
| 146 | + } |
| 147 | + |
| 148 | + private static double DefaultJitter() |
| 149 | + { |
| 150 | + // Random is not thread-safe in netstandard2.0. Synchronize on the |
| 151 | + // shared instance so concurrent reconnect loops can share one RNG. |
| 152 | + lock (DefaultRandom) |
| 153 | + { |
| 154 | + return DefaultRandom.NextDouble(); |
| 155 | + } |
| 156 | + } |
| 157 | +} |
0 commit comments