Skip to content
87 changes: 87 additions & 0 deletions PowerKit.Tests/ThrottleLockTests.cs
Comment thread
Tyrrrz marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using PowerKit;
using Xunit;

namespace PowerKit.Tests;

public class ThrottleLockTests
{
[Fact]
public async Task WaitAsync_FirstCall_DoesNotDelay_Test()
{
// Arrange
using var throttle = new ThrottleLock(TimeSpan.FromSeconds(1));
var sw = Stopwatch.StartNew();

// Act
await throttle.WaitAsync();

// Assert: first call should not be delayed
sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(500));
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
}

[Fact]
public async Task WaitAsync_SubsequentCall_IsThrottled_Test()
{
// Arrange
var interval = TimeSpan.FromMilliseconds(200);
using var throttle = new ThrottleLock(interval);
await throttle.WaitAsync();

var sw = Stopwatch.StartNew();

// Act
await throttle.WaitAsync();

// Assert: second call must have waited at least the interval
sw.Elapsed.Should().BeGreaterThanOrEqualTo(interval - TimeSpan.FromMilliseconds(50));
}

[Fact]
public async Task WaitAsync_AfterIntervalElapsed_DoesNotDelay_Test()
{
// Arrange
var interval = TimeSpan.FromMilliseconds(100);
using var throttle = new ThrottleLock(interval);
await throttle.WaitAsync();

// Wait longer than the interval so the next call should not be throttled
await Task.Delay(interval + TimeSpan.FromMilliseconds(100));

var sw = Stopwatch.StartNew();

// Act
await throttle.WaitAsync();

// Assert: call after interval elapsed should not be significantly delayed
sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(100));
}
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated

[Fact]
public async Task WaitAsync_Cancelled_ThrowsOperationCancelledException_Test()
{
// Arrange
using var throttle = new ThrottleLock(TimeSpan.FromSeconds(10));
using var cts = new CancellationTokenSource();
cts.Cancel();

// Act & assert
var act = async () => await throttle.WaitAsync(cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}

[Fact]
public void Dispose_Test()
{
// Arrange
var throttle = new ThrottleLock(TimeSpan.FromSeconds(1));

// Act & assert
var act = throttle.Dispose;
act.Should().NotThrow();
}
}
48 changes: 48 additions & 0 deletions PowerKit/ThrottleLock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#if NET40_OR_GREATER || NETSTANDARD || NET
#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;

namespace PowerKit;

/// <summary>
/// Represents a lock that enforces a minimum interval between consecutive acquisitions,
/// ensuring that operations do not proceed faster than the specified rate.
/// </summary>
#if !POWERKIT_INCLUDE_COVERAGE
[ExcludeFromCodeCoverage]
#endif
internal sealed class ThrottleLock(TimeSpan interval) : IDisposable
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private DateTimeOffset _lastRequestInstant = DateTimeOffset.MinValue;
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated

/// <summary>
/// Asynchronously waits until the throttle interval has elapsed since the last acquisition,
/// then records the current instant as the new last-request time.
/// </summary>
public async Task WaitAsync(CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);

try
{
var now = DateTimeOffset.Now;
var remaining = interval - (now - _lastRequestInstant);
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
if (remaining > TimeSpan.Zero)
await Task.Delay(remaining, cancellationToken).ConfigureAwait(false);

_lastRequestInstant = DateTimeOffset.Now;
Comment thread
Tyrrrz marked this conversation as resolved.
Outdated
}
finally
{
_semaphore.Release();
}
}

/// <inheritdoc />
public void Dispose() => _semaphore.Dispose();
}
#endif