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..5c2a26fd 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,222 @@ -using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Microsoft.Extensions.Logging; +using RateLimiter.Rules; -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest +namespace RateLimiter.Tests { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } + [TestFixture] + public class FixedWindowRuleTests + { + [Test] + public void IsAllowed_WithinLimit_ReturnsTrue() + { + var rule = new FixedWindowRule(2, TimeSpan.FromMinutes(1)); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + + [Test] + public void IsAllowed_ExceedsLimit_ReturnsFalse() + { + var rule = new FixedWindowRule(2, TimeSpan.FromMinutes(1)); + rule.IsAllowed("client1", "res1"); + rule.IsAllowed("client1", "res1"); + Assert.That(rule.IsAllowed("client1", "res1"), Is.False); + } + + [Test] + public void IsAllowed_AfterWindowReset_AllowsAgain() + { + var rule = new FixedWindowRule(1, TimeSpan.FromMilliseconds(50)); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(100); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + + [Test] + public void Cleanup_RemovesExpiredEntries() + { + var rule = new FixedWindowRule(1, TimeSpan.FromMilliseconds(50)); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(100); + rule.Cleanup(); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + } + + [TestFixture] + public class SlidingWindowRuleTests + { + [Test] + public void IsAllowed_WithinLimit_ReturnsTrue() + { + var rule = new SlidingWindowRule(3, TimeSpan.FromMinutes(1)); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + + [Test] + public void IsAllowed_ExceedsLimit_ReturnsFalse() + { + var rule = new SlidingWindowRule(2, TimeSpan.FromMinutes(1)); + rule.IsAllowed("client1", "res1"); + rule.IsAllowed("client1", "res1"); + Assert.That(rule.IsAllowed("client1", "res1"), Is.False); + } + + [Test] + public void IsAllowed_AfterSlidingWindow_AllowsAgain() + { + var rule = new SlidingWindowRule(2, TimeSpan.FromMilliseconds(100)); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(50); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(60); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + + [Test] + public void Cleanup_RemovesOldRequests() + { + var rule = new SlidingWindowRule(2, TimeSpan.FromMilliseconds(50)); + rule.IsAllowed("client1", "res1"); + Thread.Sleep(100); + rule.Cleanup(); + Assert.That(rule.IsAllowed("client1", "res1"), Is.True); + } + } + + [TestFixture] + public class RegionalRuleTests + { + [Test] + public void IsAllowed_UsesCorrectRegionRule() + { + var usRule = new Mock(); + usRule.Setup(r => r.IsAllowed("US-123", "res1")).Returns(true); + + var euRule = new Mock(); + euRule.Setup(r => r.IsAllowed("EU-456", "res1")).Returns(true); + + var rule = new RegionalRule(new Dictionary + { + ["US"] = usRule.Object, + ["EU"] = euRule.Object + }); + + rule.IsAllowed("US-123", "res1"); + usRule.Verify(r => r.IsAllowed("US-123", "res1"), Times.Once); + + rule.IsAllowed("EU-456", "res1"); + euRule.Verify(r => r.IsAllowed("EU-456", "res1"), Times.Once); + } + + [Test] + public void IsAllowed_UnknownRegion_ReturnsFalse() + { + var rule = new RegionalRule(new Dictionary()); + Assert.That(rule.IsAllowed("ASIA-789", "res1"), Is.False); + } + } + + [TestFixture] + public class RateLimiterTests + { + private RateLimiter _rateLimiter; + private Mock> _loggerMock; + + [SetUp] + public void Setup() + { + _loggerMock = new Mock>(); + _rateLimiter = new RateLimiter(_loggerMock.Object); + } + + [Test] + public void ResourceWithSingleFixedWindowRule_ShouldAllowWithinLimit() + { + var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(10)); + _rateLimiter.AddRule("resourceA", rule); + + for (int i = 0; i < 3; i++) + { + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client1", "resourceA")); + } + Assert.IsFalse(_rateLimiter.IsRequestAllowed("client1", "resourceA")); + } + + [Test] + public void ResourceWithSingleSlidingWindowRule_ShouldAllowWithinLimit() + { + var rule = new SlidingWindowRule(2, TimeSpan.FromSeconds(5)); + _rateLimiter.AddRule("resourceB", rule); + + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client2", "resourceB")); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client2", "resourceB")); + Assert.IsFalse(_rateLimiter.IsRequestAllowed("client2", "resourceB")); + } + + [Test] + public void ResourceWithMultipleRules_ShouldDenyIfAnyRuleFails() + { + var fixedRule = new FixedWindowRule(2, TimeSpan.FromSeconds(10)); + var slidingRule = new SlidingWindowRule(3, TimeSpan.FromSeconds(5)); + + _rateLimiter.AddRule("resourceC", fixedRule); + _rateLimiter.AddRule("resourceC", slidingRule); + + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC")); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC")); + + Assert.IsFalse(_rateLimiter.IsRequestAllowed("client3", "resourceC")); + + Thread.Sleep(10000); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC")); + } + + [Test] + public void RegionalRule_ShouldApplyCorrectRegionalLimits() + { + var regionalRule = new RegionalRule(new Dictionary + { + { "US", new FixedWindowRule(1, TimeSpan.FromSeconds(10)) }, + { "ASIA", new SlidingWindowRule(2, TimeSpan.FromSeconds(5)) } + }); + + _rateLimiter.AddRule("resourceD", regionalRule); + + Assert.IsTrue(_rateLimiter.IsRequestAllowed("US-client", "resourceD")); + Assert.IsFalse(_rateLimiter.IsRequestAllowed("US-client", "resourceD")); + + Assert.IsTrue(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD")); + Assert.IsTrue(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD")); + Assert.IsFalse(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD")); + } + } + + [TestFixture] + public class ConcurrencyTests + { + [Test] + public async Task FixedWindowRule_WithConcurrentRequests_EnforcesLimit() + { + var rule = new FixedWindowRule(10, TimeSpan.FromSeconds(1)); + var tasks = new List(); + + for (int i = 0; i < 15; i++) + { + tasks.Add(Task.Run(() => rule.IsAllowed("client1", "res1"))); + } + + await Task.WhenAll(tasks); + + Assert.That(rule.IsAllowed("client1", "res1"), Is.False); + } + } } \ No newline at end of file diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..575cc3fd --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,50 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using RateLimiter.Rules; + +namespace RateLimiter +{ + public class RateLimiter + { + private readonly ConcurrentDictionary> _resourceRules = new(); + private readonly ILogger _logger; + + public RateLimiter(ILogger logger) + { + _logger = logger; + } + + public void AddRule(string resource, IRateLimitRule rule) + { + _resourceRules.AddOrUpdate(resource, _ => new List { rule }, (_, rules) => { rules.Add(rule); return rules; }); + } + + public bool IsRequestAllowed(string clientId, string resource) + { + if (!_resourceRules.TryGetValue(resource, out var rules)) + return true; + + foreach (var rule in rules) + { + if (!rule.IsAllowed(clientId, resource)) + { + _logger.LogWarning($"Request blocked: Client {clientId}, Resource {resource}"); + return false; + } + } + return true; + } + + public void Cleanup() + { + foreach (var rules in _resourceRules.Values) + { + foreach (var rule in rules) + { + rule.Cleanup(); + } + } + } + } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..dac28970 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/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs new file mode 100644 index 00000000..01e4eb83 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules +{ + public class FixedWindowRule : IRateLimitRule + { + private readonly int _limit; + private readonly TimeSpan _window; + private readonly ConcurrentDictionary _requests = new(); + + public FixedWindowRule(int limit, TimeSpan window) + { + _limit = limit; + _window = window; + } + + public bool IsAllowed(string clientId, string resource) + { + var key = $"{clientId}:{resource}"; + var now = DateTime.UtcNow; + + _requests.AddOrUpdate(key, _ => (1, now + _window), (_, entry) => + { + if (entry.resetTime <= now) + return (1, now + _window); + return (entry.count + 1, entry.resetTime); + }); + + return _requests[key].count <= _limit; + } + + public void Cleanup() + { + var now = DateTime.UtcNow; + foreach (var key in _requests.Keys) + { + if (_requests.TryGetValue(key, out var entry) && entry.resetTime <= now) + { + _requests.TryRemove(key, out _); + } + } + } + } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..cea73c0c --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Rules +{ + public interface IRateLimitRule + { + bool IsAllowed(string clientId, string resource); + void Cleanup(); + } +} diff --git a/RateLimiter/Rules/RegionalRule.cs b/RateLimiter/Rules/RegionalRule.cs new file mode 100644 index 00000000..e6c4a0a3 --- /dev/null +++ b/RateLimiter/Rules/RegionalRule.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace RateLimiter.Rules +{ + public class RegionalRule : IRateLimitRule + { + private readonly Dictionary _regionRules; + + public RegionalRule(Dictionary regionRules) + { + _regionRules = regionRules; + } + + public bool IsAllowed(string clientId, string resource) + { + var region = GetRegionFromClient(clientId); + return _regionRules.TryGetValue(region, out var rule) && rule.IsAllowed(clientId, resource); + } + + private string GetRegionFromClient(string clientId) + { + if (clientId.StartsWith("US-")) return "US"; + if (clientId.StartsWith("EU-")) return "EU"; + if (clientId.StartsWith("ASIA-")) return "ASIA"; + return "GLOBAL"; + } + + public void Cleanup() + { + foreach (var rule in _regionRules.Values) + { + rule.Cleanup(); + } + } + } +} diff --git a/RateLimiter/Rules/SlidingWindowRule.cs b/RateLimiter/Rules/SlidingWindowRule.cs new file mode 100644 index 00000000..35bb0670 --- /dev/null +++ b/RateLimiter/Rules/SlidingWindowRule.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter.Rules +{ + public class SlidingWindowRule : IRateLimitRule + { + private readonly int _limit; + private readonly TimeSpan _window; + private readonly ConcurrentDictionary> _requests = new(); + + public SlidingWindowRule(int limit, TimeSpan window) + { + _limit = limit; + _window = window; + } + + public bool IsAllowed(string clientId, string resource) + { + var key = $"{clientId}:{resource}"; + var now = DateTime.UtcNow; + + _requests.AddOrUpdate(key, _ => new Queue(new[] { now }), (_, queue) => + { + while (queue.Count > 0 && queue.Peek() <= now - _window) + queue.Dequeue(); + queue.Enqueue(now); + return queue; + }); + + return _requests[key].Count <= _limit; + } + + public void Cleanup() + { + var now = DateTime.UtcNow; + foreach (var key in _requests.Keys) + { + if (_requests.TryGetValue(key, out var queue)) + { + while (queue.Count > 0 && queue.Peek() <= now - _window) + queue.Dequeue(); + } + } + } + } +}