From fbd22b8e8dc9c11647449cb0094345703adde331 Mon Sep 17 00:00:00 2001 From: Andrey Mylnikov Date: Fri, 28 Feb 2025 14:46:14 +0300 Subject: [PATCH 1/2] Add RateLimiter implementation with some basic rules --- .gitignore | 4 +- RateLimiter.Tests/RateLimiter.Tests.csproj | 1 + RateLimiter.Tests/RateLimiterTest.cs | 13 ---- RateLimiter.Tests/RequestRateLimiterTests.cs | 76 +++++++++++++++++++ .../Rules/FixedWindowRateLimitRuleTests.cs | 44 +++++++++++ .../Rules/RegionRateLimitRuleTests.cs | 42 ++++++++++ ...mePassedSinceLastCallRateLimitRuleTests.cs | 40 ++++++++++ RateLimiter/IRateLimiter.cs | 9 +++ RateLimiter/Models/Region.cs | 8 ++ RateLimiter/Models/RequestContext.cs | 3 + RateLimiter/Models/RequestKey.cs | 3 + RateLimiter/RequestRateLimiter.cs | 29 +++++++ RateLimiter/Rules/FixedWindowRateLimitRule.cs | 61 +++++++++++++++ RateLimiter/Rules/IRateLimitRule.cs | 9 +++ RateLimiter/Rules/RegionRateLimitRule.cs | 23 ++++++ .../TimePassedSinceLastCallRateLimitRule.cs | 33 ++++++++ RateLimiter/Store/IRateLimitRuleStore.cs | 11 +++ RateLimiter/Store/RateLimitRuleStore.cs | 28 +++++++ 18 files changed, 423 insertions(+), 14 deletions(-) delete mode 100644 RateLimiter.Tests/RateLimiterTest.cs create mode 100644 RateLimiter.Tests/RequestRateLimiterTests.cs create mode 100644 RateLimiter.Tests/Rules/FixedWindowRateLimitRuleTests.cs create mode 100644 RateLimiter.Tests/Rules/RegionRateLimitRuleTests.cs create mode 100644 RateLimiter.Tests/Rules/TimePassedSinceLastCallRateLimitRuleTests.cs create mode 100644 RateLimiter/IRateLimiter.cs create mode 100644 RateLimiter/Models/Region.cs create mode 100644 RateLimiter/Models/RequestContext.cs create mode 100644 RateLimiter/Models/RequestKey.cs create mode 100644 RateLimiter/RequestRateLimiter.cs create mode 100644 RateLimiter/Rules/FixedWindowRateLimitRule.cs create mode 100644 RateLimiter/Rules/IRateLimitRule.cs create mode 100644 RateLimiter/Rules/RegionRateLimitRule.cs create mode 100644 RateLimiter/Rules/TimePassedSinceLastCallRateLimitRule.cs create mode 100644 RateLimiter/Store/IRateLimitRuleStore.cs create mode 100644 RateLimiter/Store/RateLimitRuleStore.cs diff --git a/.gitignore b/.gitignore index dc462b74..7c6a85ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .vs +.idea packages bin -obj \ No newline at end of file +obj +*.DotSettings.user \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..ef10b84d 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs deleted file mode 100644 index 172d44a7..00000000 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file diff --git a/RateLimiter.Tests/RequestRateLimiterTests.cs b/RateLimiter.Tests/RequestRateLimiterTests.cs new file mode 100644 index 00000000..15b79ded --- /dev/null +++ b/RateLimiter.Tests/RequestRateLimiterTests.cs @@ -0,0 +1,76 @@ +namespace RateLimiter.Tests; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiter.Models; +using RateLimiter.Rules; +using RateLimiter.Store; + +[TestFixture] +public class RequestRateLimiterTests +{ + [Test] + public async Task IsRequestAllowed_WhenAllRateLimitRulesPass_ReturnsTrue() + { + // Arrange + var rateLimiterStore = new RateLimitRuleStore(); + rateLimiterStore.AddRules( + "resourceA", + new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromMinutes(1)), + new RegionRateLimitRule(Region.EU, new FixedWindowRateLimitRule(TimeSpan.FromMinutes(1), 5)), + new RegionRateLimitRule(Region.US, new FixedWindowRateLimitRule(TimeSpan.FromMinutes(1), 100))); + var rateLimiter = new RequestRateLimiter(rateLimiterStore); + + var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123", ClientRegion: Region.EU); + + // Act + var result = await rateLimiter.IsRequestAllowedAsync(request); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task IsRequestAllowed_WhenAnyLimitRuleFails_ReturnsFalse() + { + // Arrange + var rateLimiterStore = new RateLimitRuleStore(); + rateLimiterStore.AddRules( + "resourceA", + new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromMinutes(5)), + new RegionRateLimitRule(Region.US, new FixedWindowRateLimitRule(TimeSpan.FromMinutes(1), 100)), + new RegionRateLimitRule(Region.EU, new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromMinutes(1)))); + var rateLimiter = new RequestRateLimiter(rateLimiterStore); + + var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123", ClientRegion: Region.EU); + + // Act + for (int i = 0; i < 10; i++) + { + await rateLimiter.IsRequestAllowedAsync(request); + } + + var result = await rateLimiter.IsRequestAllowedAsync(request); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public async Task IsRequestAllowed_WhenNoRulesConfigured_ReturnsTrue() + { + // Arrange + var rateLimiterStore = new RateLimitRuleStore(); + rateLimiterStore.AddRules("resourceA", new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromMinutes(1))); + var rateLimiter = new RequestRateLimiter(rateLimiterStore); + + var request = new RequestContext(ResourceId: "resourceB", AccessToken: "123"); + + // Act + var result = await rateLimiter.IsRequestAllowedAsync(request); + + // Assert + Assert.That(result, Is.True); + } +} diff --git a/RateLimiter.Tests/Rules/FixedWindowRateLimitRuleTests.cs b/RateLimiter.Tests/Rules/FixedWindowRateLimitRuleTests.cs new file mode 100644 index 00000000..6bd65277 --- /dev/null +++ b/RateLimiter.Tests/Rules/FixedWindowRateLimitRuleTests.cs @@ -0,0 +1,44 @@ +namespace RateLimiter.Tests.Rules; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiter.Models; +using RateLimiter.Rules; + +[TestFixture] +public class FixedWindowRateLimitRuleTests +{ + [Test] + public async Task IsRequestAllowed_WhenCallCountWithInLimits_ReturnsTrue() + { + // Arrange + var rule = new FixedWindowRateLimitRule(TimeSpan.FromSeconds(1), 10); + var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123"); + + // Act + for (int i = 0; i < 5; i++) + { + await rule.IsRequestAllowedAsync(request); + } + var result = await rule.IsRequestAllowedAsync(request); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task IsRequestAllowed_WhenCallCountExceedsLimits_ReturnsFalse() + { + // Arrange + var rule = new FixedWindowRateLimitRule(TimeSpan.FromSeconds(1), 1); + var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123"); + + // Act + await rule.IsRequestAllowedAsync(request); + var result = await rule.IsRequestAllowedAsync(request); + + // Assert + Assert.That(result, Is.False); + } +} diff --git a/RateLimiter.Tests/Rules/RegionRateLimitRuleTests.cs b/RateLimiter.Tests/Rules/RegionRateLimitRuleTests.cs new file mode 100644 index 00000000..ccdb1edf --- /dev/null +++ b/RateLimiter.Tests/Rules/RegionRateLimitRuleTests.cs @@ -0,0 +1,42 @@ +namespace RateLimiter.Tests.Rules; + +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using RateLimiter.Models; +using RateLimiter.Rules; + +[TestFixture] +public class RegionRateLimitRuleTests +{ + [Test] + public async Task IsRequestAllowed_WhenRegionMatched_CallsAppropriateRateLimitRule() + { + // Arrange + var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123", ClientRegion: Region.US); + var rateLimitRule = new Mock(); + var rule = new RegionRateLimitRule(Region.US, rateLimitRule.Object); + + // Act + + var result = await rule.IsRequestAllowedAsync(request); + + // Assert + rateLimitRule.Verify(it => it.IsRequestAllowedAsync(request), Times.Once); + } + + [Test] + public async Task IsRequestAllowed_WhenRegionDoesNotMatch_ReturnsTrue() + { + // Arrange + var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123", ClientRegion: Region.US); + var rule = new RegionRateLimitRule(Region.US, new Mock().Object); + + // Act + + var result = await rule.IsRequestAllowedAsync(request); + + // Assert + Assert.That(result, Is.True); + } +} diff --git a/RateLimiter.Tests/Rules/TimePassedSinceLastCallRateLimitRuleTests.cs b/RateLimiter.Tests/Rules/TimePassedSinceLastCallRateLimitRuleTests.cs new file mode 100644 index 00000000..6775a275 --- /dev/null +++ b/RateLimiter.Tests/Rules/TimePassedSinceLastCallRateLimitRuleTests.cs @@ -0,0 +1,40 @@ +namespace RateLimiter.Tests.Rules; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiter.Models; +using RateLimiter.Rules; + +[TestFixture] +public class TimePassedSinceLastCallRateLimitRuleTests +{ + [Test] + public async Task IsRequestAllowed_WhenRequiredTimePassed_ReturnsTrue() + { + // Arrange + var rule = new TimePassedSinceLastCallRateLimitRule(TimeSpan.Zero); + var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123"); + + // Act + var result = await rule.IsRequestAllowedAsync(request); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task IsRequestAllowed_WhenRequiredTimeNotPassed_ReturnsFalse() + { + // Arrange + var rule = new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromSeconds(1)); + var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123"); + + // Act + await rule.IsRequestAllowedAsync(request); + var result = await rule.IsRequestAllowedAsync(request); + + // Assert + Assert.That(result, Is.False); + } +} diff --git a/RateLimiter/IRateLimiter.cs b/RateLimiter/IRateLimiter.cs new file mode 100644 index 00000000..b3bb547e --- /dev/null +++ b/RateLimiter/IRateLimiter.cs @@ -0,0 +1,9 @@ +namespace RateLimiter; + +using System.Threading.Tasks; +using RateLimiter.Models; + +public interface IRateLimiter +{ + Task IsRequestAllowedAsync(RequestContext request); +} diff --git a/RateLimiter/Models/Region.cs b/RateLimiter/Models/Region.cs new file mode 100644 index 00000000..edcc60a4 --- /dev/null +++ b/RateLimiter/Models/Region.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Models; + +public enum Region +{ + Unknown, + US = 1, + EU = 2, +} diff --git a/RateLimiter/Models/RequestContext.cs b/RateLimiter/Models/RequestContext.cs new file mode 100644 index 00000000..ac0df949 --- /dev/null +++ b/RateLimiter/Models/RequestContext.cs @@ -0,0 +1,3 @@ +namespace RateLimiter.Models; + +public record RequestContext(string ResourceId, string AccessToken, Region ClientRegion = Region.Unknown); diff --git a/RateLimiter/Models/RequestKey.cs b/RateLimiter/Models/RequestKey.cs new file mode 100644 index 00000000..cd622598 --- /dev/null +++ b/RateLimiter/Models/RequestKey.cs @@ -0,0 +1,3 @@ +namespace RateLimiter.Models; + +public record RequestKey(string ResourceId, string Token); diff --git a/RateLimiter/RequestRateLimiter.cs b/RateLimiter/RequestRateLimiter.cs new file mode 100644 index 00000000..d9c9bbd6 --- /dev/null +++ b/RateLimiter/RequestRateLimiter.cs @@ -0,0 +1,29 @@ +namespace RateLimiter; + +using System.Threading.Tasks; +using RateLimiter.Models; +using RateLimiter.Store; + +public class RequestRateLimiter : IRateLimiter +{ + private readonly IRateLimitRuleStore _ruleStore; + + public RequestRateLimiter(IRateLimitRuleStore ruleStore) + { + _ruleStore = ruleStore; + } + + public async Task IsRequestAllowedAsync(RequestContext request) + { + var rules = _ruleStore.GetRules(request.ResourceId); + foreach (var rule in rules) + { + if (!await rule.IsRequestAllowedAsync(request)) + { + return false; + } + } + + return true; + } +} diff --git a/RateLimiter/Rules/FixedWindowRateLimitRule.cs b/RateLimiter/Rules/FixedWindowRateLimitRule.cs new file mode 100644 index 00000000..6b3e58db --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRateLimitRule.cs @@ -0,0 +1,61 @@ +namespace RateLimiter.Rules; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using RateLimiter.Models; + +public class FixedWindowRateLimitRule : IRateLimitRule +{ + + private class FixedWindow + { + private int _count; + + public FixedWindow(DateTime timestamp, int count) + { + Timestamp = timestamp; + _count = count; + } + + public DateTime Timestamp { get; } + public int Count => _count; + + public void IncrementCount() => Interlocked.Increment(ref _count); + }; + + + private readonly TimeSpan _windowDuration; + private readonly int _maxRequestsCount; + private readonly ConcurrentDictionary _clientWindows = new(); + + public FixedWindowRateLimitRule(TimeSpan windowDuration, int maxRequestsCount) + { + _windowDuration = windowDuration; + _maxRequestsCount = maxRequestsCount; + } + + public Task IsRequestAllowedAsync(RequestContext request) + { + var requestKey = new RequestKey(request.ResourceId, request.AccessToken); + var now = DateTime.UtcNow; + var fixedWindow = _clientWindows.GetValueOrDefault(requestKey); + + if (fixedWindow is null || fixedWindow.Timestamp < now - _windowDuration) + { + fixedWindow = new FixedWindow(now, 0); + _clientWindows[requestKey] = fixedWindow; + } + + if (fixedWindow.Count >= _maxRequestsCount) + { + return Task.FromResult(false); + } + + fixedWindow.IncrementCount(); + + return Task.FromResult(true); + } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..3f1803ef --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Rules; + +using System.Threading.Tasks; +using RateLimiter.Models; + +public interface IRateLimitRule +{ + Task IsRequestAllowedAsync(RequestContext request); +} diff --git a/RateLimiter/Rules/RegionRateLimitRule.cs b/RateLimiter/Rules/RegionRateLimitRule.cs new file mode 100644 index 00000000..714596ea --- /dev/null +++ b/RateLimiter/Rules/RegionRateLimitRule.cs @@ -0,0 +1,23 @@ +namespace RateLimiter.Rules; + +using System.Threading.Tasks; +using RateLimiter.Models; + +public class RegionRateLimitRule : IRateLimitRule +{ + private readonly Region _region; + private readonly IRateLimitRule _rateLimitRule; + + public RegionRateLimitRule(Region region, IRateLimitRule rateLimitRule) + { + _region = region; + _rateLimitRule = rateLimitRule; + } + + public Task IsRequestAllowedAsync(RequestContext request) + { + return _region == request.ClientRegion + ? _rateLimitRule.IsRequestAllowedAsync(request) + : Task.FromResult(true); + } +} diff --git a/RateLimiter/Rules/TimePassedSinceLastCallRateLimitRule.cs b/RateLimiter/Rules/TimePassedSinceLastCallRateLimitRule.cs new file mode 100644 index 00000000..fec17f57 --- /dev/null +++ b/RateLimiter/Rules/TimePassedSinceLastCallRateLimitRule.cs @@ -0,0 +1,33 @@ +namespace RateLimiter.Rules; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using RateLimiter.Models; + +public class TimePassedSinceLastCallRateLimitRule : IRateLimitRule +{ + private readonly TimeSpan _requiredTimeSpanBetweenCalls; + private readonly ConcurrentDictionary _clientLastCalls = new(); + + public TimePassedSinceLastCallRateLimitRule(TimeSpan requiredTimeSpanBetweenCalls) + { + _requiredTimeSpanBetweenCalls = requiredTimeSpanBetweenCalls; + } + + public Task IsRequestAllowedAsync(RequestContext request) + { + var requestKey = new RequestKey(request.ResourceId, request.AccessToken); + var now = DateTime.UtcNow; + var lastTimeStamp = _clientLastCalls.GetValueOrDefault(requestKey, DateTime.MinValue); + + if (lastTimeStamp < now - _requiredTimeSpanBetweenCalls) + { + _clientLastCalls[requestKey] = now; + return Task.FromResult(true); + } + + return Task.FromResult(false); + } +} diff --git a/RateLimiter/Store/IRateLimitRuleStore.cs b/RateLimiter/Store/IRateLimitRuleStore.cs new file mode 100644 index 00000000..480e2fee --- /dev/null +++ b/RateLimiter/Store/IRateLimitRuleStore.cs @@ -0,0 +1,11 @@ +namespace RateLimiter.Store; + +using System.Collections.Generic; +using RateLimiter.Rules; + +public interface IRateLimitRuleStore +{ + public void AddRules(string resourceId, params IRateLimitRule[] rules); + + IReadOnlyCollection GetRules(string resourceId); +} diff --git a/RateLimiter/Store/RateLimitRuleStore.cs b/RateLimiter/Store/RateLimitRuleStore.cs new file mode 100644 index 00000000..1833bff2 --- /dev/null +++ b/RateLimiter/Store/RateLimitRuleStore.cs @@ -0,0 +1,28 @@ +namespace RateLimiter.Store; + +using System.Collections.Generic; +using System.Linq; +using RateLimiter.Rules; + +public class RateLimitRuleStore : IRateLimitRuleStore +{ + private static readonly List EmptyRulesCollection = new(); + private readonly Dictionary> _rateLimitRules = new(); + + public void AddRules(string resourceId, params IRateLimitRule[] rules) + { + if (_rateLimitRules.TryGetValue(resourceId, out var existingRules)) + { + existingRules.AddRange(existingRules); + return; + } + + _rateLimitRules.Add(resourceId, rules.ToList()); + + } + + public IReadOnlyCollection GetRules(string resourceId) + { + return _rateLimitRules.GetValueOrDefault(resourceId, EmptyRulesCollection); + } +} From cf672dbe7b66d661d1b04348a9cc24f096b6d25d Mon Sep 17 00:00:00 2001 From: Andrey Mylnikov Date: Fri, 28 Feb 2025 15:45:50 +0300 Subject: [PATCH 2/2] Fixed typo in method that adds new rules --- RateLimiter/Store/RateLimitRuleStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RateLimiter/Store/RateLimitRuleStore.cs b/RateLimiter/Store/RateLimitRuleStore.cs index 1833bff2..16639ce5 100644 --- a/RateLimiter/Store/RateLimitRuleStore.cs +++ b/RateLimiter/Store/RateLimitRuleStore.cs @@ -13,7 +13,7 @@ public void AddRules(string resourceId, params IRateLimitRule[] rules) { if (_rateLimitRules.TryGetValue(resourceId, out var existingRules)) { - existingRules.AddRange(existingRules); + existingRules.AddRange(rules); return; }