Skip to content

Commit 676e72b

Browse files
committed
WIP Add sliding window rate limiter
TODO: Commit message + Explain rationale for using the 'Sliding Window Counter Rate Limiter' algorithm + Link to https://medium.com/redis-with-raphael-de-lio/sliding-window-counter-rate-limiter-redis-java-1ba8901c02e5 + Explain reason for filtering segments for sum (as opposed to relying purely on Redis expiring the segment hash fields) + Split out RedisConnection and FakeRedisConnectionManager into separate commit
1 parent e1976ed commit 676e72b

10 files changed

+252
-0
lines changed

src/Buttercup.Security.Tests/ServiceCollectionExtensionsTests.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ public void AddSecurityServices_AddsRandomTokenGenerator() =>
9090
serviceDescriptor.ImplementationType == typeof(RandomTokenGenerator) &&
9191
serviceDescriptor.Lifetime == ServiceLifetime.Transient);
9292

93+
[Fact]
94+
public void AddSecurityServices_AddsSlidingWindowRateLimiter() =>
95+
Assert.Contains(
96+
new ServiceCollection().AddSecurityServices(),
97+
serviceDescriptor =>
98+
serviceDescriptor.ServiceType == typeof(ISlidingWindowRateLimiter) &&
99+
serviceDescriptor.ImplementationType == typeof(SlidingWindowRateLimiter) &&
100+
serviceDescriptor.Lifetime == ServiceLifetime.Transient);
101+
102+
93103
[Fact]
94104
public void AddSecurityServices_AddsTokenAuthenticationService() =>
95105
Assert.Contains(
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using Buttercup.TestUtils;
2+
using Microsoft.Extensions.Time.Testing;
3+
using Moq;
4+
using StackExchange.Redis;
5+
using Xunit;
6+
7+
namespace Buttercup.Security;
8+
9+
public sealed class SlidingWindowRateLimiterTests
10+
{
11+
private readonly FakeTimeProvider timeProvider = new();
12+
13+
private readonly SlidingWindowRateLimiterOptions options = new()
14+
{
15+
Limit = 15,
16+
SegmentsPerWindow = 5,
17+
Window = TimeSpan.FromMilliseconds(50),
18+
};
19+
20+
[Fact]
21+
public async Task IsAllowed_ReturnsTrueOrFalseBasedOnLimit()
22+
{
23+
var keySuffix = Random.Shared.Next();
24+
var key1 = $"Foo{keySuffix}";
25+
var key2 = $"Bar{keySuffix}";
26+
27+
using var connection = await RedisConnection.GetConnection();
28+
var connectionManager = new FakeRedisConnectionManager(connection);
29+
var rateLimiter = new SlidingWindowRateLimiter(connectionManager, this.timeProvider);
30+
31+
for (var i = 0; i < 5; i++)
32+
{
33+
Assert.True(await rateLimiter.IsAllowed(key1, this.options));
34+
}
35+
36+
this.timeProvider.Advance(TimeSpan.FromMilliseconds(10));
37+
38+
for (var i = 0; i < 5; i++)
39+
{
40+
Assert.True(await rateLimiter.IsAllowed(key1, this.options));
41+
}
42+
43+
this.timeProvider.Advance(TimeSpan.FromMilliseconds(20));
44+
45+
for (var i = 0; i < 5; i++)
46+
{
47+
Assert.True(await rateLimiter.IsAllowed(key1, this.options));
48+
}
49+
50+
Assert.False(await rateLimiter.IsAllowed(key1, this.options));
51+
Assert.True(await rateLimiter.IsAllowed(key2, this.options));
52+
53+
this.timeProvider.Advance(TimeSpan.FromMilliseconds(20));
54+
55+
for (var i = 0; i < 5; i++)
56+
{
57+
Assert.True(await rateLimiter.IsAllowed(key1, this.options));
58+
}
59+
60+
Assert.False(await rateLimiter.IsAllowed(key1, this.options));
61+
Assert.True(await rateLimiter.IsAllowed(key2, this.options));
62+
}
63+
64+
[Fact]
65+
public async Task IsAllowed_ChecksAndRethrowsExceptions()
66+
{
67+
var connectionMock = new Mock<IConnectionMultiplexer>();
68+
var connectionManager = new FakeRedisConnectionManager(connectionMock.Object);
69+
var rateLimiter = new SlidingWindowRateLimiter(connectionManager, this.timeProvider);
70+
71+
var expectedException = new RedisException("Fake exception");
72+
connectionMock.Setup(x => x.GetDatabase(-1, null)).Throws(expectedException);
73+
74+
Assert.Same(
75+
expectedException,
76+
await Assert.ThrowsAsync<RedisException>(
77+
() => rateLimiter.IsAllowed("Foo", this.options)));
78+
Assert.Same(expectedException, Assert.Single(connectionManager.CheckedExceptions));
79+
}
80+
}

src/Buttercup.Security/Buttercup.Security.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
<ProjectReference Include="..\Buttercup.Core\Buttercup.Core.csproj" />
77
<ProjectReference Include="..\Buttercup.EntityModel\Buttercup.EntityModel.csproj" />
88
<ProjectReference Include="..\Buttercup.Email\Buttercup.Email.csproj" />
9+
<ProjectReference Include="..\Buttercup.Redis\Buttercup.Redis.csproj" />
910
</ItemGroup>
1011
</Project>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Buttercup.Security;
2+
3+
/// <summary>
4+
/// Defines the contract for a sliding window rate limiter.
5+
/// </summary>
6+
public interface ISlidingWindowRateLimiter
7+
{
8+
/// <summary>
9+
/// Checks whether an operation is allowed to proceed based on a sliding window rate limit.
10+
/// </summary>
11+
/// <param name="key">The operation key.</param>
12+
/// <param name="options">The rate limiter options.</param>
13+
/// <returns>
14+
/// <b>true</b> if the operation is allowed to proceed; otherwise, <b>false</b>
15+
/// </returns>
16+
Task<bool> IsAllowed(string key, SlidingWindowRateLimiterOptions options);
17+
}

src/Buttercup.Security/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ public static IServiceCollection AddSecurityServices(this IServiceCollection ser
3030
.AddTransient<IPasswordAuthenticationService, PasswordAuthenticationService>()
3131
.AddTransient<IRandomNumberGeneratorFactory, RandomNumberGeneratorFactory>()
3232
.AddTransient<IRandomTokenGenerator, RandomTokenGenerator>()
33+
.AddTransient<ISlidingWindowRateLimiter, SlidingWindowRateLimiter>()
3334
.AddTransient<ITokenAuthenticationService, TokenAuthenticationService>();
3435
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using Buttercup.Redis;
2+
using StackExchange.Redis;
3+
4+
namespace Buttercup.Security;
5+
6+
internal sealed class SlidingWindowRateLimiter(
7+
IRedisConnectionManager redisConnectionManager, TimeProvider timeProvider)
8+
: ISlidingWindowRateLimiter
9+
{
10+
private readonly IRedisConnectionManager redisConnectionManager = redisConnectionManager;
11+
private readonly TimeProvider timeProvider = timeProvider;
12+
13+
public async Task<bool> IsAllowed(string key, SlidingWindowRateLimiterOptions options)
14+
{
15+
var redisKey = $"{options.RedisKeyPrefix}:{key}";
16+
17+
await this.redisConnectionManager.EnsureInitialized();
18+
19+
try
20+
{
21+
var currentTicks = this.timeProvider.GetUtcNow().Ticks;
22+
var ticksPerSegment = options.Window.Ticks / options.SegmentsPerWindow;
23+
var currentSegmentNumber = currentTicks / ticksPerSegment;
24+
var windowStartSegmentNumber = currentSegmentNumber - options.SegmentsPerWindow;
25+
26+
var database = this.redisConnectionManager.CurrentConnection.GetDatabase();
27+
var segmentCounts = await database.HashGetAllAsync(redisKey);
28+
var totalCountAcrossWindow = segmentCounts
29+
.Where(entry => (long)entry.Name > windowStartSegmentNumber)
30+
.Sum(entry => (long)entry.Value);
31+
32+
if (totalCountAcrossWindow >= options.Limit)
33+
{
34+
return false;
35+
}
36+
37+
var batch = database.CreateBatch();
38+
var batchTasks = new Task[]
39+
{
40+
batch.HashIncrementAsync(
41+
redisKey, currentSegmentNumber, 1, CommandFlags.FireAndForget),
42+
batch.HashFieldExpireAsync(
43+
redisKey,
44+
[currentSegmentNumber],
45+
options.Window,
46+
ExpireWhen.HasNoExpiry,
47+
CommandFlags.FireAndForget)
48+
};
49+
batch.Execute();
50+
51+
await Task.WhenAll(batchTasks);
52+
53+
return true;
54+
}
55+
catch (Exception e)
56+
{
57+
await this.redisConnectionManager.CheckException(e);
58+
throw;
59+
}
60+
}
61+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace Buttercup.Security;
4+
5+
/// <summary>
6+
/// The options for a sliding window rate limiter.
7+
/// </summary>
8+
public sealed class SlidingWindowRateLimiterOptions
9+
{
10+
/// <summary>
11+
/// The maximum number of requests allowed within the sliding window.
12+
/// </summary>
13+
[Required]
14+
public long Limit { get; set; }
15+
16+
/// <summary>
17+
/// The prefix used to create the Redis key for each rate limit key.
18+
/// </summary>
19+
public string RedisKeyPrefix { get; set; } = "rate_limit:sliding_window";
20+
21+
/// <summary>
22+
/// The number of segments each window is divided into.
23+
/// </summary>
24+
public int SegmentsPerWindow { get; set; } = 10;
25+
26+
/// <summary>
27+
/// The duration of the sliding window.
28+
/// </summary>
29+
[Required]
30+
public TimeSpan Window { get; set; }
31+
}

src/Buttercup.TestUtils/Buttercup.TestUtils.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
<ItemGroup>
1212
<ProjectReference Include="..\Buttercup.Core\Buttercup.Core.csproj" />
1313
<ProjectReference Include="..\Buttercup.EntityModel\Buttercup.EntityModel.csproj" />
14+
<ProjectReference Include="..\Buttercup.Redis\Buttercup.Redis.csproj" />
1415
</ItemGroup>
1516
</Project>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Buttercup.Redis;
2+
using StackExchange.Redis;
3+
4+
namespace Buttercup.TestUtils;
5+
6+
/// <summary>
7+
/// A fake implementation of <see cref="IRedisConnectionManager" />.
8+
/// </summary>
9+
public sealed class FakeRedisConnectionManager(IConnectionMultiplexer connection)
10+
: IRedisConnectionManager
11+
{
12+
/// <summary>
13+
/// The list of exceptions passed to <see cref="CheckException"/>, in invocation order.
14+
/// </summary>
15+
public List<Exception> CheckedExceptions { get; } = [];
16+
17+
/// <inheritdoc/>
18+
public IConnectionMultiplexer CurrentConnection { get; } = connection;
19+
20+
/// <inheritdoc/>
21+
public Task<bool> CheckException(Exception exception)
22+
{
23+
this.CheckedExceptions.Add(exception);
24+
return Task.FromResult(false);
25+
}
26+
27+
/// <inheritdoc/>
28+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
29+
30+
/// <inheritdoc/>
31+
public Task EnsureInitialized() => Task.CompletedTask;
32+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using StackExchange.Redis;
2+
3+
namespace Buttercup.TestUtils;
4+
5+
/// <summary>
6+
/// Provides a singleton Redis connection for use in tests.
7+
/// </summary>
8+
public static class RedisConnection
9+
{
10+
private static readonly Lazy<Task<ConnectionMultiplexer>> lazyConnectionTask =
11+
new(() => ConnectionMultiplexer.ConnectAsync("localhost,abortConnect=false"));
12+
13+
/// <summary>
14+
/// Gets the singleton Redis connection.
15+
/// </summary>
16+
/// <returns>The singleton Redis connection.</returns>
17+
public static Task<ConnectionMultiplexer> GetConnection() => lazyConnectionTask.Value;
18+
}

0 commit comments

Comments
 (0)