From 319b57a95a8fc0e015eb7afa9b27a5055f477679 Mon Sep 17 00:00:00 2001 From: jcontradiction Date: Sun, 13 Oct 2024 07:12:18 -0500 Subject: [PATCH] initial commit --- .gitignore | 1 + .vscode/launch.json | 26 ++++ .vscode/tasks.json | 41 +++++ RateLimiter.Tests/RateLimiterTest.cs | 143 ++++++++++++++++-- RateLimiter/RateLimitRules/IRateLimitRule.cs | 10 ++ .../RateLimitRules/RequestsPerTimeSpanRule.cs | 50 ++++++ .../TimeSpanSinceLastRequestRule.cs | 42 +++++ RateLimiter/RateLimiter.cs | 79 ++++++++++ RateLimiter/RateLimiter.csproj | 5 + RateLimiter/RateLimiter.xml | 31 ++++ 10 files changed, 419 insertions(+), 9 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 RateLimiter/RateLimitRules/IRateLimitRule.cs create mode 100644 RateLimiter/RateLimitRules/RequestsPerTimeSpanRule.cs create mode 100644 RateLimiter/RateLimitRules/TimeSpanSinceLastRequestRule.cs create mode 100644 RateLimiter/RateLimiter.cs create mode 100644 RateLimiter/RateLimiter.xml diff --git a/.gitignore b/.gitignore index dc462b74..85847ba4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .vs +.DS_Store packages bin obj \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..73c9eabd --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/RateLimiter.Tests/bin/Debug/net6.0/RateLimiter.Tests.dll", + "args": [], + "cwd": "${workspaceFolder}/RateLimiter.Tests", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..a8d2105e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/RateLimiter.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/RateLimiter.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/RateLimiter.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..94e14637 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,138 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests; +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiterNS.RateLimitRules; [TestFixture] public class RateLimiterTest { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file + private RateLimiterNS.RateLimiter.RateLimiter? _rateLimiter; + + [SetUp] + public void SetUp() + { + string xmlFilePath = Path.Combine(AppContext.BaseDirectory, "RateLimiter.xml"); + _rateLimiter = RateLimiterNS.RateLimiter.RateLimiter.LoadFromConfiguration(xmlFilePath); + } + + [Test] + public async Task ValidTokenWithMultipleRules_ShouldAllowRequests_UnderLimit() + { + //max 5 requests per 1 minute and min 2 seconds between requests allowed + string token = "token1"; + + for (int i = 0; i < 5; i++) + { + await Task.Delay(TimeSpan.FromSeconds(3)); + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + } + } + + [Test] + public async Task ValidTokenWithMultipleRules_ShouldDenyRequests_OverLimit() + { + //max 5 requests per 1 minute and min 2 seconds between requests allowed + string token = "token1"; + + for (int i = 0; i < 5; i++) + { + if (i == 0) + { + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + } + Assert.IsFalse(await _rateLimiter!.IsRequestAllowedAsync(token)); + } + // The sixth request should be denied + Assert.IsFalse(await _rateLimiter!.IsRequestAllowedAsync(token)); + } + + [Test] + public async Task ConcurrentRequests_ShouldBeHandledCorrectly() + { + // max 3 requests per 1 minute allowed + string token = "token3"; + int allowedRequests = 3; + var tasks = new List>(); + + // Fire multiple requests concurrently + for (int i = 0; i < 6; i++) + { + tasks.Add(Task.Run(() => _rateLimiter!.IsRequestAllowedAsync(token))); + } + + Task.WhenAll(tasks).Wait(); + + int allowedCount = tasks.Count(task => task.Result); + Assert.IsTrue(allowedCount <= allowedRequests, $"Allowed requests exceeded: {allowedCount} > {allowedRequests}"); + } + + [Test] + public async Task ValidTokenWithMultipleRules_ShouldAllowRequests_AfterWait() + { + //max 5 requests per 1 minute and min 2 seconds between requests allowed + string token = "token1"; + + // First request -> should be allowed + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + // Second request (immediately) -> should be denied + Assert.IsFalse(await _rateLimiter!.IsRequestAllowedAsync(token)); + // Sleep for 2 seconds + await Task.Delay(TimeSpan.FromSeconds(2)); + // Now it should be allowed + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + } + [Test] + public async Task ValidTokenWithTimeSpanSinceLastRequestRule_ShouldEnforceRule() + { + // min 2 seconds between requests allowed + string token = "token2"; + // First request -> should be allowed + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + // Second request (immediately) -> should be denied + Assert.IsFalse(await _rateLimiter!.IsRequestAllowedAsync(token)); + // Sleep for 2 seconds + await Task.Delay(TimeSpan.FromSeconds(2)); + // Now next requests should be allowed and denied + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + Assert.IsFalse(await _rateLimiter!.IsRequestAllowedAsync(token)); + + } + + [Test] + public async Task ValidTokenWithRequestsPerTimeSpanRule_ShouldEnforceRule() + { + // max 3 requests per 1 minute allowed + string token = "token3"; + + // First request -> should be allowed + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + + // Second request -> should be allowed + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + + // Third request -> should be allowed + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + + // Fourth request -> should be denied (exceeding the limit of 3 requests within 1 minute) + Assert.IsFalse(await _rateLimiter!.IsRequestAllowedAsync(token)); + + // Wait for the timespan of 1 minute + await Task.Delay(TimeSpan.FromMinutes(1)); + + // restriction should be lifted and next 3 requests should be allowed now and 4th request should be denied + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + Assert.IsTrue(await _rateLimiter!.IsRequestAllowedAsync(token)); + Assert.IsFalse(await _rateLimiter!.IsRequestAllowedAsync(token)); + } + + [Test] + public async Task InvalidToken_ShouldDenyRequests() + { + string token = "invalid_token"; + Assert.IsFalse(await _rateLimiter!.IsRequestAllowedAsync(token)); + } +} diff --git a/RateLimiter/RateLimitRules/IRateLimitRule.cs b/RateLimiter/RateLimitRules/IRateLimitRule.cs new file mode 100644 index 00000000..492ae998 --- /dev/null +++ b/RateLimiter/RateLimitRules/IRateLimitRule.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace RateLimiterNS.RateLimitRules +{ + public interface IRateLimitRule + { + Task IsRequestAllowedAsync(string token, DateTime requestTime); + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimitRules/RequestsPerTimeSpanRule.cs b/RateLimiter/RateLimitRules/RequestsPerTimeSpanRule.cs new file mode 100644 index 00000000..c98a9be9 --- /dev/null +++ b/RateLimiter/RateLimitRules/RequestsPerTimeSpanRule.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RateLimiterNS.RateLimitRules +{ + public class RequestsPerTimeSpanRule : IRateLimitRule + { + private readonly int _maxRequests; + private readonly TimeSpan _timespan; + private readonly ConcurrentDictionary> _requestTimes = new(); + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + + public RequestsPerTimeSpanRule(int maxRequests, TimeSpan timespan) + { + _maxRequests = maxRequests; + _timespan = timespan; + } + + public async Task IsRequestAllowedAsync(string token, DateTime requestTime) + { + var times = _requestTimes.GetOrAdd(token, _ => new List()); + + await _semaphore.WaitAsync(); + try + { + + times.RemoveAll(t => t < requestTime - _timespan); + + + if (times.Count >= _maxRequests) + { + return false; + } + + + times.Add(requestTime); + return true; + + } + finally + { + _semaphore.Release(); + } + } + } + +} diff --git a/RateLimiter/RateLimitRules/TimeSpanSinceLastRequestRule.cs b/RateLimiter/RateLimitRules/TimeSpanSinceLastRequestRule.cs new file mode 100644 index 00000000..600d619c --- /dev/null +++ b/RateLimiter/RateLimitRules/TimeSpanSinceLastRequestRule.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; + +namespace RateLimiterNS.RateLimitRules +{ + public class TimeSpanSinceLastRequestRule : IRateLimitRule + { + private readonly TimeSpan _minTimeSpan; + private readonly ConcurrentDictionary _lastRequestTimes = new(); + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + + public TimeSpanSinceLastRequestRule(TimeSpan minTimeSpan) + { + _minTimeSpan = minTimeSpan; + } + + public async Task IsRequestAllowedAsync(string token, DateTime requestTime) + { + await _semaphore.WaitAsync(); + try + { + var lastRequestTime = _lastRequestTimes.GetValueOrDefault(token); + + if (lastRequestTime == default || requestTime - lastRequestTime >= _minTimeSpan) + { + _lastRequestTimes[token] = requestTime; + return true; + } + + return false; + } + finally + { + _semaphore.Release(); + } + } + } +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..32b53842 --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RateLimiterNS.RateLimitRules; +using System.Xml.Linq; +using System.Threading.Tasks; + +namespace RateLimiterNS.RateLimiter +{ + public class RateLimiter + { + private readonly Dictionary> _tokenRules; + + public RateLimiter(Dictionary> tokenRules) + { + _tokenRules = tokenRules; + } + + public async Task IsRequestAllowedAsync(string token) + { + if (!_tokenRules.ContainsKey(token)) + { + Console.WriteLine($"Invalid token: {token}"); + return false; + } + + var requestTime = DateTime.UtcNow; + var tasks = _tokenRules[token].Select(rule => rule.IsRequestAllowedAsync(token, requestTime)); + + var results = await Task.WhenAll(tasks); + return results.All(result => result); + } + + public static RateLimiter LoadFromConfiguration(string xmlFilePath) + { + var doc = XDocument.Load(xmlFilePath); + var tokenRules = new Dictionary>(); + + foreach (var tokenElement in doc.Descendants("Token")) + { + + string? token = tokenElement.Attribute("Value")?.Value; + if (string.IsNullOrEmpty(token)) + { + continue; + } + + var rules = new List(); + + foreach (var ruleElement in tokenElement.Descendants("Rule")) + { + string? ruleType = ruleElement.Attribute("Type")?.Value; + if (ruleType == "RequestsPerTimeSpan") + { + if (int.TryParse(ruleElement.Element("MaxRequests")?.Value, out int maxRequests) && + int.TryParse(ruleElement.Element("TimeSpanMinutes")?.Value, out int timeSpanMinutes)) + { + TimeSpan timespan = TimeSpan.FromMinutes(timeSpanMinutes); + rules.Add(new RequestsPerTimeSpanRule(maxRequests, timespan)); + } + } + else if (ruleType == "TimeSpanSinceLastRequest") + { + if (int.TryParse(ruleElement.Element("MinTimeSpanSeconds")?.Value, out int minTimeSpanSeconds)) + { + TimeSpan minTimeSpan = TimeSpan.FromSeconds(minTimeSpanSeconds); + rules.Add(new TimeSpanSinceLastRequestRule(minTimeSpan)); + } + } + } + + tokenRules[token] = rules; + } + + return new RateLimiter(tokenRules); + } + } +} + diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..7d344f4d 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,9 @@ latest enable + + + PreserveNewest + + \ No newline at end of file diff --git a/RateLimiter/RateLimiter.xml b/RateLimiter/RateLimiter.xml new file mode 100644 index 00000000..385e428b --- /dev/null +++ b/RateLimiter/RateLimiter.xml @@ -0,0 +1,31 @@ + + + + + + + 5 + 1 + + + 2 + + + + + + + 2 + + + + + + + 3 + 1 + + + + +