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