Search code examples
c#.netrate-limitingpollyrategate

RateGate class to Polly policy


I'm trying to replace the RateGate logic with a Polly policy. However, there is no status code or anything and I'm not sure if it's possible achieve the same idea but with Polly.

Usage

// Binance allows 5 messages per second, but we still get rate limited if we send a lot of messages at that rate
// By sending 3 messages per second, evenly spaced out, we can keep sending messages without being limited
private readonly RateGate _webSocketRateLimiter = new RateGate(1, TimeSpan.FromMilliseconds(330));

private void Send(IWebSocket webSocket, object obj)
{
    var json = JsonConvert.SerializeObject(obj);

    _webSocketRateLimiter.WaitToProceed();

    Log.Trace("Send: " + json);

    webSocket.Send(json);
}

RateGate class

public class RateGate : IDisposable
{
    // Semaphore used to count and limit the number of occurrences per
    // unit time.
    private readonly SemaphoreSlim _semaphore;

    // Times (in millisecond ticks) at which the semaphore should be exited.
    private readonly ConcurrentQueue<int> _exitTimes;

    // Timer used to trigger exiting the semaphore.
    private readonly Timer _exitTimer;

    // Whether this instance is disposed.
    private bool _isDisposed;

    /// <summary>
    /// Number of occurrences allowed per unit of time.
    /// </summary>
    public int Occurrences
    {
        get; private set;
    }

    /// <summary>
    /// The length of the time unit, in milliseconds.
    /// </summary>
    public int TimeUnitMilliseconds
    {
        get; private set;
    }

    /// <summary>
    /// Flag indicating we are currently being rate limited
    /// </summary>
    public bool IsRateLimited
    {
        get { return !WaitToProceed(0); }
    }

    /// <summary>
    /// Initializes a <see cref="RateGate"/> with a rate of <paramref name="occurrences"/>
    /// per <paramref name="timeUnit"/>.
    /// </summary>
    /// <param name="occurrences">Number of occurrences allowed per unit of time.</param>
    /// <param name="timeUnit">Length of the time unit.</param>
    /// <exception cref="ArgumentOutOfRangeException">
    /// If <paramref name="occurrences"/> or <paramref name="timeUnit"/> is negative.
    /// </exception>
    public RateGate(int occurrences, TimeSpan timeUnit)
    {
        // Check the arguments.
        if (occurrences <= 0)
            throw new ArgumentOutOfRangeException(nameof(occurrences), "Number of occurrences must be a positive integer");
        if (timeUnit != timeUnit.Duration())
            throw new ArgumentOutOfRangeException(nameof(timeUnit), "Time unit must be a positive span of time");
        if (timeUnit >= TimeSpan.FromMilliseconds(UInt32.MaxValue))
            throw new ArgumentOutOfRangeException(nameof(timeUnit), "Time unit must be less than 2^32 milliseconds");

        Occurrences = occurrences;
        TimeUnitMilliseconds = (int)timeUnit.TotalMilliseconds;

        // Create the semaphore, with the number of occurrences as the maximum count.
        _semaphore = new SemaphoreSlim(Occurrences, Occurrences);

        // Create a queue to hold the semaphore exit times.
        _exitTimes = new ConcurrentQueue<int>();

        // Create a timer to exit the semaphore. Use the time unit as the original
        // interval length because that's the earliest we will need to exit the semaphore.
        _exitTimer = new Timer(ExitTimerCallback, null, TimeUnitMilliseconds, -1);
    }

    // Callback for the exit timer that exits the semaphore based on exit times
    // in the queue and then sets the timer for the nextexit time.
    // Credit to Jim: http://www.jackleitch.net/2010/10/better-rate-limiting-with-dot-net/#comment-3620
    // for providing the code below, fixing issue #3499 - https://github.com/QuantConnect/Lean/issues/3499
    private void ExitTimerCallback(object state)
    {
        try
        {
            // While there are exit times that are passed due still in the queue,
            // exit the semaphore and dequeue the exit time.
            var exitTime = 0;
            var exitTimeValid = _exitTimes.TryPeek(out exitTime);
            while (exitTimeValid)
            {
                if (unchecked(exitTime - Environment.TickCount) > 0)
                {
                    break;
                }
                _semaphore.Release();
                _exitTimes.TryDequeue(out exitTime);
                exitTimeValid = _exitTimes.TryPeek(out exitTime);
            }
            // we are already holding the next item from the queue, do not peek again
            // although this exit time may have already pass by this stmt.
            var timeUntilNextCheck = exitTimeValid
                ? Math.Min(TimeUnitMilliseconds, Math.Max(0, exitTime - Environment.TickCount))
                : TimeUnitMilliseconds;

            _exitTimer.Change(timeUntilNextCheck, -1);
        }
        catch (Exception)
        {
            // can throw if called when disposing
        }
    }

    /// <summary>
    /// Blocks the current thread until allowed to proceed or until the
    /// specified timeout elapses.
    /// </summary>
    /// <param name="millisecondsTimeout">Number of milliseconds to wait, or -1 to wait indefinitely.</param>
    /// <returns>true if the thread is allowed to proceed, or false if timed out</returns>
    public bool WaitToProceed(int millisecondsTimeout)
    {
        // Check the arguments.
        if (millisecondsTimeout < -1)
            throw new ArgumentOutOfRangeException(nameof(millisecondsTimeout));

        CheckDisposed();

        // Block until we can enter the semaphore or until the timeout expires.
        var entered = _semaphore.Wait(millisecondsTimeout);

        // If we entered the semaphore, compute the corresponding exit time
        // and add it to the queue.
        if (entered)
        {
            var timeToExit = unchecked(Environment.TickCount + TimeUnitMilliseconds);
            _exitTimes.Enqueue(timeToExit);
        }

        return entered;
    }

    /// <summary>
    /// Blocks the current thread until allowed to proceed or until the
    /// specified timeout elapses.
    /// </summary>
    /// <param name="timeout"></param>
    /// <returns>true if the thread is allowed to proceed, or false if timed out</returns>
    public bool WaitToProceed(TimeSpan timeout)
    {
        return WaitToProceed((int)timeout.TotalMilliseconds);
    }

    /// <summary>
    /// Blocks the current thread indefinitely until allowed to proceed.
    /// </summary>
    public void WaitToProceed()
    {
        WaitToProceed(Timeout.Infinite);
    }

    // Throws an ObjectDisposedException if this object is disposed.
    private void CheckDisposed()
    {
        if (_isDisposed)
            throw new ObjectDisposedException("RateGate is already disposed");
    }

    /// <summary>
    /// Releases unmanaged resources held by an instance of this class.
    /// </summary>
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Releases unmanaged resources held by an instance of this class.
    /// </summary>
    /// <param name="isDisposing">Whether this object is being disposed.</param>
    protected virtual void Dispose(bool isDisposing)
    {
        if (!_isDisposed)
        {
            if (isDisposing)
            {
                // The semaphore and timer both implement IDisposable and
                // therefore must be disposed.
                _semaphore.Dispose();
                _exitTimer.Dispose();

                _isDisposed = true;
            }
        }
    }
}

GitHub source code


Solution

  • Rate gate

    Disclaimer: I haven't used this component so what I describe here is what I understand from the code.

    It is an intrusive policy which means it modifies/alters the execution/data flow in order to slow down fast producer or smoothen out burst. It is blocking the flow to avoid resource abuse.

    Here you can specify the "sleep duration" between subsequent calls which is enforced by the gate itself.

    Polly's Rate limiter

    This policy is designed to avoid resource abuse as well. That means if the consumer issues too many requests against the resource under a predefined time then it simply shortcuts the execution by throwing a RateLimitRejectedException.

    So, if you want to allow 20 executions under 1 minute

    RateLimitPolicy rateLimiter = Policy
        .RateLimit(20, TimeSpan.FromSeconds(1));
    

    and you do not want to exceed the limit you have to wait by yourself

    rateLimiter.Execute(() =>
    {
        //Your Action delegate which runs <1ms
        Thread.Sleep(50);
    });
    

    So, the executions should be distributed evenly during the allowed period. If your manually injected delay is shorter let's say 10ms then it will throw an exception.

    Conclusion

    According to my understanding both works like a proxy object. They are sitting between the consumer and producer to control the consumption rate.

    The rate gate does that by injecting artificial delays whereas the rate limiter shortcuts the execution if abuse is detected.