From 7aff5476f7d2ecc1f5f7f1b5d74401a6281af921 Mon Sep 17 00:00:00 2001 From: Mikhail Batsian Date: Fri, 31 Jan 2025 10:55:00 +0300 Subject: [PATCH] Implemented custom rate limiter --- RateLimiter.Tests/RateLimiter.Tests.csproj | 2 +- RateLimiter.Tests/RateLimiterTest.cs | 104 +++++++++++++++++- RateLimiter/Limiter/IRateLimiter.cs | 10 ++ RateLimiter/Limiter/IRuleRateLimiter.cs | 10 ++ RateLimiter/Limiter/RateLimiter.cs | 33 ++++++ RateLimiter/Limiter/RuleRateLimiter.cs | 41 +++++++ RateLimiter/Models/RateLimitResult.cs | 9 ++ .../Models/RateLimitRuleByKeyFactory.cs | 9 ++ RateLimiter/Models/RuleResult.cs | 7 ++ RateLimiter/RateLimiter.csproj | 2 +- RateLimiter/Rules/FixedWindowRule.cs | 56 ++++++++++ RateLimiter/Rules/IRateLimitRule.cs | 10 ++ RateLimiter/Rules/SlidingWindowRule.cs | 51 +++++++++ .../Storage/DefaultRateLimiterStorage.cs | 25 +++++ RateLimiter/Storage/IRateLimiterStorage.cs | 11 ++ 15 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 RateLimiter/Limiter/IRateLimiter.cs create mode 100644 RateLimiter/Limiter/IRuleRateLimiter.cs create mode 100644 RateLimiter/Limiter/RateLimiter.cs create mode 100644 RateLimiter/Limiter/RuleRateLimiter.cs create mode 100644 RateLimiter/Models/RateLimitResult.cs create mode 100644 RateLimiter/Models/RateLimitRuleByKeyFactory.cs create mode 100644 RateLimiter/Models/RuleResult.cs create mode 100644 RateLimiter/Rules/FixedWindowRule.cs create mode 100644 RateLimiter/Rules/IRateLimitRule.cs create mode 100644 RateLimiter/Rules/SlidingWindowRule.cs create mode 100644 RateLimiter/Storage/DefaultRateLimiterStorage.cs create mode 100644 RateLimiter/Storage/IRateLimiterStorage.cs diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..d80768da 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -2,7 +2,7 @@ net6.0 latest - enable + disable diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..f5e9fa7e 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -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(); + + var slidingRuleRateLimiter = new RuleRateLimiter( + resource => + { + var key = resource; + + var factory = new RateLimitRuleByKeyFactory + { + Key = key, + LimitRule = _ => new SlidingWindowRule(2, TimeSpan.FromSeconds(3)) + }; + + return factory; + }, storage + ); + + var windowRuleRateLimiter = new RuleRateLimiter( + resource => + { + var key = resource; + + var factory = new RateLimitRuleByKeyFactory + { + Key = key, + LimitRule = _ => new FixedWindowRule(3, TimeSpan.FromSeconds(5)) + }; + + return factory; + }, storage + ); + + var list = new List> + { + slidingRuleRateLimiter, + windowRuleRateLimiter + }; + + var sut = new RateLimiter(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(new List>()); + + //Act + var rateLimitResult1 = await sut.ApplyRateLimitRulesAsync(anyResource); + + //Assert + Assert.That(rateLimitResult1.IsAllowed); + CollectionAssert.IsEmpty(rateLimitResult1.RulesMessages); + } } \ No newline at end of file diff --git a/RateLimiter/Limiter/IRateLimiter.cs b/RateLimiter/Limiter/IRateLimiter.cs new file mode 100644 index 00000000..b5c47f22 --- /dev/null +++ b/RateLimiter/Limiter/IRateLimiter.cs @@ -0,0 +1,10 @@ +using System.Threading; +using RateLimiter.Models; +using System.Threading.Tasks; + +namespace RateLimiter.Limiter; + +public interface IRateLimiter +{ + ValueTask ApplyRateLimitRulesAsync(TResource resource, CancellationToken ct = default); +} \ No newline at end of file diff --git a/RateLimiter/Limiter/IRuleRateLimiter.cs b/RateLimiter/Limiter/IRuleRateLimiter.cs new file mode 100644 index 00000000..8964a99a --- /dev/null +++ b/RateLimiter/Limiter/IRuleRateLimiter.cs @@ -0,0 +1,10 @@ +using System.Threading; +using RateLimiter.Models; +using System.Threading.Tasks; + +namespace RateLimiter.Limiter; + +public interface IRuleRateLimiter +{ + ValueTask ApplyRateLimitRulesAsync(TResource resource, CancellationToken ct = default); +} diff --git a/RateLimiter/Limiter/RateLimiter.cs b/RateLimiter/Limiter/RateLimiter.cs new file mode 100644 index 00000000..769cb3ae --- /dev/null +++ b/RateLimiter/Limiter/RateLimiter.cs @@ -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 : IRateLimiter +{ + private readonly IList> _ruleRateLimiters; + + public RateLimiter(IList> ruleRateLimiters) + { + _ruleRateLimiters = ruleRateLimiters; + } + + public async ValueTask 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; + } +} diff --git a/RateLimiter/Limiter/RuleRateLimiter.cs b/RateLimiter/Limiter/RuleRateLimiter.cs new file mode 100644 index 00000000..de62ba19 --- /dev/null +++ b/RateLimiter/Limiter/RuleRateLimiter.cs @@ -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 : IRuleRateLimiter +{ + private readonly string _ruleRateLimiterId = Guid.NewGuid().ToString(); + + private readonly Func> _ruleRateLimiter; + private readonly IRateLimiterStorage _rateLimiterStorage; + + public RuleRateLimiter( + Func> ruleRateLimiter, + IRateLimiterStorage rateLimiterStorage) + { + _ruleRateLimiter = ruleRateLimiter; + _rateLimiterStorage = rateLimiterStorage; + } + + public async ValueTask 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; + } +} diff --git a/RateLimiter/Models/RateLimitResult.cs b/RateLimiter/Models/RateLimitResult.cs new file mode 100644 index 00000000..33e81c3b --- /dev/null +++ b/RateLimiter/Models/RateLimitResult.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace RateLimiter.Models; + +public class RateLimitResult +{ + public bool IsAllowed { get; set; } + public IList RulesMessages { get; set; } +} diff --git a/RateLimiter/Models/RateLimitRuleByKeyFactory.cs b/RateLimiter/Models/RateLimitRuleByKeyFactory.cs new file mode 100644 index 00000000..ddc57b58 --- /dev/null +++ b/RateLimiter/Models/RateLimitRuleByKeyFactory.cs @@ -0,0 +1,9 @@ +using System; +using RateLimiter.Rules; + +namespace RateLimiter.Models; +public class RateLimitRuleByKeyFactory +{ + public TKey Key { get; set; } + public Func LimitRule { get; set; } +} diff --git a/RateLimiter/Models/RuleResult.cs b/RateLimiter/Models/RuleResult.cs new file mode 100644 index 00000000..8e126615 --- /dev/null +++ b/RateLimiter/Models/RuleResult.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Models; + +public class RuleResult +{ + public bool IsAllowed { get; set; } + public string RuleMessage { get; set; } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..dc6a7866 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -2,6 +2,6 @@ net6.0 latest - enable + disable \ No newline at end of file diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs new file mode 100644 index 00000000..60e0ca43 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -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 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 }); + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..26ecd5a5 --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,10 @@ +using System.Threading; +using RateLimiter.Models; +using System.Threading.Tasks; + +namespace RateLimiter.Rules; + +public interface IRateLimitRule +{ + ValueTask ApplyAsync(CancellationToken ct = default); +} diff --git a/RateLimiter/Rules/SlidingWindowRule.cs b/RateLimiter/Rules/SlidingWindowRule.cs new file mode 100644 index 00000000..3c3cd7ff --- /dev/null +++ b/RateLimiter/Rules/SlidingWindowRule.cs @@ -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 _timestamps = new(); + private readonly object _lock = new(); + + public SlidingWindowRule(int limit, TimeSpan window) + { + _limit = limit; + _window = window; + } + + public ValueTask 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 }); + } +} diff --git a/RateLimiter/Storage/DefaultRateLimiterStorage.cs b/RateLimiter/Storage/DefaultRateLimiterStorage.cs new file mode 100644 index 00000000..74b2f139 --- /dev/null +++ b/RateLimiter/Storage/DefaultRateLimiterStorage.cs @@ -0,0 +1,25 @@ +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using RateLimiter.Rules; + +namespace RateLimiter.Storage; + +public class DefaultRateLimiterStorage : IRateLimiterStorage +{ + private readonly ConcurrentDictionary _storage = new(); + + public ValueTask AddRateLimitRuleAsync(TKey key, IRateLimitRule rateLimitRule, CancellationToken ct = default) + { + _storage.GetOrAdd(key, _ => rateLimitRule); + + return ValueTask.CompletedTask; + } + + ValueTask IRateLimiterStorage.GetRuleAsync(TKey key, CancellationToken ct = default) + { + var result = _storage.TryGetValue(key, out var rule) ? rule : null; + + return new ValueTask(result); + } +} diff --git a/RateLimiter/Storage/IRateLimiterStorage.cs b/RateLimiter/Storage/IRateLimiterStorage.cs new file mode 100644 index 00000000..e25697f9 --- /dev/null +++ b/RateLimiter/Storage/IRateLimiterStorage.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using RateLimiter.Rules; + +namespace RateLimiter.Storage; + +public interface IRateLimiterStorage +{ + ValueTask AddRateLimitRuleAsync(TKey key, IRateLimitRule rateLimitRule, CancellationToken ct = default); + ValueTask GetRuleAsync(TKey key, CancellationToken ct = default); +}