From dd552ddc80606a52c39aee4ca581a650d898af79 Mon Sep 17 00:00:00 2001 From: Sargis Panosyan Date: Thu, 20 Feb 2025 19:14:21 -0800 Subject: [PATCH 1/8] Implemented rate limiter, one rule, and tests for limiter and rule --- RateLimiter.Tests/RateLimiter.Tests.csproj | 1 + RateLimiter.Tests/RateLimiterTest.cs | 71 +++++++++++++++++- .../Rules/RequestPerTimeSpanRuleTest.cs | 72 +++++++++++++++++++ RateLimiter/IRateLimiter.cs | 10 +++ RateLimiter/Models/UserModel.cs | 4 ++ RateLimiter/RateLimiter.cs | 32 +++++++++ RateLimiter/Rules/IRateLimitRule.cs | 13 ++++ .../Models/FixedTimeWindowLimitTracker.cs | 26 +++++++ RateLimiter/Rules/RequestPerTimeSpanRule.cs | 47 ++++++++++++ .../Stores/ConcurrentInMemoryRulesetStore.cs | 32 +++++++++ RateLimiter/Stores/IRulesetStore.cs | 12 ++++ 11 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs create mode 100644 RateLimiter/IRateLimiter.cs create mode 100644 RateLimiter/Models/UserModel.cs create mode 100644 RateLimiter/RateLimiter.cs create mode 100644 RateLimiter/Rules/IRateLimitRule.cs create mode 100644 RateLimiter/Rules/Models/FixedTimeWindowLimitTracker.cs create mode 100644 RateLimiter/Rules/RequestPerTimeSpanRule.cs create mode 100644 RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs create mode 100644 RateLimiter/Stores/IRulesetStore.cs 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 index 172d44a7..2d43b164 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,4 +1,8 @@ -using NUnit.Framework; +using Moq; +using NUnit.Framework; +using RateLimiter.Rules; +using RateLimiter.Stores; +using System.Collections.Generic; namespace RateLimiter.Tests; @@ -6,8 +10,69 @@ namespace RateLimiter.Tests; public class RateLimiterTest { [Test] - public void Example() + public void RateLimiter_AllowsRequest_When_NoRulesAreSet() { - Assert.That(true, Is.True); + // Arrange + var resourceId = "/api/resource"; + var userId = "user1"; + var rulesetStoreMock = new Mock(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object); + + // Act + var allowed = rateLimiter.IsRequestAllowed(resourceId, userId); + + // Assert + Assert.That(allowed, Is.True); } + + [Test] + public void RateLimiter_AllowsRequest_When_WithinConfiguredRuleLimits() + { + // Arrange + var resourceId = "/api/resource"; + var userId = "user1"; + var testRule = new Mock(); + var ruleList = new List() + { + testRule.Object + }; + var rulesetStoreMock = new Mock(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object); + + testRule.Setup(testRule => testRule.IsWithinLimit(userId)).Returns(true); + rulesetStoreMock.Setup(store => store.GetRules(resourceId)).Returns(ruleList); + + // Act + var allowed = rateLimiter.IsRequestAllowed(resourceId, userId); + + // Assert + Assert.That(allowed, Is.True); + } + + [Test] + public void RateLimiter_DeniesRequest_When_ConfiguredRulesFail() + { + // Arrange + var resourceId = "/api/resource"; + var userId = "user1"; + var testRule1 = new Mock(); + var testRule2 = new Mock(); + var ruleList = new List() + { + testRule1.Object, + testRule2.Object + }; + var rulesetStoreMock = new Mock(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object); + + testRule1.Setup(testRule => testRule.IsWithinLimit(userId)).Returns(true); + testRule2.Setup(testRule => testRule.IsWithinLimit(userId)).Returns(false); + rulesetStoreMock.Setup(store => store.GetRules(resourceId)).Returns(ruleList); + + // Act + var allowed = rateLimiter.IsRequestAllowed(resourceId, userId); + + // Assert + Assert.That(allowed, Is.False); + } } \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs new file mode 100644 index 00000000..0f918b66 --- /dev/null +++ b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using RateLimiter.Rules; +using NUnit.Framework; + +namespace RateLimiter.Tests.Rules +{ + internal class RequestPerTimeSpanRuleTest + { + [Test] + public void RequestPerTimeSpanRule_IsWithinLimit_ReturnsTrue_When_LimitIsNotExceeded() + { + // Arrange + string userId = "user1"; + uint numRequestsAllowed = 1; + var interval = new TimeSpan(0, 0, 5); + var rateLimitRule = new RequestPerTimeSpanRule(numRequestsAllowed, interval); + + // Act + var firstRequestAllowed = rateLimitRule.IsWithinLimit(userId); + + // Assert + Assert.IsTrue(firstRequestAllowed); + } + + [Test] + public void RequestPerTimeSpanRule_IsWithinLimit_ReturnsFalse_When_LimitIsExceeded() + { + // Arrange + string userId = "user1"; + uint numRequestsAllowed = 1; + var interval = new TimeSpan(0, 0, 5); + var rateLimitRule = new RequestPerTimeSpanRule(numRequestsAllowed, interval); + + // Act + var firstRequestAllowed = rateLimitRule.IsWithinLimit(userId); + var secondRequestAllowed = rateLimitRule.IsWithinLimit(userId); + + // Assert + Assert.IsTrue(firstRequestAllowed); + Assert.IsFalse(secondRequestAllowed); + } + + + [Test] + public void RequestPerTimeSpanRule_IsWithinLimit_MultipleRequests() + { + // Arrange + var delayInMs = 11; + string userId = "user1"; + uint numRequestsAllowed = 1; + var interval = new TimeSpan(0, 0, 0, 0, 10); + var rateLimitRule = new RequestPerTimeSpanRule(numRequestsAllowed, interval); + + // Act + var firstRequestAllowed = rateLimitRule.IsWithinLimit(userId); + var secondRequestAllowed = rateLimitRule.IsWithinLimit(userId); + var thirdRequestAllowed = () => rateLimitRule.IsWithinLimit(userId); + var fourthRequestAllowed = () => rateLimitRule.IsWithinLimit(userId); + + // Assert + Assert.IsTrue(firstRequestAllowed); + Assert.IsFalse(secondRequestAllowed); + Assert.That(() => thirdRequestAllowed(), Is.True.After(delayInMs)); + Assert.That(() => fourthRequestAllowed(), Is.False); + } + } +} diff --git a/RateLimiter/IRateLimiter.cs b/RateLimiter/IRateLimiter.cs new file mode 100644 index 00000000..4f6f84f6 --- /dev/null +++ b/RateLimiter/IRateLimiter.cs @@ -0,0 +1,10 @@ +using RateLimiter.Rules; + +namespace RateLimiter +{ + public interface IRateLimiter + { + void RegisterRule(string resourceId, IRateLimitRule rule); + bool IsRequestAllowed(string resourceId, string userId); + } +} diff --git a/RateLimiter/Models/UserModel.cs b/RateLimiter/Models/UserModel.cs new file mode 100644 index 00000000..239e0ed2 --- /dev/null +++ b/RateLimiter/Models/UserModel.cs @@ -0,0 +1,4 @@ +namespace RateLimiter.Models +{ + public record UserModel(string userId, string organizationId, string region); +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..4299909c --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,32 @@ +using RateLimiter.Rules; +using RateLimiter.Stores; +using System.Linq; + +namespace RateLimiter +{ + public class RateLimiter : IRateLimiter + { + private readonly IRulesetStore _rulesetStore; + + public RateLimiter(IRulesetStore rulesetStore) + { + _rulesetStore = rulesetStore; + } + + public bool IsRequestAllowed(string resourceId, string userId) + { + var applicableRules = _rulesetStore.GetRules(resourceId); + if (applicableRules == null || !applicableRules.Any()) + { + return true; + } + + return applicableRules.All(rule => rule.IsWithinLimit(userId)); + } + + public void RegisterRule(string resourceId, IRateLimitRule rule) + { + _rulesetStore.AddRule(resourceId, rule); + } + } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..2c6f4404 --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public interface IRateLimitRule + { + bool IsWithinLimit(string userId); + } +} diff --git a/RateLimiter/Rules/Models/FixedTimeWindowLimitTracker.cs b/RateLimiter/Rules/Models/FixedTimeWindowLimitTracker.cs new file mode 100644 index 00000000..37f75d4f --- /dev/null +++ b/RateLimiter/Rules/Models/FixedTimeWindowLimitTracker.cs @@ -0,0 +1,26 @@ +namespace RateLimiter.Rules.Models +{ + public class FixedTimeWindowLimitTracker + { + public FixedTimeWindowLimitTracker(uint count, long initialRequestTime) + { + Count = count; + InitialRequestTime = initialRequestTime; + } + + public uint Count { get; private set; } + + public long InitialRequestTime { get; private set; } + + public void IncrementCount() + { + Count++; + } + + public void Reset(long initialRequestTime) + { + Count = 1; + InitialRequestTime = initialRequestTime; + } + } +} diff --git a/RateLimiter/Rules/RequestPerTimeSpanRule.cs b/RateLimiter/Rules/RequestPerTimeSpanRule.cs new file mode 100644 index 00000000..a0fb1164 --- /dev/null +++ b/RateLimiter/Rules/RequestPerTimeSpanRule.cs @@ -0,0 +1,47 @@ +using RateLimiter.Rules.Models; +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules +{ + public class RequestPerTimeSpanRule : IRateLimitRule + { + private readonly ConcurrentDictionary _userRequestLimits; + private readonly TimeSpan _interval; + private readonly uint _numberOfRequests; + + public RequestPerTimeSpanRule(uint numberOfRequests, TimeSpan interval) + { + _interval = interval; + _numberOfRequests = numberOfRequests; + _userRequestLimits = new ConcurrentDictionary(); + } + + public bool IsWithinLimit(string userId) + { + var currentRequestTime = DateTime.UtcNow.Ticks; + var limitTracker = _userRequestLimits.GetOrAdd(userId, new FixedTimeWindowLimitTracker(0, currentRequestTime)); + + if (IsWithinTimeInterval(currentRequestTime, limitTracker.InitialRequestTime)) + { + limitTracker.IncrementCount(); + } + else + { + limitTracker.Reset(currentRequestTime); + } + + if (limitTracker.Count > _numberOfRequests) + { + return false; + } + + return true; + } + + private bool IsWithinTimeInterval(long currentRequestTime, long initialRequestTime) + { + return currentRequestTime - initialRequestTime <= _interval.Ticks; + } + } +} diff --git a/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs b/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs new file mode 100644 index 00000000..12089fec --- /dev/null +++ b/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs @@ -0,0 +1,32 @@ +using RateLimiter.Rules; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter.Stores +{ + public class ConcurrentInMemoryRulesetStore : IRulesetStore + { + private readonly ConcurrentDictionary> _rulesetStore; + + public ConcurrentInMemoryRulesetStore(ConcurrentDictionary> rulesetStore) + { + _rulesetStore = rulesetStore; + } + + public void AddRule(string resourceId, IRateLimitRule rule) + { + var currentRules = _rulesetStore.GetOrAdd(resourceId, new List()); + currentRules.Add(rule); + } + + public void ClearRules(string resourceId) + { + _rulesetStore.TryRemove(resourceId, out _); + } + + public IList GetRules(string resourceId) + { + return _rulesetStore.GetOrAdd(resourceId, new List()); + } + } +} diff --git a/RateLimiter/Stores/IRulesetStore.cs b/RateLimiter/Stores/IRulesetStore.cs new file mode 100644 index 00000000..5755db31 --- /dev/null +++ b/RateLimiter/Stores/IRulesetStore.cs @@ -0,0 +1,12 @@ +using RateLimiter.Rules; +using System.Collections.Generic; + +namespace RateLimiter.Stores +{ + public interface IRulesetStore + { + IList GetRules(string resourceId); + void AddRule(string resourceId, IRateLimitRule rule); + void ClearRules(string resourceId); + } +} From 420f57147d93f81917370f552cab8e92badc19e0 Mon Sep 17 00:00:00 2001 From: Sargis Panosyan Date: Sat, 22 Feb 2025 01:47:26 -0800 Subject: [PATCH 2/8] Refactored rate limiting rules. Updated rate limiter to take a request model. The rate limiting rules were refactored to utilize semaphores to ensure limit counting is consistent across multiple threads. The rate limiter was updated to use a RequestModel so that requests can be limited based on different request metadata: request path, user id, organization id, IP address, or region. Tests were updated to reflect these changes. --- RateLimiter.Tests/RateLimiterTest.cs | 39 +++++---- .../Rules/RequestPerTimeSpanRuleTest.cs | 82 +++++++++++-------- RateLimiter/IRateLimiter.cs | 6 +- RateLimiter/Models/RateLimitCounterModel.cs | 15 ++++ RateLimiter/Models/RequestModel.cs | 4 + RateLimiter/Models/UserModel.cs | 4 - RateLimiter/RateLimiter.cs | 35 ++++++-- RateLimiter/RateLimiter.csproj | 3 + RateLimiter/Rules/BaseRateLimitRule.cs | 41 ++++++++++ RateLimiter/Rules/IRateLimitRule.cs | 7 +- .../Models/FixedTimeWindowLimitTracker.cs | 26 ------ RateLimiter/Rules/RequestPerTimeSpanRule.cs | 47 ----------- RateLimiter/Rules/RequestsPerTimeSpanRule.cs | 64 +++++++++++++++ .../Rules/RequestsPerUserPerTimeSpanRule.cs | 60 ++++++++++++++ .../ConcurrentInMemoryRateLimitDataStore.cs | 36 ++++++++ RateLimiter/Stores/IRateLimitDataStore.cs | 11 +++ 16 files changed, 340 insertions(+), 140 deletions(-) create mode 100644 RateLimiter/Models/RateLimitCounterModel.cs create mode 100644 RateLimiter/Models/RequestModel.cs delete mode 100644 RateLimiter/Models/UserModel.cs create mode 100644 RateLimiter/Rules/BaseRateLimitRule.cs delete mode 100644 RateLimiter/Rules/Models/FixedTimeWindowLimitTracker.cs delete mode 100644 RateLimiter/Rules/RequestPerTimeSpanRule.cs create mode 100644 RateLimiter/Rules/RequestsPerTimeSpanRule.cs create mode 100644 RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs create mode 100644 RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs create mode 100644 RateLimiter/Stores/IRateLimitDataStore.cs diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 2d43b164..b7e2d928 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,8 +1,11 @@ -using Moq; +using Microsoft.Extensions.Logging; +using Moq; using NUnit.Framework; +using RateLimiter.Models; using RateLimiter.Rules; using RateLimiter.Stores; using System.Collections.Generic; +using System.Threading.Tasks; namespace RateLimiter.Tests; @@ -10,51 +13,56 @@ namespace RateLimiter.Tests; public class RateLimiterTest { [Test] - public void RateLimiter_AllowsRequest_When_NoRulesAreSet() + public async Task RateLimiter_AllowsRequest_When_NoRulesAreSet() { // Arrange var resourceId = "/api/resource"; var userId = "user1"; + var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); var rulesetStoreMock = new Mock(); - var rateLimiter = new RateLimiter(rulesetStoreMock.Object); + var loggerMock = new Mock>(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object); // Act - var allowed = rateLimiter.IsRequestAllowed(resourceId, userId); + var allowed = await rateLimiter.IsRequestAllowedAsync(request); // Assert Assert.That(allowed, Is.True); } [Test] - public void RateLimiter_AllowsRequest_When_WithinConfiguredRuleLimits() + public async Task RateLimiter_AllowsRequest_When_WithinConfiguredRuleLimits() { // Arrange var resourceId = "/api/resource"; var userId = "user1"; + var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); var testRule = new Mock(); var ruleList = new List() { testRule.Object }; var rulesetStoreMock = new Mock(); - var rateLimiter = new RateLimiter(rulesetStoreMock.Object); + var loggerMock = new Mock>(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object); - testRule.Setup(testRule => testRule.IsWithinLimit(userId)).Returns(true); + testRule.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(true); rulesetStoreMock.Setup(store => store.GetRules(resourceId)).Returns(ruleList); // Act - var allowed = rateLimiter.IsRequestAllowed(resourceId, userId); + var allowed = await rateLimiter.IsRequestAllowedAsync(request); // Assert Assert.That(allowed, Is.True); } [Test] - public void RateLimiter_DeniesRequest_When_ConfiguredRulesFail() + public async Task RateLimiter_DeniesRequest_When_ConfiguredRulesFail() { // Arrange - var resourceId = "/api/resource"; + var requestPath = "/api/resource"; var userId = "user1"; + var request = new RequestModel(requestPath, userId, string.Empty, string.Empty, string.Empty); var testRule1 = new Mock(); var testRule2 = new Mock(); var ruleList = new List() @@ -63,14 +71,15 @@ public void RateLimiter_DeniesRequest_When_ConfiguredRulesFail() testRule2.Object }; var rulesetStoreMock = new Mock(); - var rateLimiter = new RateLimiter(rulesetStoreMock.Object); + var loggerMock = new Mock>(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object); - testRule1.Setup(testRule => testRule.IsWithinLimit(userId)).Returns(true); - testRule2.Setup(testRule => testRule.IsWithinLimit(userId)).Returns(false); - rulesetStoreMock.Setup(store => store.GetRules(resourceId)).Returns(ruleList); + testRule1.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(true); + testRule2.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(false); + rulesetStoreMock.Setup(store => store.GetRules(requestPath)).Returns(ruleList); // Act - var allowed = rateLimiter.IsRequestAllowed(resourceId, userId); + var allowed = await rateLimiter.IsRequestAllowedAsync(request); // Assert Assert.That(allowed, Is.False); diff --git a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs index 0f918b66..c55d293c 100644 --- a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs +++ b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs @@ -1,72 +1,84 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Moq; -using RateLimiter.Rules; using NUnit.Framework; +using RateLimiter.Models; +using RateLimiter.Rules; +using RateLimiter.Stores; +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; namespace RateLimiter.Tests.Rules { internal class RequestPerTimeSpanRuleTest { [Test] - public void RequestPerTimeSpanRule_IsWithinLimit_ReturnsTrue_When_LimitIsNotExceeded() + public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsTrue_When_LimitIsNotExceeded() { // Arrange - string userId = "user1"; - uint numRequestsAllowed = 1; - var interval = new TimeSpan(0, 0, 5); - var rateLimitRule = new RequestPerTimeSpanRule(numRequestsAllowed, interval); + var resourceId = "/api/path"; + var userId = "user1"; + var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); + var numRequestsAllowed = 1; + var interval = new TimeSpan(hours: 0, minutes: 0, seconds: 5); + var loggerMock = new Mock>(); + var dataStoreMock = new Mock>(); + var rateLimitRule = new RequestsPerTimeSpanRule(numRequestsAllowed, interval, dataStoreMock.Object, loggerMock.Object); // Act - var firstRequestAllowed = rateLimitRule.IsWithinLimit(userId); + var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); // Assert Assert.IsTrue(firstRequestAllowed); } [Test] - public void RequestPerTimeSpanRule_IsWithinLimit_ReturnsFalse_When_LimitIsExceeded() + public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsFalse_When_LimitIsExceeded() { // Arrange - string userId = "user1"; - uint numRequestsAllowed = 1; - var interval = new TimeSpan(0, 0, 5); - var rateLimitRule = new RequestPerTimeSpanRule(numRequestsAllowed, interval); + var resourceId = "/api/path"; + int numRequestsAllowed = 1; + var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 1); + var loggerMock = new Mock>(); + var concurrentDict = new ConcurrentDictionary(); + var dataStore = new ConcurrentInMemoryRateLimitDataStore(concurrentDict); + var rateLimitRule = new RequestsPerTimeSpanRule(numRequestsAllowed, interval, dataStore, loggerMock.Object); // Act - var firstRequestAllowed = rateLimitRule.IsWithinLimit(userId); - var secondRequestAllowed = rateLimitRule.IsWithinLimit(userId); + var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); + var secondRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); // Assert - Assert.IsTrue(firstRequestAllowed); - Assert.IsFalse(secondRequestAllowed); + Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); + Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); } [Test] - public void RequestPerTimeSpanRule_IsWithinLimit_MultipleRequests() + public async Task RequestPerTimeSpanRule_IsWithinLimit_MultipleRequests() { // Arrange - var delayInMs = 11; - string userId = "user1"; - uint numRequestsAllowed = 1; - var interval = new TimeSpan(0, 0, 0, 0, 10); - var rateLimitRule = new RequestPerTimeSpanRule(numRequestsAllowed, interval); + var delayInMs = 1001; + var resourceId = "/api/path"; + var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); + int numRequestsAllowed = 1; + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 1); + var loggerMock = new Mock>(); + var concurrentDict = new ConcurrentDictionary(); + var dataStore = new ConcurrentInMemoryRateLimitDataStore(concurrentDict); + var rateLimitRule = new RequestsPerTimeSpanRule(numRequestsAllowed, interval, dataStore, loggerMock.Object); // Act - var firstRequestAllowed = rateLimitRule.IsWithinLimit(userId); - var secondRequestAllowed = rateLimitRule.IsWithinLimit(userId); - var thirdRequestAllowed = () => rateLimitRule.IsWithinLimit(userId); - var fourthRequestAllowed = () => rateLimitRule.IsWithinLimit(userId); + var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); + var secondRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); + var thirdRequestAllowed = async () => await rateLimitRule.IsWithinLimitAsync(request); // Assert - Assert.IsTrue(firstRequestAllowed); - Assert.IsFalse(secondRequestAllowed); - Assert.That(() => thirdRequestAllowed(), Is.True.After(delayInMs)); - Assert.That(() => fourthRequestAllowed(), Is.False); + Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); + Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); + Assert.That(() => thirdRequestAllowed(), Is.True.After(delayInMs), "Expected third request to be allowed"); + Assert.That(async () => await rateLimitRule.IsWithinLimitAsync(request), Is.False, "Expected fourth request to be denied"); } } } diff --git a/RateLimiter/IRateLimiter.cs b/RateLimiter/IRateLimiter.cs index 4f6f84f6..172fb041 100644 --- a/RateLimiter/IRateLimiter.cs +++ b/RateLimiter/IRateLimiter.cs @@ -1,10 +1,12 @@ -using RateLimiter.Rules; +using RateLimiter.Models; +using RateLimiter.Rules; +using System.Threading.Tasks; namespace RateLimiter { public interface IRateLimiter { void RegisterRule(string resourceId, IRateLimitRule rule); - bool IsRequestAllowed(string resourceId, string userId); + Task IsRequestAllowedAsync(RequestModel request); } } diff --git a/RateLimiter/Models/RateLimitCounterModel.cs b/RateLimiter/Models/RateLimitCounterModel.cs new file mode 100644 index 00000000..edf7d943 --- /dev/null +++ b/RateLimiter/Models/RateLimitCounterModel.cs @@ -0,0 +1,15 @@ +namespace RateLimiter.Models +{ + public class RateLimitCounterModel + { + public RateLimitCounterModel(uint count, long requestTime) + { + RequestCount = count; + RequestTime = requestTime; + } + + public uint RequestCount { get; set; } + + public long RequestTime { get; set; } + } +} diff --git a/RateLimiter/Models/RequestModel.cs b/RateLimiter/Models/RequestModel.cs new file mode 100644 index 00000000..cdf138ec --- /dev/null +++ b/RateLimiter/Models/RequestModel.cs @@ -0,0 +1,4 @@ +namespace RateLimiter.Models +{ + public record RequestModel(string RequestPath, string UserId, string OrganizationId, string IpAddress, string Region); +} diff --git a/RateLimiter/Models/UserModel.cs b/RateLimiter/Models/UserModel.cs deleted file mode 100644 index 239e0ed2..00000000 --- a/RateLimiter/Models/UserModel.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace RateLimiter.Models -{ - public record UserModel(string userId, string organizationId, string region); -} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 4299909c..7bd03244 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -1,27 +1,50 @@ -using RateLimiter.Rules; +using Microsoft.Extensions.Logging; +using RateLimiter.Models; +using RateLimiter.Rules; using RateLimiter.Stores; +using System; using System.Linq; +using System.Threading.Tasks; namespace RateLimiter { public class RateLimiter : IRateLimiter { private readonly IRulesetStore _rulesetStore; + private readonly ILogger _logger; - public RateLimiter(IRulesetStore rulesetStore) + public RateLimiter(IRulesetStore rulesetStore, ILogger logger) { _rulesetStore = rulesetStore; + _logger = logger; } - public bool IsRequestAllowed(string resourceId, string userId) + public async Task IsRequestAllowedAsync(RequestModel request) { - var applicableRules = _rulesetStore.GetRules(resourceId); - if (applicableRules == null || !applicableRules.Any()) + try { + var applicableRules = _rulesetStore.GetRules(request.RequestPath); + if (applicableRules == null || !applicableRules.Any()) + { + return true; + } + + foreach (var rule in applicableRules) + { + if (!await rule.IsWithinLimitAsync(request)) + { + return false; + } + } + return true; } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } - return applicableRules.All(rule => rule.IsWithinLimit(userId)); + return true; } public void RegisterRule(string resourceId, IRateLimitRule rule) diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..2fc2d6fb 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,7 @@ latest enable + + + \ No newline at end of file diff --git a/RateLimiter/Rules/BaseRateLimitRule.cs b/RateLimiter/Rules/BaseRateLimitRule.cs new file mode 100644 index 00000000..d8aa9112 --- /dev/null +++ b/RateLimiter/Rules/BaseRateLimitRule.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Logging; +using RateLimiter.Models; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public abstract class BaseRateLimitRule : IRateLimitRule + { + private readonly SemaphoreSlim _semaphoreSlim; + private readonly ILogger _logger; + + public BaseRateLimitRule(int numberOfRequests, ILogger logger) + { + _semaphoreSlim = new SemaphoreSlim(numberOfRequests); + _logger = logger; + } + + public async Task IsWithinLimitAsync(RequestModel request) + { + await _semaphoreSlim.WaitAsync(); + try + { + return await ProcessRuleAsync(request); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } + finally + { + _semaphoreSlim.Release(); + } + + return true; + } + + protected abstract Task ProcessRuleAsync(RequestModel request); + } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs index 2c6f4404..a79535d2 100644 --- a/RateLimiter/Rules/IRateLimitRule.cs +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -1,13 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using RateLimiter.Models; using System.Threading.Tasks; namespace RateLimiter.Rules { public interface IRateLimitRule { - bool IsWithinLimit(string userId); + Task IsWithinLimitAsync(RequestModel request); } } diff --git a/RateLimiter/Rules/Models/FixedTimeWindowLimitTracker.cs b/RateLimiter/Rules/Models/FixedTimeWindowLimitTracker.cs deleted file mode 100644 index 37f75d4f..00000000 --- a/RateLimiter/Rules/Models/FixedTimeWindowLimitTracker.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace RateLimiter.Rules.Models -{ - public class FixedTimeWindowLimitTracker - { - public FixedTimeWindowLimitTracker(uint count, long initialRequestTime) - { - Count = count; - InitialRequestTime = initialRequestTime; - } - - public uint Count { get; private set; } - - public long InitialRequestTime { get; private set; } - - public void IncrementCount() - { - Count++; - } - - public void Reset(long initialRequestTime) - { - Count = 1; - InitialRequestTime = initialRequestTime; - } - } -} diff --git a/RateLimiter/Rules/RequestPerTimeSpanRule.cs b/RateLimiter/Rules/RequestPerTimeSpanRule.cs deleted file mode 100644 index a0fb1164..00000000 --- a/RateLimiter/Rules/RequestPerTimeSpanRule.cs +++ /dev/null @@ -1,47 +0,0 @@ -using RateLimiter.Rules.Models; -using System; -using System.Collections.Concurrent; - -namespace RateLimiter.Rules -{ - public class RequestPerTimeSpanRule : IRateLimitRule - { - private readonly ConcurrentDictionary _userRequestLimits; - private readonly TimeSpan _interval; - private readonly uint _numberOfRequests; - - public RequestPerTimeSpanRule(uint numberOfRequests, TimeSpan interval) - { - _interval = interval; - _numberOfRequests = numberOfRequests; - _userRequestLimits = new ConcurrentDictionary(); - } - - public bool IsWithinLimit(string userId) - { - var currentRequestTime = DateTime.UtcNow.Ticks; - var limitTracker = _userRequestLimits.GetOrAdd(userId, new FixedTimeWindowLimitTracker(0, currentRequestTime)); - - if (IsWithinTimeInterval(currentRequestTime, limitTracker.InitialRequestTime)) - { - limitTracker.IncrementCount(); - } - else - { - limitTracker.Reset(currentRequestTime); - } - - if (limitTracker.Count > _numberOfRequests) - { - return false; - } - - return true; - } - - private bool IsWithinTimeInterval(long currentRequestTime, long initialRequestTime) - { - return currentRequestTime - initialRequestTime <= _interval.Ticks; - } - } -} diff --git a/RateLimiter/Rules/RequestsPerTimeSpanRule.cs b/RateLimiter/Rules/RequestsPerTimeSpanRule.cs new file mode 100644 index 00000000..b4600c14 --- /dev/null +++ b/RateLimiter/Rules/RequestsPerTimeSpanRule.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; +using RateLimiter.Models; +using RateLimiter.Stores; +using System; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public class RequestsPerTimeSpanRule : BaseRateLimitRule + { + private readonly IRateLimitDataStore _store; + private readonly TimeSpan _interval; + private readonly int _numberOfRequestsAllowed; + + public RequestsPerTimeSpanRule( + int numberOfRequests, + TimeSpan interval, + IRateLimitDataStore store, + ILogger logger) + : base(numberOfRequests, logger) + { + _interval = interval; + _numberOfRequestsAllowed = numberOfRequests; + _store = store; + } + + protected override Task ProcessRuleAsync(RequestModel request) + { + var currentRequestTime = DateTime.UtcNow.Ticks; + var limitCounterModel = _store.Get(request.RequestPath); + + if (limitCounterModel == null) + { + limitCounterModel = new RateLimitCounterModel(0, currentRequestTime); + _store.Add(request.RequestPath, limitCounterModel); + } + + // If the current request is within the timespan, + if (IsWithinTimeInterval(currentRequestTime, limitCounterModel.RequestTime)) + { + limitCounterModel.RequestCount++; + } + else + { + limitCounterModel.RequestCount = 1; + limitCounterModel.RequestTime = currentRequestTime; + } + + _store.Update(request.RequestPath, limitCounterModel); + + if (limitCounterModel.RequestCount > _numberOfRequestsAllowed) + { + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + private bool IsWithinTimeInterval(long currentRequestTime, long initialRequestTime) + { + return currentRequestTime - initialRequestTime < _interval.Ticks; + } + } +} diff --git a/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs b/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs new file mode 100644 index 00000000..0ba7db60 --- /dev/null +++ b/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; +using RateLimiter.Models; +using RateLimiter.Stores; +using System; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public class RequestsPerUserPerTimeSpanRule : BaseRateLimitRule + { + private readonly IRateLimitDataStore _store; + private readonly TimeSpan _interval; + private readonly int _numberOfRequests; + + public RequestsPerUserPerTimeSpanRule( + int numberOfRequests, + TimeSpan interval, + IRateLimitDataStore store, + ILogger logger) + : base(numberOfRequests, logger) + { + _interval = interval; + _numberOfRequests = numberOfRequests; + _store = store; + } + + protected override Task ProcessRuleAsync(RequestModel request) + { + var currentRequestTime = DateTime.UtcNow.Ticks; + var limitCounterModel = _store.Get(request.RequestPath); + + if (limitCounterModel == null) + { + limitCounterModel = new RateLimitCounterModel(0, currentRequestTime); + _store.Add(request.RequestPath, limitCounterModel); + } + + // If the current request is within the timespan, + if (IsWithinTimeInterval(currentRequestTime, limitCounterModel.RequestTime)) + { + limitCounterModel = new RateLimitCounterModel(limitCounterModel.RequestCount + 1, limitCounterModel.RequestTime); + + } + + _store.Update(request.UserId, limitCounterModel); + + if (limitCounterModel.RequestCount > _numberOfRequests) + { + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + private bool IsWithinTimeInterval(long currentRequestTime, long initialRequestTime) + { + return currentRequestTime - initialRequestTime <= _interval.Ticks; + } + } +} diff --git a/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs b/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs new file mode 100644 index 00000000..a27dc3fa --- /dev/null +++ b/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs @@ -0,0 +1,36 @@ +using RateLimiter.Models; +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Stores +{ + public class ConcurrentInMemoryRateLimitDataStore : IRateLimitDataStore + { + private readonly ConcurrentDictionary _store; + + public ConcurrentInMemoryRateLimitDataStore(ConcurrentDictionary store) + { + _store = store; + } + + public RateLimitCounterModel? Get(string key) + { + if (_store.TryGetValue(key, out var model)) + { + return model; + } + + return null; + } + + public void Add(string key, RateLimitCounterModel value) + { + _store.GetOrAdd(key, value); + } + + public void Update(string key, RateLimitCounterModel value) + { + _store.AddOrUpdate(key, value, (key, oldValue) => value); + } + } +} diff --git a/RateLimiter/Stores/IRateLimitDataStore.cs b/RateLimiter/Stores/IRateLimitDataStore.cs new file mode 100644 index 00000000..aec0f7fa --- /dev/null +++ b/RateLimiter/Stores/IRateLimitDataStore.cs @@ -0,0 +1,11 @@ +using RateLimiter.Models; + +namespace RateLimiter.Stores +{ + public interface IRateLimitDataStore where T : class + { + T? Get(string key); + void Add(string key, RateLimitCounterModel value); + void Update(string key, T value); + } +} From bed552da554720077053d411c31c57adaf8e4b42 Mon Sep 17 00:00:00 2001 From: Sargis Panosyan Date: Sun, 23 Feb 2025 15:42:24 -0800 Subject: [PATCH 3/8] Created factories for the rate limit data store and rule creation --- RateLimiter.Tests/RateLimiterTest.cs | 6 +-- .../Rules/RequestPerTimeSpanRuleTest.cs | 47 +++++++++++-------- .../Factories/IRateLimitDataStoreFactory.cs | 9 ++++ .../Factories/IRateLimitRuleFactory.cs | 11 +++++ .../Factories/RateLimitDataStoreFactory.cs | 23 +++++++++ RateLimiter/Factories/RateLimitRuleFactory.cs | 33 +++++++++++++ RateLimiter/RateLimiter.cs | 8 ++-- RateLimiter/Rules/BaseRateLimitRule.cs | 16 ++----- RateLimiter/Rules/RateLimitRuleTypes.cs | 8 ++++ RateLimiter/Rules/RequestsPerTimeSpanRule.cs | 12 ++--- .../Rules/RequestsPerUserPerTimeSpanRule.cs | 13 ++--- .../ConcurrentInMemoryRateLimitDataStore.cs | 6 +-- RateLimiter/Stores/IRateLimitDataStore.cs | 6 +-- RateLimiter/Stores/RateLimitDataStoreTypes.cs | 7 +++ 14 files changed, 145 insertions(+), 60 deletions(-) create mode 100644 RateLimiter/Factories/IRateLimitDataStoreFactory.cs create mode 100644 RateLimiter/Factories/IRateLimitRuleFactory.cs create mode 100644 RateLimiter/Factories/RateLimitDataStoreFactory.cs create mode 100644 RateLimiter/Factories/RateLimitRuleFactory.cs create mode 100644 RateLimiter/Rules/RateLimitRuleTypes.cs create mode 100644 RateLimiter/Stores/RateLimitDataStoreTypes.cs diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index b7e2d928..7b83654b 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,11 +1,11 @@ -using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using RateLimiter.Models; using RateLimiter.Rules; using RateLimiter.Stores; -using System.Collections.Generic; -using System.Threading.Tasks; namespace RateLimiter.Tests; diff --git a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs index c55d293c..ab6ee68a 100644 --- a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs +++ b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs @@ -1,12 +1,10 @@ -using Microsoft.Extensions.Logging; -using Moq; +using System; +using System.Threading.Tasks; using NUnit.Framework; +using RateLimiter.Factories; using RateLimiter.Models; using RateLimiter.Rules; using RateLimiter.Stores; -using System; -using System.Collections.Concurrent; -using System.Threading.Tasks; namespace RateLimiter.Tests.Rules { @@ -21,9 +19,13 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsTrue_When_LimitIsN var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); var numRequestsAllowed = 1; var interval = new TimeSpan(hours: 0, minutes: 0, seconds: 5); - var loggerMock = new Mock>(); - var dataStoreMock = new Mock>(); - var rateLimitRule = new RequestsPerTimeSpanRule(numRequestsAllowed, interval, dataStoreMock.Object, loggerMock.Object); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRateLimitRule( + RateLimitRuleTypes.RequestsPerTimeSpan, + RateLimitDataStoreTypes.ConcurrentInMemory, + numRequestsAllowed, + interval); // Act var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); @@ -40,10 +42,13 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsFalse_When_LimitIs int numRequestsAllowed = 1; var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 1); - var loggerMock = new Mock>(); - var concurrentDict = new ConcurrentDictionary(); - var dataStore = new ConcurrentInMemoryRateLimitDataStore(concurrentDict); - var rateLimitRule = new RequestsPerTimeSpanRule(numRequestsAllowed, interval, dataStore, loggerMock.Object); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRateLimitRule( + RateLimitRuleTypes.RequestsPerTimeSpan, + RateLimitDataStoreTypes.ConcurrentInMemory, + numRequestsAllowed, + interval); // Act var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); @@ -59,26 +64,30 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsFalse_When_LimitIs public async Task RequestPerTimeSpanRule_IsWithinLimit_MultipleRequests() { // Arrange - var delayInMs = 1001; + var delayInMs = 11; var resourceId = "/api/path"; var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); int numRequestsAllowed = 1; - var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 1); - var loggerMock = new Mock>(); - var concurrentDict = new ConcurrentDictionary(); - var dataStore = new ConcurrentInMemoryRateLimitDataStore(concurrentDict); - var rateLimitRule = new RequestsPerTimeSpanRule(numRequestsAllowed, interval, dataStore, loggerMock.Object); + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRateLimitRule( + RateLimitRuleTypes.RequestsPerTimeSpan, + RateLimitDataStoreTypes.ConcurrentInMemory, + numRequestsAllowed, + interval); // Act var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); var secondRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); var thirdRequestAllowed = async () => await rateLimitRule.IsWithinLimitAsync(request); + var fourthRequestAllowed = async () => await rateLimitRule.IsWithinLimitAsync(request); // Assert Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); Assert.That(() => thirdRequestAllowed(), Is.True.After(delayInMs), "Expected third request to be allowed"); - Assert.That(async () => await rateLimitRule.IsWithinLimitAsync(request), Is.False, "Expected fourth request to be denied"); + Assert.That(() => fourthRequestAllowed(), Is.False, "Expected fourth request to be denied"); } } } diff --git a/RateLimiter/Factories/IRateLimitDataStoreFactory.cs b/RateLimiter/Factories/IRateLimitDataStoreFactory.cs new file mode 100644 index 00000000..87e0acf1 --- /dev/null +++ b/RateLimiter/Factories/IRateLimitDataStoreFactory.cs @@ -0,0 +1,9 @@ +using RateLimiter.Stores; + +namespace RateLimiter.Factories +{ + public interface IRateLimitDataStoreFactory + { + IRateLimitDataStore CreateDataStore(RateLimitDataStoreTypes dataStoreType); + } +} diff --git a/RateLimiter/Factories/IRateLimitRuleFactory.cs b/RateLimiter/Factories/IRateLimitRuleFactory.cs new file mode 100644 index 00000000..629b2318 --- /dev/null +++ b/RateLimiter/Factories/IRateLimitRuleFactory.cs @@ -0,0 +1,11 @@ +using System; +using RateLimiter.Rules; +using RateLimiter.Stores; + +namespace RateLimiter.Factories +{ + public interface IRateLimitRuleFactory + { + IRateLimitRule CreateRateLimitRule(RateLimitRuleTypes ruleType, RateLimitDataStoreTypes dataStoreType, int numberOfRequests, TimeSpan interval); + } +} diff --git a/RateLimiter/Factories/RateLimitDataStoreFactory.cs b/RateLimiter/Factories/RateLimitDataStoreFactory.cs new file mode 100644 index 00000000..a0da437c --- /dev/null +++ b/RateLimiter/Factories/RateLimitDataStoreFactory.cs @@ -0,0 +1,23 @@ +using RateLimiter.Models; +using RateLimiter.Stores; +using System; + +namespace RateLimiter.Factories +{ + public class RateLimitDataStoreFactory : IRateLimitDataStoreFactory + { + private static readonly string UnknownDataStoreError = "Unknown RateLimitDataStoreType: {0}"; + + public IRateLimitDataStore CreateDataStore(RateLimitDataStoreTypes dataStoreType) + { + switch (dataStoreType) + { + case RateLimitDataStoreTypes.ConcurrentInMemory: + return new ConcurrentInMemoryRateLimitDataStore(); + default: + var errorMessage = string.Format(UnknownDataStoreError, dataStoreType.ToString()); + throw new NotImplementedException(errorMessage); + } + } + } +} diff --git a/RateLimiter/Factories/RateLimitRuleFactory.cs b/RateLimiter/Factories/RateLimitRuleFactory.cs new file mode 100644 index 00000000..561612bc --- /dev/null +++ b/RateLimiter/Factories/RateLimitRuleFactory.cs @@ -0,0 +1,33 @@ +using System; +using RateLimiter.Rules; +using RateLimiter.Stores; + +namespace RateLimiter.Factories +{ + public class RateLimitRuleFactory : IRateLimitRuleFactory + { + private static readonly string UnknownRuleError = "Unknown RateLimitRuleType: {0}"; + private readonly IRateLimitDataStoreFactory _rateLimitDataStoreFactory; + + public RateLimitRuleFactory(IRateLimitDataStoreFactory rateLimitDataStoreFactory) + { + _rateLimitDataStoreFactory = rateLimitDataStoreFactory; + } + + public IRateLimitRule CreateRateLimitRule(RateLimitRuleTypes ruleType, RateLimitDataStoreTypes dataStoreTypeint, int numberOfRequests, TimeSpan interval) + { + var dataStore = _rateLimitDataStoreFactory.CreateDataStore(dataStoreTypeint); + + switch (ruleType) + { + case RateLimitRuleTypes.RequestsPerTimeSpan: + return new RequestsPerTimeSpanRule(numberOfRequests, interval, dataStore); + case RateLimitRuleTypes.RequestsPerUserPerTimeSpan: + return new RequestsPerUserPerTimeSpanRule(numberOfRequests, interval, dataStore); + default: + var errorMessage = string.Format(UnknownRuleError, ruleType.ToString()); + throw new NotImplementedException(errorMessage); + } + } + } +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 7bd03244..b3372f3f 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -1,10 +1,10 @@ -using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using RateLimiter.Models; using RateLimiter.Rules; using RateLimiter.Stores; -using System; -using System.Linq; -using System.Threading.Tasks; namespace RateLimiter { diff --git a/RateLimiter/Rules/BaseRateLimitRule.cs b/RateLimiter/Rules/BaseRateLimitRule.cs index d8aa9112..7419a25c 100644 --- a/RateLimiter/Rules/BaseRateLimitRule.cs +++ b/RateLimiter/Rules/BaseRateLimitRule.cs @@ -1,20 +1,16 @@ -using Microsoft.Extensions.Logging; -using RateLimiter.Models; -using System; -using System.Threading; +using System.Threading; using System.Threading.Tasks; +using RateLimiter.Models; namespace RateLimiter.Rules { public abstract class BaseRateLimitRule : IRateLimitRule { private readonly SemaphoreSlim _semaphoreSlim; - private readonly ILogger _logger; - public BaseRateLimitRule(int numberOfRequests, ILogger logger) + public BaseRateLimitRule(int numberOfRequests) { _semaphoreSlim = new SemaphoreSlim(numberOfRequests); - _logger = logger; } public async Task IsWithinLimitAsync(RequestModel request) @@ -24,16 +20,10 @@ public async Task IsWithinLimitAsync(RequestModel request) { return await ProcessRuleAsync(request); } - catch (Exception e) - { - _logger.LogError(e, e.Message); - } finally { _semaphoreSlim.Release(); } - - return true; } protected abstract Task ProcessRuleAsync(RequestModel request); diff --git a/RateLimiter/Rules/RateLimitRuleTypes.cs b/RateLimiter/Rules/RateLimitRuleTypes.cs new file mode 100644 index 00000000..125f265b --- /dev/null +++ b/RateLimiter/Rules/RateLimitRuleTypes.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Rules +{ + public enum RateLimitRuleTypes + { + RequestsPerTimeSpan, + RequestsPerUserPerTimeSpan + } +} diff --git a/RateLimiter/Rules/RequestsPerTimeSpanRule.cs b/RateLimiter/Rules/RequestsPerTimeSpanRule.cs index b4600c14..bdccf336 100644 --- a/RateLimiter/Rules/RequestsPerTimeSpanRule.cs +++ b/RateLimiter/Rules/RequestsPerTimeSpanRule.cs @@ -1,23 +1,21 @@ -using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; using RateLimiter.Models; using RateLimiter.Stores; -using System; -using System.Threading.Tasks; namespace RateLimiter.Rules { public class RequestsPerTimeSpanRule : BaseRateLimitRule { - private readonly IRateLimitDataStore _store; + private readonly IRateLimitDataStore _store; private readonly TimeSpan _interval; private readonly int _numberOfRequestsAllowed; public RequestsPerTimeSpanRule( int numberOfRequests, TimeSpan interval, - IRateLimitDataStore store, - ILogger logger) - : base(numberOfRequests, logger) + IRateLimitDataStore store) + : base(numberOfRequests) { _interval = interval; _numberOfRequestsAllowed = numberOfRequests; diff --git a/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs b/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs index 0ba7db60..bfc0c0b7 100644 --- a/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs +++ b/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs @@ -1,23 +1,21 @@ -using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; using RateLimiter.Models; using RateLimiter.Stores; -using System; -using System.Threading.Tasks; namespace RateLimiter.Rules { public class RequestsPerUserPerTimeSpanRule : BaseRateLimitRule { - private readonly IRateLimitDataStore _store; + private readonly IRateLimitDataStore _store; private readonly TimeSpan _interval; private readonly int _numberOfRequests; public RequestsPerUserPerTimeSpanRule( int numberOfRequests, TimeSpan interval, - IRateLimitDataStore store, - ILogger logger) - : base(numberOfRequests, logger) + IRateLimitDataStore store) + : base(numberOfRequests) { _interval = interval; _numberOfRequests = numberOfRequests; @@ -39,7 +37,6 @@ protected override Task ProcessRuleAsync(RequestModel request) if (IsWithinTimeInterval(currentRequestTime, limitCounterModel.RequestTime)) { limitCounterModel = new RateLimitCounterModel(limitCounterModel.RequestCount + 1, limitCounterModel.RequestTime); - } _store.Update(request.UserId, limitCounterModel); diff --git a/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs b/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs index a27dc3fa..32911360 100644 --- a/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs +++ b/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs @@ -4,13 +4,13 @@ namespace RateLimiter.Stores { - public class ConcurrentInMemoryRateLimitDataStore : IRateLimitDataStore + public class ConcurrentInMemoryRateLimitDataStore : IRateLimitDataStore { private readonly ConcurrentDictionary _store; - public ConcurrentInMemoryRateLimitDataStore(ConcurrentDictionary store) + public ConcurrentInMemoryRateLimitDataStore() { - _store = store; + _store = new ConcurrentDictionary(); } public RateLimitCounterModel? Get(string key) diff --git a/RateLimiter/Stores/IRateLimitDataStore.cs b/RateLimiter/Stores/IRateLimitDataStore.cs index aec0f7fa..feee7ae3 100644 --- a/RateLimiter/Stores/IRateLimitDataStore.cs +++ b/RateLimiter/Stores/IRateLimitDataStore.cs @@ -2,10 +2,10 @@ namespace RateLimiter.Stores { - public interface IRateLimitDataStore where T : class + public interface IRateLimitDataStore { - T? Get(string key); + RateLimitCounterModel? Get(string key); void Add(string key, RateLimitCounterModel value); - void Update(string key, T value); + void Update(string key, RateLimitCounterModel value); } } diff --git a/RateLimiter/Stores/RateLimitDataStoreTypes.cs b/RateLimiter/Stores/RateLimitDataStoreTypes.cs new file mode 100644 index 00000000..3179e405 --- /dev/null +++ b/RateLimiter/Stores/RateLimitDataStoreTypes.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Stores +{ + public enum RateLimitDataStoreTypes + { + ConcurrentInMemory + } +} From a9fa89b78766daf60c4c49674274fcb420d3781c Mon Sep 17 00:00:00 2001 From: Sargis Panosyan Date: Mon, 24 Feb 2025 11:45:16 -0800 Subject: [PATCH 4/8] Added DataStoreKeyGenerator and exceptions The DataStoreKeyGenerator utilizes the strategy pattern to generate different types of data store keys that a rate limit rule can use to store the rate limit counting data. Doing so allows the RequestPerTimeSpan rule logic to be reused with different types of keys, e.g. RequestsPerResource, RequestsPerUser, etc... In this way, new types of RequestPerTimeSpan rules can be created by simply added a new data store key type. --- .../Rules/RequestPerTimeSpanRuleTest.cs | 12 ++-- RateLimiter/Constants/DataStoreKeyTypes.cs | 16 ++++++ .../RateLimitDataStoreTypes.cs | 2 +- RateLimiter/Constants/RateLimitRuleTypes.cs | 7 +++ ...DataStoreKeyTypeNotImplementedException.cs | 11 ++++ .../DataStoreTypeNotImplementedException.cs | 11 ++++ .../RuleTypeNotImplementedException.cs | 11 ++++ .../Factories/IRateLimitDataStoreFactory.cs | 3 +- .../Factories/IRateLimitRuleFactory.cs | 9 ++- .../Factories/RateLimitDataStoreFactory.cs | 9 +-- RateLimiter/Factories/RateLimitRuleFactory.cs | 20 ++++--- RateLimiter/Rules/RateLimitRuleTypes.cs | 8 --- RateLimiter/Rules/RequestsPerTimeSpanRule.cs | 12 ++-- .../Rules/RequestsPerUserPerTimeSpanRule.cs | 57 ------------------- RateLimiter/Stores/DataStoreKeyGenerator.cs | 43 ++++++++++++++ RateLimiter/Stores/IDataStoreKeyGenerator.cs | 10 ++++ 16 files changed, 149 insertions(+), 92 deletions(-) create mode 100644 RateLimiter/Constants/DataStoreKeyTypes.cs rename RateLimiter/{Stores => Constants}/RateLimitDataStoreTypes.cs (70%) create mode 100644 RateLimiter/Constants/RateLimitRuleTypes.cs create mode 100644 RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs create mode 100644 RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs create mode 100644 RateLimiter/Exceptions/RuleTypeNotImplementedException.cs delete mode 100644 RateLimiter/Rules/RateLimitRuleTypes.cs delete mode 100644 RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs create mode 100644 RateLimiter/Stores/DataStoreKeyGenerator.cs create mode 100644 RateLimiter/Stores/IDataStoreKeyGenerator.cs diff --git a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs index ab6ee68a..550540c4 100644 --- a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs +++ b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs @@ -1,10 +1,9 @@ using System; using System.Threading.Tasks; using NUnit.Framework; +using RateLimiter.Constants; using RateLimiter.Factories; using RateLimiter.Models; -using RateLimiter.Rules; -using RateLimiter.Stores; namespace RateLimiter.Tests.Rules { @@ -21,9 +20,10 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsTrue_When_LimitIsN var interval = new TimeSpan(hours: 0, minutes: 0, seconds: 5); var dataStoreFactory = new RateLimitDataStoreFactory(); var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); - var rateLimitRule = ruleFactory.CreateRateLimitRule( + var rateLimitRule = ruleFactory.CreateRule( RateLimitRuleTypes.RequestsPerTimeSpan, RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, numRequestsAllowed, interval); @@ -44,9 +44,10 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsFalse_When_LimitIs var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 1); var dataStoreFactory = new RateLimitDataStoreFactory(); var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); - var rateLimitRule = ruleFactory.CreateRateLimitRule( + var rateLimitRule = ruleFactory.CreateRule( RateLimitRuleTypes.RequestsPerTimeSpan, RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, numRequestsAllowed, interval); @@ -71,9 +72,10 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_MultipleRequests() var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); var dataStoreFactory = new RateLimitDataStoreFactory(); var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); - var rateLimitRule = ruleFactory.CreateRateLimitRule( + var rateLimitRule = ruleFactory.CreateRule( RateLimitRuleTypes.RequestsPerTimeSpan, RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, numRequestsAllowed, interval); diff --git a/RateLimiter/Constants/DataStoreKeyTypes.cs b/RateLimiter/Constants/DataStoreKeyTypes.cs new file mode 100644 index 00000000..7cd34b75 --- /dev/null +++ b/RateLimiter/Constants/DataStoreKeyTypes.cs @@ -0,0 +1,16 @@ +namespace RateLimiter.Constants +{ + public enum DataStoreKeyTypes + { + RequestsPerResource, + RequestsPerUser, + RequestsPerOrganization, + RequestsPerIpAddress, + RequestsPerRegion, + RequestsPerUserPerResource, + RequestsPerOrganizationUserPerResource, + RequestsPerOrganizationPerResource, + RequestsPerIpAddressPerResource, + RequestsPerRegionPerResource + } +} \ No newline at end of file diff --git a/RateLimiter/Stores/RateLimitDataStoreTypes.cs b/RateLimiter/Constants/RateLimitDataStoreTypes.cs similarity index 70% rename from RateLimiter/Stores/RateLimitDataStoreTypes.cs rename to RateLimiter/Constants/RateLimitDataStoreTypes.cs index 3179e405..e7211a11 100644 --- a/RateLimiter/Stores/RateLimitDataStoreTypes.cs +++ b/RateLimiter/Constants/RateLimitDataStoreTypes.cs @@ -1,4 +1,4 @@ -namespace RateLimiter.Stores +namespace RateLimiter.Constants { public enum RateLimitDataStoreTypes { diff --git a/RateLimiter/Constants/RateLimitRuleTypes.cs b/RateLimiter/Constants/RateLimitRuleTypes.cs new file mode 100644 index 00000000..0ef364d3 --- /dev/null +++ b/RateLimiter/Constants/RateLimitRuleTypes.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Constants +{ + public enum RateLimitRuleTypes + { + RequestsPerTimeSpan + } +} diff --git a/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs b/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs new file mode 100644 index 00000000..c93c21fa --- /dev/null +++ b/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs @@ -0,0 +1,11 @@ +using System; + +namespace RateLimiter.Exceptions +{ + public class DataStoreKeyTypeNotImplementedException : Exception + { + private const string _message = "The requested data store key type has not been implemented"; + + public DataStoreKeyTypeNotImplementedException() : base(_message) { } + } +} diff --git a/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs b/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs new file mode 100644 index 00000000..20fbf080 --- /dev/null +++ b/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs @@ -0,0 +1,11 @@ +using System; + +namespace RateLimiter.Exceptions +{ + public class DataStoreTypeNotImplementedException : Exception + { + private const string _message = "The requested data store type has not been implemented"; + + public DataStoreTypeNotImplementedException() : base(_message) { } + } +} diff --git a/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs b/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs new file mode 100644 index 00000000..16e136fb --- /dev/null +++ b/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs @@ -0,0 +1,11 @@ +using System; + +namespace RateLimiter.Exceptions +{ + public class RuleTypeNotImplementedException : Exception + { + private const string _message = "The requested rule type has not been implemented"; + + public RuleTypeNotImplementedException() : base(_message) { } + } +} diff --git a/RateLimiter/Factories/IRateLimitDataStoreFactory.cs b/RateLimiter/Factories/IRateLimitDataStoreFactory.cs index 87e0acf1..6b7f8720 100644 --- a/RateLimiter/Factories/IRateLimitDataStoreFactory.cs +++ b/RateLimiter/Factories/IRateLimitDataStoreFactory.cs @@ -1,4 +1,5 @@ -using RateLimiter.Stores; +using RateLimiter.Constants; +using RateLimiter.Stores; namespace RateLimiter.Factories { diff --git a/RateLimiter/Factories/IRateLimitRuleFactory.cs b/RateLimiter/Factories/IRateLimitRuleFactory.cs index 629b2318..7904f0ae 100644 --- a/RateLimiter/Factories/IRateLimitRuleFactory.cs +++ b/RateLimiter/Factories/IRateLimitRuleFactory.cs @@ -1,11 +1,16 @@ using System; +using RateLimiter.Constants; using RateLimiter.Rules; -using RateLimiter.Stores; namespace RateLimiter.Factories { public interface IRateLimitRuleFactory { - IRateLimitRule CreateRateLimitRule(RateLimitRuleTypes ruleType, RateLimitDataStoreTypes dataStoreType, int numberOfRequests, TimeSpan interval); + IRateLimitRule CreateRule( + RateLimitRuleTypes ruleType, + RateLimitDataStoreTypes dataStoreType, + DataStoreKeyTypes dataStoreKeyType, + int numberOfRequests, + TimeSpan interval); } } diff --git a/RateLimiter/Factories/RateLimitDataStoreFactory.cs b/RateLimiter/Factories/RateLimitDataStoreFactory.cs index a0da437c..2dcc056b 100644 --- a/RateLimiter/Factories/RateLimitDataStoreFactory.cs +++ b/RateLimiter/Factories/RateLimitDataStoreFactory.cs @@ -1,13 +1,11 @@ -using RateLimiter.Models; +using RateLimiter.Constants; +using RateLimiter.Exceptions; using RateLimiter.Stores; -using System; namespace RateLimiter.Factories { public class RateLimitDataStoreFactory : IRateLimitDataStoreFactory { - private static readonly string UnknownDataStoreError = "Unknown RateLimitDataStoreType: {0}"; - public IRateLimitDataStore CreateDataStore(RateLimitDataStoreTypes dataStoreType) { switch (dataStoreType) @@ -15,8 +13,7 @@ public IRateLimitDataStore CreateDataStore(RateLimitDataStoreTypes dataStoreType case RateLimitDataStoreTypes.ConcurrentInMemory: return new ConcurrentInMemoryRateLimitDataStore(); default: - var errorMessage = string.Format(UnknownDataStoreError, dataStoreType.ToString()); - throw new NotImplementedException(errorMessage); + throw new DataStoreTypeNotImplementedException(); } } } diff --git a/RateLimiter/Factories/RateLimitRuleFactory.cs b/RateLimiter/Factories/RateLimitRuleFactory.cs index 561612bc..0bfe8069 100644 --- a/RateLimiter/Factories/RateLimitRuleFactory.cs +++ b/RateLimiter/Factories/RateLimitRuleFactory.cs @@ -1,4 +1,6 @@ using System; +using RateLimiter.Constants; +using RateLimiter.Exceptions; using RateLimiter.Rules; using RateLimiter.Stores; @@ -6,7 +8,6 @@ namespace RateLimiter.Factories { public class RateLimitRuleFactory : IRateLimitRuleFactory { - private static readonly string UnknownRuleError = "Unknown RateLimitRuleType: {0}"; private readonly IRateLimitDataStoreFactory _rateLimitDataStoreFactory; public RateLimitRuleFactory(IRateLimitDataStoreFactory rateLimitDataStoreFactory) @@ -14,19 +15,22 @@ public RateLimitRuleFactory(IRateLimitDataStoreFactory rateLimitDataStoreFactory _rateLimitDataStoreFactory = rateLimitDataStoreFactory; } - public IRateLimitRule CreateRateLimitRule(RateLimitRuleTypes ruleType, RateLimitDataStoreTypes dataStoreTypeint, int numberOfRequests, TimeSpan interval) + public IRateLimitRule CreateRule( + RateLimitRuleTypes ruleType, + RateLimitDataStoreTypes dataStoreType, + DataStoreKeyTypes dataStoreKeyType, + int numberOfRequests, + TimeSpan interval) { - var dataStore = _rateLimitDataStoreFactory.CreateDataStore(dataStoreTypeint); + var dataStore = _rateLimitDataStoreFactory.CreateDataStore(dataStoreType); + var keyGenerator = new DataStoreKeyGenerator(dataStoreKeyType); switch (ruleType) { case RateLimitRuleTypes.RequestsPerTimeSpan: - return new RequestsPerTimeSpanRule(numberOfRequests, interval, dataStore); - case RateLimitRuleTypes.RequestsPerUserPerTimeSpan: - return new RequestsPerUserPerTimeSpanRule(numberOfRequests, interval, dataStore); + return new RequestsPerTimeSpanRule(numberOfRequests, interval, dataStore, keyGenerator); default: - var errorMessage = string.Format(UnknownRuleError, ruleType.ToString()); - throw new NotImplementedException(errorMessage); + throw new RuleTypeNotImplementedException(); } } } diff --git a/RateLimiter/Rules/RateLimitRuleTypes.cs b/RateLimiter/Rules/RateLimitRuleTypes.cs deleted file mode 100644 index 125f265b..00000000 --- a/RateLimiter/Rules/RateLimitRuleTypes.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace RateLimiter.Rules -{ - public enum RateLimitRuleTypes - { - RequestsPerTimeSpan, - RequestsPerUserPerTimeSpan - } -} diff --git a/RateLimiter/Rules/RequestsPerTimeSpanRule.cs b/RateLimiter/Rules/RequestsPerTimeSpanRule.cs index bdccf336..cf7bf56a 100644 --- a/RateLimiter/Rules/RequestsPerTimeSpanRule.cs +++ b/RateLimiter/Rules/RequestsPerTimeSpanRule.cs @@ -7,6 +7,7 @@ namespace RateLimiter.Rules { public class RequestsPerTimeSpanRule : BaseRateLimitRule { + private readonly IDataStoreKeyGenerator _keyGenerator; private readonly IRateLimitDataStore _store; private readonly TimeSpan _interval; private readonly int _numberOfRequestsAllowed; @@ -14,23 +15,26 @@ public class RequestsPerTimeSpanRule : BaseRateLimitRule public RequestsPerTimeSpanRule( int numberOfRequests, TimeSpan interval, - IRateLimitDataStore store) + IRateLimitDataStore store, + IDataStoreKeyGenerator keyGenerator) : base(numberOfRequests) { _interval = interval; _numberOfRequestsAllowed = numberOfRequests; _store = store; + _keyGenerator = keyGenerator; } protected override Task ProcessRuleAsync(RequestModel request) { + var rateLimitDataKey = _keyGenerator.GenerateKey(request); var currentRequestTime = DateTime.UtcNow.Ticks; - var limitCounterModel = _store.Get(request.RequestPath); + var limitCounterModel = _store.Get(rateLimitDataKey); if (limitCounterModel == null) { limitCounterModel = new RateLimitCounterModel(0, currentRequestTime); - _store.Add(request.RequestPath, limitCounterModel); + _store.Add(rateLimitDataKey, limitCounterModel); } // If the current request is within the timespan, @@ -44,7 +48,7 @@ protected override Task ProcessRuleAsync(RequestModel request) limitCounterModel.RequestTime = currentRequestTime; } - _store.Update(request.RequestPath, limitCounterModel); + _store.Update(rateLimitDataKey, limitCounterModel); if (limitCounterModel.RequestCount > _numberOfRequestsAllowed) { diff --git a/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs b/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs deleted file mode 100644 index bfc0c0b7..00000000 --- a/RateLimiter/Rules/RequestsPerUserPerTimeSpanRule.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Threading.Tasks; -using RateLimiter.Models; -using RateLimiter.Stores; - -namespace RateLimiter.Rules -{ - public class RequestsPerUserPerTimeSpanRule : BaseRateLimitRule - { - private readonly IRateLimitDataStore _store; - private readonly TimeSpan _interval; - private readonly int _numberOfRequests; - - public RequestsPerUserPerTimeSpanRule( - int numberOfRequests, - TimeSpan interval, - IRateLimitDataStore store) - : base(numberOfRequests) - { - _interval = interval; - _numberOfRequests = numberOfRequests; - _store = store; - } - - protected override Task ProcessRuleAsync(RequestModel request) - { - var currentRequestTime = DateTime.UtcNow.Ticks; - var limitCounterModel = _store.Get(request.RequestPath); - - if (limitCounterModel == null) - { - limitCounterModel = new RateLimitCounterModel(0, currentRequestTime); - _store.Add(request.RequestPath, limitCounterModel); - } - - // If the current request is within the timespan, - if (IsWithinTimeInterval(currentRequestTime, limitCounterModel.RequestTime)) - { - limitCounterModel = new RateLimitCounterModel(limitCounterModel.RequestCount + 1, limitCounterModel.RequestTime); - } - - _store.Update(request.UserId, limitCounterModel); - - if (limitCounterModel.RequestCount > _numberOfRequests) - { - return Task.FromResult(false); - } - - return Task.FromResult(true); - } - - private bool IsWithinTimeInterval(long currentRequestTime, long initialRequestTime) - { - return currentRequestTime - initialRequestTime <= _interval.Ticks; - } - } -} diff --git a/RateLimiter/Stores/DataStoreKeyGenerator.cs b/RateLimiter/Stores/DataStoreKeyGenerator.cs new file mode 100644 index 00000000..62cf63db --- /dev/null +++ b/RateLimiter/Stores/DataStoreKeyGenerator.cs @@ -0,0 +1,43 @@ +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Models; + +namespace RateLimiter.Stores +{ + public class DataStoreKeyGenerator : IDataStoreKeyGenerator + { + private readonly DataStoreKeyTypes _ruleType; + + public DataStoreKeyGenerator(DataStoreKeyTypes ruleType) + { + _ruleType = ruleType; + } + + public string GenerateKey(RequestModel request) + { + switch (_ruleType) + { + case DataStoreKeyTypes.RequestsPerResource: + return $"{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerUser: + return $"{request.UserId}"; + case DataStoreKeyTypes.RequestsPerIpAddress: + return $"{request.IpAddress}"; + case DataStoreKeyTypes.RequestsPerOrganization: + return $"{request.OrganizationId}"; + case DataStoreKeyTypes.RequestsPerUserPerResource: + return $"{request.UserId}:{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerOrganizationPerResource: + return $"{request.OrganizationId}:{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerRegionPerResource: + return $"{request.Region}:{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerIpAddressPerResource: + return $"{request.IpAddress}:{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerOrganizationUserPerResource: + return $"{request.UserId}:{request.OrganizationId}:{request.RequestPath}"; + default: + throw new DataStoreKeyTypeNotImplementedException(); + } + } + } +} diff --git a/RateLimiter/Stores/IDataStoreKeyGenerator.cs b/RateLimiter/Stores/IDataStoreKeyGenerator.cs new file mode 100644 index 00000000..ea2ebe38 --- /dev/null +++ b/RateLimiter/Stores/IDataStoreKeyGenerator.cs @@ -0,0 +1,10 @@ +using RateLimiter.Constants; +using RateLimiter.Models; + +namespace RateLimiter.Stores +{ + public interface IDataStoreKeyGenerator + { + string GenerateKey(RequestModel request); + } +} From 8bda55dc924f921040aa1fa8515bd8896a2b3256 Mon Sep 17 00:00:00 2001 From: Sargis Panosyan Date: Mon, 24 Feb 2025 11:59:58 -0800 Subject: [PATCH 5/8] Add allowRequestsOnFailure configuration parameter to RateLimiter Added the allowRequestsOnFailure parameter to the rate limiter In order to allow the user of this library to configure whether the rate limiter will allow or deny requests when an exception occurs. This configuration parameter will give us the flexibility to choose whether we allow or block requests in failure scenarios. Although there is currently only one implementation of each data store that stores the data in memory, we may eventually want to utilize a Redis distributed cache. This is one such case where failures outside of our system may cause the rate limiter to fail. --- RateLimiter.Tests/RateLimiterTest.cs | 9 ++++++--- RateLimiter/RateLimiter.cs | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 7b83654b..a93c83c5 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -16,12 +16,13 @@ public class RateLimiterTest public async Task RateLimiter_AllowsRequest_When_NoRulesAreSet() { // Arrange + var allowRequestsOnFailure = true; var resourceId = "/api/resource"; var userId = "user1"; var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); var rulesetStoreMock = new Mock(); var loggerMock = new Mock>(); - var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object, allowRequestsOnFailure); // Act var allowed = await rateLimiter.IsRequestAllowedAsync(request); @@ -34,6 +35,7 @@ public async Task RateLimiter_AllowsRequest_When_NoRulesAreSet() public async Task RateLimiter_AllowsRequest_When_WithinConfiguredRuleLimits() { // Arrange + var allowRequestsOnFailure = true; var resourceId = "/api/resource"; var userId = "user1"; var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); @@ -44,7 +46,7 @@ public async Task RateLimiter_AllowsRequest_When_WithinConfiguredRuleLimits() }; var rulesetStoreMock = new Mock(); var loggerMock = new Mock>(); - var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object, allowRequestsOnFailure); testRule.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(true); rulesetStoreMock.Setup(store => store.GetRules(resourceId)).Returns(ruleList); @@ -60,6 +62,7 @@ public async Task RateLimiter_AllowsRequest_When_WithinConfiguredRuleLimits() public async Task RateLimiter_DeniesRequest_When_ConfiguredRulesFail() { // Arrange + var allowRequestsOnFailure = true; var requestPath = "/api/resource"; var userId = "user1"; var request = new RequestModel(requestPath, userId, string.Empty, string.Empty, string.Empty); @@ -72,7 +75,7 @@ public async Task RateLimiter_DeniesRequest_When_ConfiguredRulesFail() }; var rulesetStoreMock = new Mock(); var loggerMock = new Mock>(); - var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object, allowRequestsOnFailure); testRule1.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(true); testRule2.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(false); diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index b3372f3f..4571b0ea 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -12,11 +12,13 @@ public class RateLimiter : IRateLimiter { private readonly IRulesetStore _rulesetStore; private readonly ILogger _logger; + private readonly bool _allowRequestsOnFailure; - public RateLimiter(IRulesetStore rulesetStore, ILogger logger) + public RateLimiter(IRulesetStore rulesetStore, ILogger logger, bool allowRequestsOnFailure) { _rulesetStore = rulesetStore; _logger = logger; + _allowRequestsOnFailure = allowRequestsOnFailure; } public async Task IsRequestAllowedAsync(RequestModel request) @@ -44,7 +46,7 @@ public async Task IsRequestAllowedAsync(RequestModel request) _logger.LogError(e, e.Message); } - return true; + return _allowRequestsOnFailure; } public void RegisterRule(string resourceId, IRateLimitRule rule) From 63e60d675c3778fc1e2cda18f950528dc19faa2e Mon Sep 17 00:00:00 2001 From: Sargis Panosyan Date: Mon, 24 Feb 2025 20:19:05 -0800 Subject: [PATCH 6/8] Add tests for factories and store key generator Added tests for factories and store key generator. Added code to append the enum to the NotImplementedException error messsages. Reorganized using statements. --- .../RateLimitDataStoreFactoryTest.cs | 38 +++++++++++++ .../Factories/RateLimitRuleFactoryTest.cs | 54 ++++++++++++++++++ .../Stores/DataStoreKeyGeneratorTest.cs | 55 +++++++++++++++++++ ...DataStoreKeyTypeNotImplementedException.cs | 5 ++ .../DataStoreTypeNotImplementedException.cs | 5 ++ .../RuleTypeNotImplementedException.cs | 7 ++- .../Factories/RateLimitDataStoreFactory.cs | 2 +- RateLimiter/Factories/RateLimitRuleFactory.cs | 2 +- RateLimiter/Stores/DataStoreKeyGenerator.cs | 10 ++-- RateLimiter/Stores/IDataStoreKeyGenerator.cs | 3 +- 10 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 RateLimiter.Tests/Factories/RateLimitDataStoreFactoryTest.cs create mode 100644 RateLimiter.Tests/Factories/RateLimitRuleFactoryTest.cs create mode 100644 RateLimiter.Tests/Stores/DataStoreKeyGeneratorTest.cs diff --git a/RateLimiter.Tests/Factories/RateLimitDataStoreFactoryTest.cs b/RateLimiter.Tests/Factories/RateLimitDataStoreFactoryTest.cs new file mode 100644 index 00000000..29cbcd00 --- /dev/null +++ b/RateLimiter.Tests/Factories/RateLimitDataStoreFactoryTest.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Factories; + +namespace RateLimiter.Tests.Factories +{ + public class RateLimitDataStoreFactoryTest + { + [Test] + public void CreateDataStore_Returns_DataStore_When_DataStoreTypeIsKnown() + { + // Arrange + RateLimitDataStoreTypes dataStoreType = RateLimitDataStoreTypes.ConcurrentInMemory; + var dataStoreFactory = new RateLimitDataStoreFactory(); + + // Act + var dataStore = dataStoreFactory.CreateDataStore(dataStoreType); + + // Assert + Assert.NotNull(dataStore); + } + + [Test] + public void CreateDataStore_Throws_NotImplementedException_ForUnknownDataStoreType() + { + // Arrange + RateLimitDataStoreTypes unimplementedDataStoreType = (RateLimitDataStoreTypes)10000; + var dataStoreFactory = new RateLimitDataStoreFactory(); + + // Act + var datastoreDelegate = () => dataStoreFactory.CreateDataStore(unimplementedDataStoreType); + + // Assert + Assert.Throws(() => datastoreDelegate()); + } + } +} diff --git a/RateLimiter.Tests/Factories/RateLimitRuleFactoryTest.cs b/RateLimiter.Tests/Factories/RateLimitRuleFactoryTest.cs new file mode 100644 index 00000000..12f9f05b --- /dev/null +++ b/RateLimiter.Tests/Factories/RateLimitRuleFactoryTest.cs @@ -0,0 +1,54 @@ +using System; +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Factories; + +namespace RateLimiter.Tests.Factories +{ + public class RateLimitRuleFactoryTest + { + [Test] + public void CreateRule_Returns_ExpectedRule() + { + // Arrange + int numRequestsAllowed = 1; + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + + // Act + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.RequestsPerTimeSpan, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Assert + Assert.NotNull(rateLimitRule); + } + + [Test] + public void CreateRule_Throws_NotImplementedException() + { + // Arrange + RateLimitRuleTypes unimplementedRuleType = (RateLimitRuleTypes)10000; + int numRequestsAllowed = 1; + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + + // Act + var rateLimitRule = () => ruleFactory.CreateRule( + unimplementedRuleType, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Assert + Assert.Throws(() => rateLimitRule()); + } + } +} diff --git a/RateLimiter.Tests/Stores/DataStoreKeyGeneratorTest.cs b/RateLimiter.Tests/Stores/DataStoreKeyGeneratorTest.cs new file mode 100644 index 00000000..cc7b7f41 --- /dev/null +++ b/RateLimiter.Tests/Stores/DataStoreKeyGeneratorTest.cs @@ -0,0 +1,55 @@ +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Models; +using RateLimiter.Stores; + +namespace RateLimiter.Tests.Stores +{ + public class DataStoreKeyGeneratorTest + { + [Test] + public void GenerateKey_Returns_ExpectedKey_When_KeyTypeIsKnown() + { + // Arrange + var ipAddress = "67.88.121.44"; + var organizationId = "Simple Software Solutions Inc (SSSI)"; + var userId = "100"; + var region = "us-west"; + var requestPath = "api/profiles"; + var request = new RequestModel(requestPath, userId, organizationId, ipAddress, region); + DataStoreKeyTypes dataStoreKeyType = DataStoreKeyTypes.RequestsPerOrganizationUserPerResource; + var dataStoreKeyGenerator = new DataStoreKeyGenerator(dataStoreKeyType); + var expectedDataStoreKey = $"{request.UserId}:{request.OrganizationId}:{request.RequestPath}"; + + // Act + var actualDataStoreKey = dataStoreKeyGenerator.GenerateKey(request); + + // Assert + Assert.AreEqual(expectedDataStoreKey, actualDataStoreKey); + } + + [Test] + public void CreateDataStore_Throws_NotImplementedException_ForUnknownDataStoreType() + { + // Arrange + // Arrange + var ipAddress = "67.88.121.44"; + var organizationId = "Simple Software Solutions Inc (SSSI)"; + var userId = "100"; + var region = "us-west"; + var requestPath = "api/profiles"; + var request = new RequestModel(requestPath, userId, organizationId, ipAddress, region); + var expectedDataStoreKey = $"{request.UserId}:{request.OrganizationId}:{request.RequestPath}"; + + DataStoreKeyTypes unknownDataStoreKeyType = (DataStoreKeyTypes)10000; + var dataStoreKeyGenerator = new DataStoreKeyGenerator(unknownDataStoreKeyType); + + // Act + var dataStoreKeyGeneratorDelegate = () => dataStoreKeyGenerator.GenerateKey(request); + + // Assert + Assert.Throws(() => dataStoreKeyGeneratorDelegate()); + } + } +} diff --git a/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs b/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs index c93c21fa..1828efd7 100644 --- a/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs +++ b/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs @@ -1,4 +1,5 @@ using System; +using RateLimiter.Constants; namespace RateLimiter.Exceptions { @@ -7,5 +8,9 @@ public class DataStoreKeyTypeNotImplementedException : Exception private const string _message = "The requested data store key type has not been implemented"; public DataStoreKeyTypeNotImplementedException() : base(_message) { } + + public DataStoreKeyTypeNotImplementedException(DataStoreKeyTypes dataStoreKeyType) + : base($"{_message}: {dataStoreKeyType.ToString()}") + { } } } diff --git a/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs b/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs index 20fbf080..2161b870 100644 --- a/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs +++ b/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs @@ -1,4 +1,5 @@ using System; +using RateLimiter.Constants; namespace RateLimiter.Exceptions { @@ -7,5 +8,9 @@ public class DataStoreTypeNotImplementedException : Exception private const string _message = "The requested data store type has not been implemented"; public DataStoreTypeNotImplementedException() : base(_message) { } + + public DataStoreTypeNotImplementedException(RateLimitDataStoreTypes dataStoreType) + : base($"{_message}: {dataStoreType.ToString()}") + { } } } diff --git a/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs b/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs index 16e136fb..760b1a37 100644 --- a/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs +++ b/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs @@ -1,11 +1,16 @@ using System; +using RateLimiter.Constants; namespace RateLimiter.Exceptions { public class RuleTypeNotImplementedException : Exception { - private const string _message = "The requested rule type has not been implemented"; + private const string _message = "The requested rate limit rule type has not been implemented"; public RuleTypeNotImplementedException() : base(_message) { } + + public RuleTypeNotImplementedException(RateLimitRuleTypes rateLimitRuleType) + : base($"{_message}: {rateLimitRuleType.ToString()}") + { } } } diff --git a/RateLimiter/Factories/RateLimitDataStoreFactory.cs b/RateLimiter/Factories/RateLimitDataStoreFactory.cs index 2dcc056b..2ab3d4e9 100644 --- a/RateLimiter/Factories/RateLimitDataStoreFactory.cs +++ b/RateLimiter/Factories/RateLimitDataStoreFactory.cs @@ -13,7 +13,7 @@ public IRateLimitDataStore CreateDataStore(RateLimitDataStoreTypes dataStoreType case RateLimitDataStoreTypes.ConcurrentInMemory: return new ConcurrentInMemoryRateLimitDataStore(); default: - throw new DataStoreTypeNotImplementedException(); + throw new DataStoreTypeNotImplementedException(dataStoreType); } } } diff --git a/RateLimiter/Factories/RateLimitRuleFactory.cs b/RateLimiter/Factories/RateLimitRuleFactory.cs index 0bfe8069..e294df3f 100644 --- a/RateLimiter/Factories/RateLimitRuleFactory.cs +++ b/RateLimiter/Factories/RateLimitRuleFactory.cs @@ -30,7 +30,7 @@ public IRateLimitRule CreateRule( case RateLimitRuleTypes.RequestsPerTimeSpan: return new RequestsPerTimeSpanRule(numberOfRequests, interval, dataStore, keyGenerator); default: - throw new RuleTypeNotImplementedException(); + throw new RuleTypeNotImplementedException(ruleType); } } } diff --git a/RateLimiter/Stores/DataStoreKeyGenerator.cs b/RateLimiter/Stores/DataStoreKeyGenerator.cs index 62cf63db..f0b72fb4 100644 --- a/RateLimiter/Stores/DataStoreKeyGenerator.cs +++ b/RateLimiter/Stores/DataStoreKeyGenerator.cs @@ -6,16 +6,16 @@ namespace RateLimiter.Stores { public class DataStoreKeyGenerator : IDataStoreKeyGenerator { - private readonly DataStoreKeyTypes _ruleType; + private readonly DataStoreKeyTypes _keyType; - public DataStoreKeyGenerator(DataStoreKeyTypes ruleType) + public DataStoreKeyGenerator(DataStoreKeyTypes keyType) { - _ruleType = ruleType; + _keyType = keyType; } public string GenerateKey(RequestModel request) { - switch (_ruleType) + switch (_keyType) { case DataStoreKeyTypes.RequestsPerResource: return $"{request.RequestPath}"; @@ -36,7 +36,7 @@ public string GenerateKey(RequestModel request) case DataStoreKeyTypes.RequestsPerOrganizationUserPerResource: return $"{request.UserId}:{request.OrganizationId}:{request.RequestPath}"; default: - throw new DataStoreKeyTypeNotImplementedException(); + throw new DataStoreKeyTypeNotImplementedException(_keyType); } } } diff --git a/RateLimiter/Stores/IDataStoreKeyGenerator.cs b/RateLimiter/Stores/IDataStoreKeyGenerator.cs index ea2ebe38..5c1f4063 100644 --- a/RateLimiter/Stores/IDataStoreKeyGenerator.cs +++ b/RateLimiter/Stores/IDataStoreKeyGenerator.cs @@ -1,5 +1,4 @@ -using RateLimiter.Constants; -using RateLimiter.Models; +using RateLimiter.Models; namespace RateLimiter.Stores { From 9e0c10105dddcb62d3a2c4d5b867d076ece692b3 Mon Sep 17 00:00:00 2001 From: Sargis Panosyan Date: Tue, 25 Feb 2025 10:04:29 -0800 Subject: [PATCH 7/8] Added new rate limit rule, TimeSpanSinceLastRequestRule, and tests --- RateLimiter.Tests/RateLimiterTest.cs | 6 +- .../Rules/RequestPerTimeSpanRuleTest.cs | 14 +-- .../Rules/TimeSpanSinceLastRequestRuleTest.cs | 95 +++++++++++++++++++ RateLimiter/Constants/RateLimitRuleTypes.cs | 3 +- RateLimiter/Factories/RateLimitRuleFactory.cs | 2 + RateLimiter/RateLimiter.cs | 2 +- RateLimiter/Rules/BaseRateLimitRule.cs | 10 +- RateLimiter/Rules/IRateLimitRule.cs | 2 +- .../Rules/TimeSpanSinceLastRequestRule.cs | 62 ++++++++++++ 9 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTest.cs create mode 100644 RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index a93c83c5..2844f116 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -48,7 +48,7 @@ public async Task RateLimiter_AllowsRequest_When_WithinConfiguredRuleLimits() var loggerMock = new Mock>(); var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object, allowRequestsOnFailure); - testRule.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(true); + testRule.Setup(testRule => testRule.IsRequestAllowedAsync(request)).ReturnsAsync(true); rulesetStoreMock.Setup(store => store.GetRules(resourceId)).Returns(ruleList); // Act @@ -77,8 +77,8 @@ public async Task RateLimiter_DeniesRequest_When_ConfiguredRulesFail() var loggerMock = new Mock>(); var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object, allowRequestsOnFailure); - testRule1.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(true); - testRule2.Setup(testRule => testRule.IsWithinLimitAsync(request)).ReturnsAsync(false); + testRule1.Setup(testRule => testRule.IsRequestAllowedAsync(request)).ReturnsAsync(true); + testRule2.Setup(testRule => testRule.IsRequestAllowedAsync(request)).ReturnsAsync(false); rulesetStoreMock.Setup(store => store.GetRules(requestPath)).Returns(ruleList); // Act diff --git a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs index 550540c4..705a79a2 100644 --- a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs +++ b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs @@ -28,7 +28,7 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsTrue_When_LimitIsN interval); // Act - var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); // Assert Assert.IsTrue(firstRequestAllowed); @@ -52,8 +52,8 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsFalse_When_LimitIs interval); // Act - var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); - var secondRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var secondRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); // Assert Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); @@ -80,10 +80,10 @@ public async Task RequestPerTimeSpanRule_IsWithinLimit_MultipleRequests() interval); // Act - var firstRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); - var secondRequestAllowed = await rateLimitRule.IsWithinLimitAsync(request); - var thirdRequestAllowed = async () => await rateLimitRule.IsWithinLimitAsync(request); - var fourthRequestAllowed = async () => await rateLimitRule.IsWithinLimitAsync(request); + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var secondRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var thirdRequestAllowed = async () => await rateLimitRule.IsRequestAllowedAsync(request); + var fourthRequestAllowed = async () => await rateLimitRule.IsRequestAllowedAsync(request); // Assert Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); diff --git a/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTest.cs b/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTest.cs new file mode 100644 index 00000000..0e365adb --- /dev/null +++ b/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTest.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Factories; +using RateLimiter.Models; + +namespace RateLimiter.Tests.Rules +{ + public class TimeSpanSinceLastRequestRuleTest + { + [Test] + public async Task IsRequestAllowedAsync_ReturnsTrue_When_LimitIsNotExceeded() + { + // Arrange + var resourceId = "/api/path"; + var userId = "user1"; + var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); + var numRequestsAllowed = 1; + var interval = new TimeSpan(hours: 0, minutes: 0, seconds: 5); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.TimeSpanSinceLastRequest, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed); + } + + [Test] + public async Task IsRequestAllowedAsync_ReturnsFalse_When_LimitIsExceeded() + { + // Arrange + var resourceId = "/api/path"; + int numRequestsAllowed = 1; + var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 1); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.TimeSpanSinceLastRequest, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var secondRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); + Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); + } + + + [Test] + public async Task IsRequestAllowedAsync_MultipleRequests() + { + // Arrange + var delayInMs = 11; + var resourceId = "/api/path"; + var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); + int numRequestsAllowed = 1; + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.TimeSpanSinceLastRequest, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var secondRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var thirdRequestAllowed = async () => await rateLimitRule.IsRequestAllowedAsync(request); + var fourthRequestAllowed = async () => await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); + Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); + Assert.That(() => thirdRequestAllowed(), Is.True.After(delayInMs), "Expected third request to be allowed"); + Assert.That(() => fourthRequestAllowed(), Is.False, "Expected fourth request to be denied"); + } + } +} diff --git a/RateLimiter/Constants/RateLimitRuleTypes.cs b/RateLimiter/Constants/RateLimitRuleTypes.cs index 0ef364d3..35652fbb 100644 --- a/RateLimiter/Constants/RateLimitRuleTypes.cs +++ b/RateLimiter/Constants/RateLimitRuleTypes.cs @@ -2,6 +2,7 @@ { public enum RateLimitRuleTypes { - RequestsPerTimeSpan + RequestsPerTimeSpan, + TimeSpanSinceLastRequest } } diff --git a/RateLimiter/Factories/RateLimitRuleFactory.cs b/RateLimiter/Factories/RateLimitRuleFactory.cs index e294df3f..6e7c1f02 100644 --- a/RateLimiter/Factories/RateLimitRuleFactory.cs +++ b/RateLimiter/Factories/RateLimitRuleFactory.cs @@ -29,6 +29,8 @@ public IRateLimitRule CreateRule( { case RateLimitRuleTypes.RequestsPerTimeSpan: return new RequestsPerTimeSpanRule(numberOfRequests, interval, dataStore, keyGenerator); + case RateLimitRuleTypes.TimeSpanSinceLastRequest: + return new TimeSpanSinceLastRequestRule(interval, dataStore, keyGenerator); default: throw new RuleTypeNotImplementedException(ruleType); } diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index 4571b0ea..de471209 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -33,7 +33,7 @@ public async Task IsRequestAllowedAsync(RequestModel request) foreach (var rule in applicableRules) { - if (!await rule.IsWithinLimitAsync(request)) + if (!await rule.IsRequestAllowedAsync(request)) { return false; } diff --git a/RateLimiter/Rules/BaseRateLimitRule.cs b/RateLimiter/Rules/BaseRateLimitRule.cs index 7419a25c..da5c1803 100644 --- a/RateLimiter/Rules/BaseRateLimitRule.cs +++ b/RateLimiter/Rules/BaseRateLimitRule.cs @@ -6,14 +6,20 @@ namespace RateLimiter.Rules { public abstract class BaseRateLimitRule : IRateLimitRule { + private const int MINIMUM_CONCURRENT_REQUESTS = 1; private readonly SemaphoreSlim _semaphoreSlim; + public BaseRateLimitRule() + { + _semaphoreSlim = new SemaphoreSlim(MINIMUM_CONCURRENT_REQUESTS, MINIMUM_CONCURRENT_REQUESTS); + } + public BaseRateLimitRule(int numberOfRequests) { - _semaphoreSlim = new SemaphoreSlim(numberOfRequests); + _semaphoreSlim = new SemaphoreSlim(numberOfRequests, numberOfRequests); } - public async Task IsWithinLimitAsync(RequestModel request) + public async Task IsRequestAllowedAsync(RequestModel request) { await _semaphoreSlim.WaitAsync(); try diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs index a79535d2..0848858a 100644 --- a/RateLimiter/Rules/IRateLimitRule.cs +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -5,6 +5,6 @@ namespace RateLimiter.Rules { public interface IRateLimitRule { - Task IsWithinLimitAsync(RequestModel request); + Task IsRequestAllowedAsync(RequestModel request); } } diff --git a/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs b/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs new file mode 100644 index 00000000..ce689700 --- /dev/null +++ b/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using RateLimiter.Models; +using RateLimiter.Stores; + +namespace RateLimiter.Rules +{ + public class TimeSpanSinceLastRequestRule : BaseRateLimitRule + { + private readonly IDataStoreKeyGenerator _keyGenerator; + private readonly IRateLimitDataStore _store; + private readonly TimeSpan _interval; + + public TimeSpanSinceLastRequestRule( + TimeSpan interval, + IRateLimitDataStore store, + IDataStoreKeyGenerator keyGenerator) + { + _interval = interval; + _store = store; + _keyGenerator = keyGenerator; + } + + protected override Task ProcessRuleAsync(RequestModel request) + { + var rateLimitDataKey = _keyGenerator.GenerateKey(request); + var currentRequestTime = DateTime.UtcNow.Ticks; + var limitCounterModel = _store.Get(rateLimitDataKey); + var allowRequest = true; + + if (limitCounterModel == null) + { + limitCounterModel = new RateLimitCounterModel(0, currentRequestTime); + _store.Add(rateLimitDataKey, limitCounterModel); + } + + // If the current request is within the timespan, + if (IsWithinTimeInterval(currentRequestTime, limitCounterModel.RequestTime)) + { + limitCounterModel.RequestCount++; + if (limitCounterModel.RequestCount > 1) + { + allowRequest = false; + } + } + else + { + limitCounterModel.RequestCount = 1; + limitCounterModel.RequestTime = currentRequestTime; + } + + _store.Update(rateLimitDataKey, limitCounterModel); + + return Task.FromResult(allowRequest); + } + + private bool IsWithinTimeInterval(long currentRequestTime, long initialRequestTime) + { + return currentRequestTime - initialRequestTime < _interval.Ticks; + } + } +} From 07ab2f0f64141841a317876f00c2587c9e91262c Mon Sep 17 00:00:00 2001 From: Sargis Panosyan Date: Tue, 25 Feb 2025 11:27:03 -0800 Subject: [PATCH 8/8] Add default constructor for ConcurrentInMemoryRulesetStore --- RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs b/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs index 12089fec..ff547703 100644 --- a/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs +++ b/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs @@ -8,6 +8,11 @@ public class ConcurrentInMemoryRulesetStore : IRulesetStore { private readonly ConcurrentDictionary> _rulesetStore; + public ConcurrentInMemoryRulesetStore() + { + _rulesetStore = new ConcurrentDictionary>(); + } + public ConcurrentInMemoryRulesetStore(ConcurrentDictionary> rulesetStore) { _rulesetStore = rulesetStore;