diff --git a/RateLimiter.Core/Configuration/ConsoleLogger.cs b/RateLimiter.Core/Configuration/ConsoleLogger.cs new file mode 100644 index 00000000..1d763d46 --- /dev/null +++ b/RateLimiter.Core/Configuration/ConsoleLogger.cs @@ -0,0 +1,16 @@ +using System; +using RateLimiter.Core.Configuration.Contracts; + +namespace RateLimiter.Core.Configuration; + +public class ConsoleLogger : ILogger +{ + public void LogInformation(string message) + => Console.WriteLine($"[INFO] {DateTime.UtcNow:O} {message}"); + + public void LogWarning(string message) + => Console.WriteLine($"[WARN] {DateTime.UtcNow:O} {message}"); + + public void LogError(string message, Exception ex = null) + => Console.WriteLine($"[ERROR] {DateTime.UtcNow:O} {message} {ex?.ToString()}"); +} \ No newline at end of file diff --git a/RateLimiter.Core/Configuration/Contracts/ILogger.cs b/RateLimiter.Core/Configuration/Contracts/ILogger.cs new file mode 100644 index 00000000..19fc4c6b --- /dev/null +++ b/RateLimiter.Core/Configuration/Contracts/ILogger.cs @@ -0,0 +1,12 @@ +using System; + +namespace RateLimiter.Core.Configuration.Contracts; +/// +/// It can be AWS.Logger.NLog or Microsoft.Extensions.Logging +/// +public interface ILogger +{ + void LogInformation(string message); + void LogWarning(string message); + void LogError(string message, Exception ex = null!); +} \ No newline at end of file diff --git a/RateLimiter.Core/Configuration/Contracts/IRateLimitRule.cs b/RateLimiter.Core/Configuration/Contracts/IRateLimitRule.cs new file mode 100644 index 00000000..a6c1a823 --- /dev/null +++ b/RateLimiter.Core/Configuration/Contracts/IRateLimitRule.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Core.Configuration.Contracts; + +public interface IRateLimitRule +{ + bool IsAllowed(string clientToken, string resourceKey); + void RecordRequest(string clientToken, string resourceKey); +} \ No newline at end of file diff --git a/RateLimiter.Core/Configuration/Contracts/ISystemTime.cs b/RateLimiter.Core/Configuration/Contracts/ISystemTime.cs new file mode 100644 index 00000000..4c93998f --- /dev/null +++ b/RateLimiter.Core/Configuration/Contracts/ISystemTime.cs @@ -0,0 +1,8 @@ +using System; + +namespace RateLimiter.Core.Configuration.Contracts; + +public interface ISystemTime +{ + DateTime GetCurrentUtcTime(); +} \ No newline at end of file diff --git a/RateLimiter.Core/Configuration/MockSystemTime.cs b/RateLimiter.Core/Configuration/MockSystemTime.cs new file mode 100644 index 00000000..11688e9c --- /dev/null +++ b/RateLimiter.Core/Configuration/MockSystemTime.cs @@ -0,0 +1,15 @@ +using System; +using RateLimiter.Core.Configuration.Contracts; + +namespace RateLimiter.Core.Configuration; + +/// +/// Only for test +/// +/// Test start time +public class MockSystemTime(DateTime startTime) : ISystemTime +{ + public DateTime CurrentTime { get; set; } = startTime; + + public DateTime GetCurrentUtcTime() => CurrentTime; +} \ No newline at end of file diff --git a/RateLimiter.Core/Configuration/RateLimiter.cs b/RateLimiter.Core/Configuration/RateLimiter.cs new file mode 100644 index 00000000..56cdba1a --- /dev/null +++ b/RateLimiter.Core/Configuration/RateLimiter.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RateLimiter.Core.Configuration.Contracts; + +namespace RateLimiter.Core.Configuration; +public class RateLimiter(ILogger logger = null!) +{ + private readonly Dictionary> _resourceRules = new(); + + public void AddRule(string resourceKey, IRateLimitRule rule) + { + if (string.IsNullOrEmpty(resourceKey)) + throw new ArgumentNullException(nameof(resourceKey)); + + if (rule == null) + throw new ArgumentNullException(nameof(rule)); + + try + { + if (!_resourceRules.ContainsKey(resourceKey)) + _resourceRules[resourceKey] = new List(); + + _resourceRules[resourceKey].Add(rule); + logger.LogInformation($"Added rule {rule.GetType().Name} for resource {resourceKey}"); + } + catch (Exception ex) + { + logger.LogError($"Error adding rule for resource {resourceKey}", ex); + throw; + } + } + + + public bool IsRequestAllowed(string clientToken, string resourceKey) + { + if (string.IsNullOrEmpty(clientToken)) + throw new ArgumentNullException(nameof(clientToken)); + + if (string.IsNullOrEmpty(resourceKey)) + throw new ArgumentNullException(nameof(resourceKey)); + + try + { + if (!_resourceRules.TryGetValue(resourceKey, out var rules) || !rules.Any()) + { + logger.LogWarning($"No rules configured for resource {resourceKey}"); + return true; + } + + foreach (var rule in rules.Where(rule => !rule.IsAllowed(clientToken, resourceKey))) + { + logger.LogWarning($"Request blocked by {rule.GetType().Name} " + + $"for {clientToken} on {resourceKey}"); + return false; + } + + foreach (var rule in rules) + { + try + { + rule.RecordRequest(clientToken, resourceKey); + } + catch (Exception ex) + { + logger.LogError($"Error recording request in {rule.GetType().Name}", ex); + } + } + + return true; + } + catch (Exception ex) + { + logger.LogError($"Error processing request for {clientToken} on {resourceKey}", ex); + return false; + } + } +} \ No newline at end of file diff --git a/RateLimiter.Core/Configuration/SystemSystemTime.cs b/RateLimiter.Core/Configuration/SystemSystemTime.cs new file mode 100644 index 00000000..55112688 --- /dev/null +++ b/RateLimiter.Core/Configuration/SystemSystemTime.cs @@ -0,0 +1,11 @@ +using System; +using RateLimiter.Core.Configuration.Contracts; + +namespace RateLimiter.Core.Configuration; +/// +/// Real time usage +/// +public class SystemSystemTime : ISystemTime +{ + public DateTime GetCurrentUtcTime() => DateTime.UtcNow; +} \ No newline at end of file diff --git a/RateLimiter.Core/Exceptions/FixedWindowRuleException.cs b/RateLimiter.Core/Exceptions/FixedWindowRuleException.cs new file mode 100644 index 00000000..f92f1ba9 --- /dev/null +++ b/RateLimiter.Core/Exceptions/FixedWindowRuleException.cs @@ -0,0 +1,8 @@ +using System; + +namespace RateLimiter.Core.Exceptions; + +public class FixedWindowRuleException : Exception +{ + +} \ No newline at end of file diff --git a/RateLimiter.Core/Exceptions/RegionBasedRuleException.cs b/RateLimiter.Core/Exceptions/RegionBasedRuleException.cs new file mode 100644 index 00000000..91ab1268 --- /dev/null +++ b/RateLimiter.Core/Exceptions/RegionBasedRuleException.cs @@ -0,0 +1,8 @@ +using System; + +namespace RateLimiter.Core.Exceptions; + +public class RegionBasedRuleException : Exception +{ + +} \ No newline at end of file diff --git a/RateLimiter.Core/Exceptions/TimeSinceLastCallRuleException.cs b/RateLimiter.Core/Exceptions/TimeSinceLastCallRuleException.cs new file mode 100644 index 00000000..4248a5bd --- /dev/null +++ b/RateLimiter.Core/Exceptions/TimeSinceLastCallRuleException.cs @@ -0,0 +1,7 @@ +using System; + +namespace RateLimiter.Core.Exceptions; + +public class TimeSinceLastCallRuleException : Exception +{ +} \ No newline at end of file diff --git a/RateLimiter.Core/PolicyProviders/DatabasePolicyProvider.cs b/RateLimiter.Core/PolicyProviders/DatabasePolicyProvider.cs new file mode 100644 index 00000000..94a410e0 --- /dev/null +++ b/RateLimiter.Core/PolicyProviders/DatabasePolicyProvider.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Core.PolicyProviders; +/// +/// TODO can be used for Get Policy from DB +/// +public class DatabasePolicyProvider +{ + +} \ No newline at end of file diff --git a/RateLimiter.Core/PolicyProviders/JsonPolicyProvider.cs b/RateLimiter.Core/PolicyProviders/JsonPolicyProvider.cs new file mode 100644 index 00000000..76f4b8af --- /dev/null +++ b/RateLimiter.Core/PolicyProviders/JsonPolicyProvider.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Core.PolicyProviders; +/// +/// TODO can be used for Get Policy from app_settings or local file +/// +public class JsonPolicyProvider +{ + +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter.Core/RateLimiter.Core.csproj similarity index 77% rename from RateLimiter/RateLimiter.csproj rename to RateLimiter.Core/RateLimiter.Core.csproj index 19962f52..b4648de7 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter.Core/RateLimiter.Core.csproj @@ -1,6 +1,6 @@  - net6.0 + net9.0 latest enable diff --git a/RateLimiter.Core/Rules/Combine/RegionBasedRule.cs b/RateLimiter.Core/Rules/Combine/RegionBasedRule.cs new file mode 100644 index 00000000..297d11d0 --- /dev/null +++ b/RateLimiter.Core/Rules/Combine/RegionBasedRule.cs @@ -0,0 +1,75 @@ +using System; +using RateLimiter.Core.Configuration; +using RateLimiter.Core.Configuration.Contracts; + +namespace RateLimiter.Core.Rules.Combine; + +public class RegionBasedRule( + IRateLimitRule usRule, + IRateLimitRule euRule, + ILogger logger = null!) : IRateLimitRule +{ + private readonly IRateLimitRule _usRule = usRule ?? throw new ArgumentNullException(nameof(usRule)); + private readonly IRateLimitRule _euRule = euRule ?? throw new ArgumentNullException(nameof(euRule)); + + private readonly ILogger _logger = logger ?? new ConsoleLogger(); + + public bool IsAllowed(string clientToken, string resourceKey) + { + try + { + var region = GetRegionFromToken(clientToken); + _logger.LogInformation($"Processing {region} region request for {clientToken}"); + + return region switch + { + "US" => _usRule.IsAllowed(clientToken, resourceKey), + "EU" => _euRule.IsAllowed(clientToken, resourceKey), + _ => HandleUnknownRegion(clientToken) + }; + } + catch (Exception ex) + { + _logger.LogError($"Error in RegionBasedRule.IsAllowed for {clientToken}", ex); + return false; + } + } + + private bool HandleUnknownRegion(string clientToken) + { + _logger.LogWarning($"Unknown region for token {clientToken}"); + return false; + } + + private static string GetRegionFromToken(string token) + { + try + { + var parts = token.Split('-'); + if (parts.Length < 1 || string.IsNullOrEmpty(parts[0])) + throw new FormatException("Invalid token format"); + + return parts[0].ToUpper(); + } + catch + { + throw new FormatException($"Invalid token format: {token}"); + } + } + + public void RecordRequest(string clientToken, string resourceKey) + { + var region = GetRegionFromToken(clientToken); + switch (region) + { + case "US": + _usRule.RecordRequest(clientToken, resourceKey); + break; + case "EU": + _euRule.RecordRequest(clientToken, resourceKey); + break; + default: + throw new NotSupportedException($"Region {region} not supported."); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Core/Rules/FixedWindowRule.cs b/RateLimiter.Core/Rules/FixedWindowRule.cs new file mode 100644 index 00000000..520b672e --- /dev/null +++ b/RateLimiter.Core/Rules/FixedWindowRule.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using RateLimiter.Core.Configuration; +using RateLimiter.Core.Configuration.Contracts; + +namespace RateLimiter.Core.Rules; +public class FixedWindowRule : IRateLimitRule +{ + private readonly int _maxRequests; + private readonly TimeSpan _windowDuration; + private readonly ISystemTime _systemTime; + private readonly ILogger _logger; + + private readonly Dictionary> _clientResourceData = new(); + + public FixedWindowRule(int maxRequests, TimeSpan windowDuration, ISystemTime systemTime = null, ILogger logger = null) + { + if (maxRequests <= 0) + throw new ArgumentException("Max requests must be positive", nameof(maxRequests)); + _maxRequests = maxRequests; + _windowDuration = windowDuration; + _logger = logger; + _systemTime = systemTime; + } + + public bool IsAllowed(string clientToken, string resourceKey) + { + if (string.IsNullOrEmpty(clientToken)) + throw new ArgumentNullException(nameof(clientToken)); + + if (string.IsNullOrEmpty(resourceKey)) + throw new ArgumentNullException(nameof(resourceKey)); + + try + { + lock (_clientResourceData) + { + var currentTime = _systemTime.GetCurrentUtcTime(); + var data = GetOrInitializeData(clientToken, resourceKey, currentTime); + + _logger.LogInformation($"Client {clientToken} resource {resourceKey}: " + + $"{data.Count}/{_maxRequests} requests in current window"); + + return data.Count < _maxRequests; + } + } + catch (Exception ex) + { + _logger.LogError($"Error in FixedWindowRule.IsAllowed for {clientToken}", ex); + return false; + } + } + + public void RecordRequest(string clientToken, string resourceKey) + { + lock (_clientResourceData) + { + var currentTime = _systemTime.GetCurrentUtcTime(); + var data = GetOrInitializeData(clientToken, resourceKey, currentTime); + data.Count++; + UpdateData(clientToken, resourceKey, data.Count, data.WindowStart); + } + } + + private (int Count, DateTime WindowStart) GetOrInitializeData(string clientToken, string resourceKey, DateTime currentTime) + { + if (!_clientResourceData.TryGetValue(clientToken, out var resourceData)) + { + resourceData = new Dictionary(); + _clientResourceData[clientToken] = resourceData; + } + + if (!resourceData.TryGetValue(resourceKey, out var data)) + { + data = (0, currentTime); + resourceData[resourceKey] = data; + } + + if (currentTime - data.WindowStart > _windowDuration) + { + data = (0, currentTime); + resourceData[resourceKey] = data; + } + + return data; + } + + private void UpdateData(string clientToken, string resourceKey, int count, DateTime windowStart) + { + _clientResourceData[clientToken][resourceKey] = (count, windowStart); + } +} \ No newline at end of file diff --git a/RateLimiter.Core/Rules/TimeSinceLastCallRule.cs b/RateLimiter.Core/Rules/TimeSinceLastCallRule.cs new file mode 100644 index 00000000..d4776936 --- /dev/null +++ b/RateLimiter.Core/Rules/TimeSinceLastCallRule.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using RateLimiter.Core.Configuration.Contracts; + +namespace RateLimiter.Core.Rules; + +public class TimeSinceLastCallRule : IRateLimitRule +{ + private readonly TimeSpan _requiredDelay; + private readonly ISystemTime _systemTime; + private readonly Dictionary> _lastRequestTimes = new(); + private readonly ILogger _logger; + public TimeSinceLastCallRule(TimeSpan requiredDelay, ISystemTime systemTime = null!, ILogger logger = null!) + { + if (requiredDelay.Ticks < 0) + throw new ArgumentException("Delay cannot be negative", nameof(requiredDelay)); + _requiredDelay = requiredDelay; + _systemTime = systemTime; + _logger = logger; + } + + public bool IsAllowed(string clientToken, string resourceKey) + { + try + { + lock (_lastRequestTimes) + { + var currentTime = _systemTime.GetCurrentUtcTime(); + + if (!_lastRequestTimes.TryGetValue(clientToken, out var resourceTimes) || + !resourceTimes.TryGetValue(resourceKey, out var lastTime)) + { + return true; + } + + var timeSinceLastCall = currentTime - lastTime; + _logger.LogInformation($"Client {clientToken} last call: " + + $"{timeSinceLastCall.TotalSeconds}s ago (required {_requiredDelay.TotalSeconds}s)"); + + return timeSinceLastCall >= _requiredDelay; + } + } + catch (Exception ex) + { + _logger.LogError($"Error in TimeSinceLastCallRule.IsAllowed for {clientToken}", ex); + return false; + } + } + + public void RecordRequest(string clientToken, string resourceKey) + { + lock (_lastRequestTimes) + { + if (!_lastRequestTimes.TryGetValue(clientToken, out var resourceTimes)) + { + resourceTimes = new Dictionary(); + _lastRequestTimes[clientToken] = resourceTimes; + } + + resourceTimes[resourceKey] = _systemTime.GetCurrentUtcTime(); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Core/StorageProviders/IDistributedStorage.cs b/RateLimiter.Core/StorageProviders/IDistributedStorage.cs new file mode 100644 index 00000000..1673dcc3 --- /dev/null +++ b/RateLimiter.Core/StorageProviders/IDistributedStorage.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Core.StorageProviders; + +public class DistributedStorage +{ + +} \ No newline at end of file diff --git a/RateLimiter.Core/StorageProviders/InMemoryStorage.cs b/RateLimiter.Core/StorageProviders/InMemoryStorage.cs new file mode 100644 index 00000000..b9a60a1f --- /dev/null +++ b/RateLimiter.Core/StorageProviders/InMemoryStorage.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Core.StorageProviders; + +public class InMemoryStorage +{ + +} \ No newline at end of file diff --git a/RateLimiter.Core/StorageProviders/RedisStorage.cs b/RateLimiter.Core/StorageProviders/RedisStorage.cs new file mode 100644 index 00000000..58a31ba3 --- /dev/null +++ b/RateLimiter.Core/StorageProviders/RedisStorage.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Core.StorageProviders; + +public class RedisStorage +{ + +} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..ad92a93b 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -1,11 +1,11 @@  - net6.0 + net9.0 latest enable - + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..6290e9dd 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,96 @@ -using NUnit.Framework; +using System; +using NUnit.Framework; +using RateLimiter.Core.Configuration; +using RateLimiter.Core.Rules; +using RateLimiter.Core.Rules.Combine; namespace RateLimiter.Tests; [TestFixture] public class RateLimiterTest { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } + [Test] + public void FixedWindowRule_AllowsMaxRequestsPerWindow() + { + var startTime = new DateTime(2025, 1, 28, 1, 1, 1, DateTimeKind.Utc); + var logger = new ConsoleLogger(); + var clock = new MockSystemTime(startTime); + var rule = new FixedWindowRule(2, TimeSpan.FromMinutes(1), clock, logger); + var clientToken = "client1"; + var resourceKey = "resource1"; + + Assert.IsTrue(rule.IsAllowed(clientToken, resourceKey)); + rule.RecordRequest(clientToken, resourceKey); + + Assert.IsTrue(rule.IsAllowed(clientToken, resourceKey)); + rule.RecordRequest(clientToken, resourceKey); + + Assert.IsFalse(rule.IsAllowed(clientToken, resourceKey)); + + clock.CurrentTime = startTime.AddMinutes(1); + Assert.IsFalse(rule.IsAllowed(clientToken, resourceKey)); + } + + [Test] + public void TimeSinceLastCallRule_AllowsRequestAfterDelay() + { + var logger = new ConsoleLogger(); + var startTime = new DateTime(2025, 1, 28, 2, 2, 2, DateTimeKind.Utc); + var clock = new MockSystemTime(startTime); + var rule = new TimeSinceLastCallRule(TimeSpan.FromSeconds(10), clock, logger); + var clientToken = "client1"; + var resourceKey = "resource1"; + + Assert.IsTrue(rule.IsAllowed(clientToken, resourceKey)); + rule.RecordRequest(clientToken, resourceKey); + + clock.CurrentTime = startTime.AddSeconds(5); + Assert.IsFalse(rule.IsAllowed(clientToken, resourceKey)); + + clock.CurrentTime = startTime.AddSeconds(10); + Assert.IsTrue(rule.IsAllowed(clientToken, resourceKey)); + } + + [Test] + public void RateLimiter_CombinedRules_BothMustAllow() + { + var startTime = new DateTime(2025, 1, 28, 3, 3, 3, DateTimeKind.Utc); + var clock = new MockSystemTime(startTime); + var logger = new ConsoleLogger(); + var rateLimiter = new Core.Configuration.RateLimiter(logger); + rateLimiter.AddRule("resource1", new FixedWindowRule(2, TimeSpan.FromMinutes(1), clock, logger)); + rateLimiter.AddRule("resource1", new TimeSinceLastCallRule(TimeSpan.FromSeconds(10), clock, logger)); + + var clientToken = "client1"; + Assert.IsTrue(rateLimiter.IsRequestAllowed(clientToken, "resource1")); + clock.CurrentTime = startTime.AddSeconds(5); + Assert.IsFalse(rateLimiter.IsRequestAllowed(clientToken, "resource1")); + + clock.CurrentTime = startTime.AddSeconds(10); + Assert.IsTrue(rateLimiter.IsRequestAllowed(clientToken, "resource1")); + } + + [Test] + public void RegionBasedRule_UsesDifferentRulesBasedOnTokenRegion() + { + var logger = new ConsoleLogger(); + var startTime = new DateTime(2025, 1, 28, 4, 4, 4, DateTimeKind.Utc); + var clock = new MockSystemTime(startTime); + var usRule = new FixedWindowRule(2, TimeSpan.FromMinutes(1), clock, logger); + var euRule = new TimeSinceLastCallRule(TimeSpan.FromSeconds(10), clock, logger); + var regionRule = new RegionBasedRule(usRule, euRule); + + var rateLimiter = new Core.Configuration.RateLimiter(logger); + rateLimiter.AddRule("resource1", regionRule); + + var usToken = "US-123"; + var euToken = "EU-321"; + + Assert.IsTrue(rateLimiter.IsRequestAllowed(usToken, "resource1")); + Assert.IsTrue(rateLimiter.IsRequestAllowed(usToken, "resource1")); + Assert.IsFalse(rateLimiter.IsRequestAllowed(usToken, "resource1")); + + Assert.IsTrue(rateLimiter.IsRequestAllowed(euToken, "resource1")); + Assert.IsFalse(rateLimiter.IsRequestAllowed(euToken, "resource1")); + } } \ No newline at end of file diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..1caab340 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.26730.15 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Core", "RateLimiter.Core\RateLimiter.Core.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" EndProject