Skip to content
39 changes: 39 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,39 @@
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_Test()
Comment thread
Tyrrrz marked this conversation as resolved.
{
// Arrange
using var throttle = new ThrottleLock(TimeSpan.FromMilliseconds(50));

// Act
var stopwatch = Stopwatch.StartNew();
await throttle.WaitAsync();
await throttle.WaitAsync();
await throttle.WaitAsync();

// Assert
stopwatch.Elapsed.Should().BeGreaterThanOrEqualTo(2 * TimeSpan.FromMilliseconds(50));
}

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

// Act & assert
var act = async () => await throttle.WaitAsync(new CancellationToken(true));
await act.Should().ThrowAsync<OperationCanceledException>();
}
}
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;
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 long _lastTimestamp = Stopwatch.GetTimestamp();

/// <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 remaining = interval - Stopwatch.GetElapsedTime(_lastTimestamp);
if (remaining > TimeSpan.Zero)
await Task.Delay(remaining, cancellationToken).ConfigureAwait(false);

_lastTimestamp = Stopwatch.GetTimestamp();
}
finally
{
_semaphore.Release();
}
}

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