Skip to content

Commit

Permalink
Implemented custom rate limiter
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaelBatsian committed Jan 31, 2025
1 parent d0a5741 commit 6fe5a9c
Show file tree
Hide file tree
Showing 15 changed files with 372 additions and 8 deletions.
2 changes: 1 addition & 1 deletion RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RateLimiter\RateLimiter.csproj" />
Expand Down
104 changes: 98 additions & 6 deletions RateLimiter.Tests/RateLimiterTest.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,105 @@
using NUnit.Framework;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
using NUnit.Framework;
using RateLimiter.Limiter;
using RateLimiter.Storage;
using RateLimiter.Models;
using RateLimiter.Rules;

namespace RateLimiter.Tests;

[TestFixture]
[Parallelizable(ParallelScope.All)]
public class RateLimiterTest
{
[Test]
public void Example()
{
Assert.That(true, Is.True);
}
[Test]
public async Task ApplyRateLimitRules_ReturnsIsAllowedFalse_WhenLimitsExceeded()
{
//Arrange
var anyResource = "testResource";

var storage = new DefaultRateLimiterStorage<string>();

var slidingRuleRateLimiter = new RuleRateLimiter<string, string>(
resource =>
{
var key = resource;

var factory = new RateLimitRuleByKeyFactory<string>
{
Key = key,
LimitRule = _ => new SlidingWindowRule(2, TimeSpan.FromSeconds(3))
};

return factory;
}, storage
);

var windowRuleRateLimiter = new RuleRateLimiter<string, string>(
resource =>
{
var key = resource;

var factory = new RateLimitRuleByKeyFactory<string>
{
Key = key,
LimitRule = _ => new FixedWindowRule(3, TimeSpan.FromSeconds(5))
};

return factory;
}, storage
);

var list = new List<RuleRateLimiter<string, string>>
{
slidingRuleRateLimiter,
windowRuleRateLimiter
};

var sut = new RateLimiter<string, string>(list);

//Act
var rateLimitResult1 = await sut.ApplyRateLimitRulesAsync(anyResource);
var rateLimitResult2 = await sut.ApplyRateLimitRulesAsync(anyResource);
var rateLimitResult3 = await sut.ApplyRateLimitRulesAsync(anyResource);
var rateLimitResult4 = await sut.ApplyRateLimitRulesAsync(anyResource);

await Task.Delay(TimeSpan.FromSeconds(7));

var rateLimitResult5 = await sut.ApplyRateLimitRulesAsync(anyResource);

//Assert
Assert.That(rateLimitResult1.IsAllowed);
Assert.That(rateLimitResult1.RulesMessages, Is.All.Null);

Assert.That(rateLimitResult2.IsAllowed);
Assert.That(rateLimitResult2.RulesMessages, Is.All.Null);

Assert.That(!rateLimitResult3.IsAllowed);
Assert.That(rateLimitResult3.RulesMessages.Count(x => x != null) == 1);

Assert.That(!rateLimitResult4.IsAllowed);
Assert.That(rateLimitResult4.RulesMessages.Count(x => x != null) == 2);

Assert.That(rateLimitResult5.IsAllowed);
Assert.That(rateLimitResult5.RulesMessages, Is.All.Null);
}

[Test]
public async Task ApplyRateLimitRules_ReturnsIsAllowedTrue_WhenTheRulesWereNotConfigured()
{
//Arrange
var anyResource = "testResource";

var sut = new RateLimiter<string, string>(new List<RuleRateLimiter<string, string>>());

//Act
var rateLimitResult1 = await sut.ApplyRateLimitRulesAsync(anyResource);

//Assert
Assert.That(rateLimitResult1.IsAllowed);
CollectionAssert.IsEmpty(rateLimitResult1.RulesMessages);
}
}
10 changes: 10 additions & 0 deletions RateLimiter/Limiter/IRateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Threading;
using RateLimiter.Models;
using System.Threading.Tasks;

namespace RateLimiter.Limiter;

public interface IRateLimiter<TResource>
{
ValueTask<RateLimitResult> ApplyRateLimitRulesAsync(TResource resource, CancellationToken ct = default);
}
10 changes: 10 additions & 0 deletions RateLimiter/Limiter/IRuleRateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Threading;
using RateLimiter.Models;
using System.Threading.Tasks;

namespace RateLimiter.Limiter;

public interface IRuleRateLimiter<TResource, TKey>
{
ValueTask<RuleResult> ApplyRateLimitRulesAsync(TResource resource, CancellationToken ct = default);
}
33 changes: 33 additions & 0 deletions RateLimiter/Limiter/RateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using RateLimiter.Models;

namespace RateLimiter.Limiter;

public class RateLimiter<TResource, TKey> : IRateLimiter<TResource>
{
private readonly IList<RuleRateLimiter<TResource, TKey>> _ruleRateLimiters;

public RateLimiter(IList<RuleRateLimiter<TResource, TKey>> ruleRateLimiters)
{
_ruleRateLimiters = ruleRateLimiters;
}

public async ValueTask<RateLimitResult> ApplyRateLimitRulesAsync(TResource resource, CancellationToken ct = default)
{
var ruleRateLimiterTasks = _ruleRateLimiters
.Select(ruleLimiter => ruleLimiter.ApplyRateLimitRulesAsync(resource, ct).AsTask())
.ToArray();

var rulesResults = await Task.WhenAll(ruleRateLimiterTasks);
var result = new RateLimitResult
{
IsAllowed = rulesResults.All(x => x.IsAllowed),
RulesMessages = rulesResults.Select(x => x.RuleMessage).ToArray()
};

return result;
}
}
41 changes: 41 additions & 0 deletions RateLimiter/Limiter/RuleRateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RateLimiter.Models;
using RateLimiter.Storage;

namespace RateLimiter.Limiter;

public class RuleRateLimiter<TResource, TKey> : IRuleRateLimiter<TResource, TKey>
{
private readonly string _ruleRateLimiterId = Guid.NewGuid().ToString();

private readonly Func<TResource, RateLimitRuleByKeyFactory<TKey>> _ruleRateLimiter;
private readonly IRateLimiterStorage<string> _rateLimiterStorage;

public RuleRateLimiter(
Func<TResource, RateLimitRuleByKeyFactory<TKey>> ruleRateLimiter,
IRateLimiterStorage<string> rateLimiterStorage)
{
_ruleRateLimiter = ruleRateLimiter;
_rateLimiterStorage = rateLimiterStorage;
}

public async ValueTask<RuleResult> ApplyRateLimitRulesAsync(TResource resource, CancellationToken ct = default)
{
var ruleRateLimiter = _ruleRateLimiter(resource);
var key = $"{_ruleRateLimiterId}_{ruleRateLimiter.Key.GetHashCode()}";

var rule = await _rateLimiterStorage.GetRuleAsync(key, ct);
if (rule == null)
{
rule = ruleRateLimiter.LimitRule(ruleRateLimiter.Key);

await _rateLimiterStorage.AddRateLimitRuleAsync(key, rule, ct);
}

var ruleResult = await rule.ApplyAsync(ct);

return ruleResult;
}
}
9 changes: 9 additions & 0 deletions RateLimiter/Models/RateLimitResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Collections.Generic;

namespace RateLimiter.Models;

public class RateLimitResult
{
public bool IsAllowed { get; set; }
public IList<string> RulesMessages { get; set; }
}
9 changes: 9 additions & 0 deletions RateLimiter/Models/RateLimitRuleByKeyFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System;
using RateLimiter.Rules;

namespace RateLimiter.Models;
public class RateLimitRuleByKeyFactory<TKey>
{
public TKey Key { get; set; }
public Func<TKey, IRateLimitRule> LimitRule { get; set; }
}
7 changes: 7 additions & 0 deletions RateLimiter/Models/RuleResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RateLimiter.Models;

public class RuleResult
{
public bool IsAllowed { get; set; }
public string RuleMessage { get; set; }
}
2 changes: 1 addition & 1 deletion RateLimiter/RateLimiter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>
56 changes: 56 additions & 0 deletions RateLimiter/Rules/FixedWindowRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using RateLimiter.Models;

namespace RateLimiter.Rules;

public class FixedWindowRule : IRateLimitRule
{
private const string AccessForbiddenMessage = "Access forbidden for {0} seconds";

private readonly int _limit;
private readonly TimeSpan _window;
private DateTime _startTime;
private int _count;
private readonly object _lock = new();

public FixedWindowRule(int limit, TimeSpan window)
{
_limit = limit;
_window = window;
_startTime = DateTime.UtcNow;
_count = 0;
}

public ValueTask<RuleResult> ApplyAsync(CancellationToken ct = default)
{
var now = DateTime.UtcNow;

lock (_lock)
{
if (now - _startTime >= _window)
{
_startTime = now;
_count = 1;
return ValueTask.FromResult(new RuleResult { IsAllowed = true });
}

if (_count >= _limit)
{
var windowExpirationTime = _startTime + _window;
var remainingTime = (windowExpirationTime - now).TotalSeconds;

return ValueTask.FromResult(new RuleResult
{
IsAllowed = false,
RuleMessage = string.Format(AccessForbiddenMessage, $"{remainingTime:F2} ")
});
}

_count++;
}

return ValueTask.FromResult(new RuleResult { IsAllowed = true });
}
}
10 changes: 10 additions & 0 deletions RateLimiter/Rules/IRateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Threading;
using RateLimiter.Models;
using System.Threading.Tasks;

namespace RateLimiter.Rules;

public interface IRateLimitRule
{
ValueTask<RuleResult> ApplyAsync(CancellationToken ct = default);
}
51 changes: 51 additions & 0 deletions RateLimiter/Rules/SlidingWindowRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using RateLimiter.Models;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace RateLimiter.Rules;

public class SlidingWindowRule : IRateLimitRule
{
private const string AccessForbiddenMessage = "Access forbidden for {0} seconds";
private readonly int _limit;
private readonly TimeSpan _window;
private readonly Queue<DateTime> _timestamps = new();
private readonly object _lock = new();

public SlidingWindowRule(int limit, TimeSpan window)
{
_limit = limit;
_window = window;
}

public ValueTask<RuleResult> ApplyAsync(CancellationToken ct = default)
{
var now = DateTime.UtcNow;

lock (_lock)
{
while (_timestamps.Count > 0 && now - _timestamps.Peek() >= _window)
{
_timestamps.Dequeue();
}

if (_timestamps.Count >= _limit)
{
var oldestRequestTime = _timestamps.Peek();
var remainingTime = (_window - (now - oldestRequestTime)).TotalSeconds;

return ValueTask.FromResult(new RuleResult
{
IsAllowed = false,
RuleMessage = string.Format(AccessForbiddenMessage, $"{remainingTime:F2} ")
});
}

_timestamps.Enqueue(now);
}

return ValueTask.FromResult(new RuleResult { IsAllowed = true });
}
}
Loading

0 comments on commit 6fe5a9c

Please sign in to comment.