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();
+ }
+ }
+ }
+ }
+}