From 1d0ea2dedf00facca41e55e9c5541ce264e43155 Mon Sep 17 00:00:00 2001 From: Rachel Beneroff Date: Fri, 18 Apr 2025 14:21:58 -0700 Subject: [PATCH 1/3] working simple rate limiter --- RateLimiter.Tests/RateLimiter.Tests.csproj | 4 +- RateLimiter.Tests/RateLimiterTest.cs | 71 ++++++++++++++++--- RateLimiter.Tests/Rules/CompositeRuleTest.cs | 41 +++++++++++ RateLimiter.Tests/Rules/FixedWindowTest.cs | 43 +++++++++++ RateLimiter.Tests/Rules/RegionBasedTest.cs | 43 +++++++++++ RateLimiter.Tests/Rules/TokenBucketTest.cs | 43 +++++++++++ RateLimiter/Builders/RuleBuilder.cs | 60 ++++++++++++++++ RateLimiter/Controllers/OrdersController.cs | 30 ++++++++ RateLimiter/Controllers/TestController.cs | 32 +++++++++ RateLimiter/Controllers/UsersController.cs | 20 ++++++ RateLimiter/Core/RateLimiter.cs | 31 ++++++++ RateLimiter/Core/Rules/CompositeAndRule.cs | 24 +++++++ RateLimiter/Core/Rules/CompositeOrRule.cs | 24 +++++++ RateLimiter/Core/Rules/FixedWindowRule.cs | 48 +++++++++++++ RateLimiter/Core/Rules/IRateLimitRule.cs | 13 ++++ RateLimiter/Core/Rules/RegionBasedRule.cs | 43 +++++++++++ RateLimiter/Core/Rules/TokenBucketRule.cs | 67 +++++++++++++++++ RateLimiter/Examples/RateLimiterExamples.cs | 27 +++++++ .../Middleware/RateLimitingMiddleware.cs | 62 ++++++++++++++++ RateLimiter/Program.cs | 25 +++++++ RateLimiter/Properties/launchSettings.json | 13 ++++ RateLimiter/RateLimiter.csproj | 18 ++++- RateLimiter/Startup.cs | 41 +++++++++++ RateLimiter/appsettings.Development.json | 8 +++ RateLimiter/appsettings.json | 9 +++ 25 files changed, 825 insertions(+), 15 deletions(-) create mode 100644 RateLimiter.Tests/Rules/CompositeRuleTest.cs create mode 100644 RateLimiter.Tests/Rules/FixedWindowTest.cs create mode 100644 RateLimiter.Tests/Rules/RegionBasedTest.cs create mode 100644 RateLimiter.Tests/Rules/TokenBucketTest.cs create mode 100644 RateLimiter/Builders/RuleBuilder.cs create mode 100644 RateLimiter/Controllers/OrdersController.cs create mode 100644 RateLimiter/Controllers/TestController.cs create mode 100644 RateLimiter/Controllers/UsersController.cs create mode 100644 RateLimiter/Core/RateLimiter.cs create mode 100644 RateLimiter/Core/Rules/CompositeAndRule.cs create mode 100644 RateLimiter/Core/Rules/CompositeOrRule.cs create mode 100644 RateLimiter/Core/Rules/FixedWindowRule.cs create mode 100644 RateLimiter/Core/Rules/IRateLimitRule.cs create mode 100644 RateLimiter/Core/Rules/RegionBasedRule.cs create mode 100644 RateLimiter/Core/Rules/TokenBucketRule.cs create mode 100644 RateLimiter/Examples/RateLimiterExamples.cs create mode 100644 RateLimiter/Middleware/RateLimitingMiddleware.cs create mode 100644 RateLimiter/Program.cs create mode 100644 RateLimiter/Properties/launchSettings.json create mode 100644 RateLimiter/Startup.cs create mode 100644 RateLimiter/appsettings.Development.json create mode 100644 RateLimiter/appsettings.json diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..62e63183 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -1,6 +1,6 @@  - net6.0 + net8.0 latest enable @@ -12,4 +12,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..1150b989 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,64 @@ -using NUnit.Framework; +using System; +using NUnit.Framework; +using RateLimiter.Core; +using RateLimiter.Core.Rules; -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest +namespace RateLimiter.Tests { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } + [TestFixture] + public class RateLimiterTest + { + [Test] + public void TestNoRulesConfigured() + { + var rateLimiter = new Core.RateLimiter(); + string clientToken = "test-client"; + string resourceId = "api/no-rule"; + + // should allow all requests when no rule is configured + for (int i = 0; i < 10; i++) + { + Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, + $"Request {i+1} should be allowed when no rule is configured"); + } + } + + [Test] + public void TestUsesCorrectRule() + { + var rateLimiter = new Core.RateLimiter(); + var rule = new FixedWindowRule(2, TimeSpan.FromSeconds(10)); + + rateLimiter.ConfigureResource("api/test", rule); + + string clientToken = "test-client"; + string resourceId = "api/test"; + + // should follow the configured rule's behavior + Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, "First request should be allowed"); + Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, "Second request should be allowed"); + Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.False, "Third request should be denied"); + } + + [Test] + public void TestMultipleResources() + { + var rateLimiter = new Core.RateLimiter(); + + // configure different rules for different resources + rateLimiter.ConfigureResource("api/resource1", new FixedWindowRule(1, TimeSpan.FromSeconds(10))); + rateLimiter.ConfigureResource("api/resource2", new FixedWindowRule(2, TimeSpan.FromSeconds(10))); + + string clientToken = "test-client"; + + // resource 1 should allow 1 request + Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource1"), Is.True, "First request to resource1 should be allowed"); + Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource1"), Is.False, "Second request to resource1 should be denied"); + + // resource 2 should allow 2 requests + Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.True, "First request to resource2 should be allowed"); + Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.True, "Second request to resource2 should be allowed"); + Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.False, "Third request to resource2 should be denied"); + } + } } \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/CompositeRuleTest.cs b/RateLimiter.Tests/Rules/CompositeRuleTest.cs new file mode 100644 index 00000000..1505a2a9 --- /dev/null +++ b/RateLimiter.Tests/Rules/CompositeRuleTest.cs @@ -0,0 +1,41 @@ +using System; +using NUnit.Framework; +using RateLimiter.Core.Rules; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class CompositeRuleTest + { + [Test] + public void TestCompositeAnd() + { + var rule1 = new FixedWindowRule(2, TimeSpan.FromSeconds(10)); // 2 requests max + var rule2 = new FixedWindowRule(3, TimeSpan.FromSeconds(10)); // 3 requests max + var compositeRule = new CompositeAndRule(rule1, rule2); + + string clientToken = "test-client"; + string resourceId = "api/test"; + + Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed by both rules"); + Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed by both rules"); + Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.False, "Third request should be denied by rule1"); + } + + [Test] + public void TestCompositeOr() + { + var rule1 = new FixedWindowRule(1, TimeSpan.FromSeconds(10)); // 1 request max + var rule2 = new TokenBucketRule(2, 0); // allow 2 requests + var compositeRule = new CompositeOrRule(rule1, rule2); + + string clientToken = "test-client"; + string resourceId = "api/test"; + + Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed by both rules"); + Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed by rule2"); + Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed by 1 rule"); + Assert.That(compositeRule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied by both rules"); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/FixedWindowTest.cs b/RateLimiter.Tests/Rules/FixedWindowTest.cs new file mode 100644 index 00000000..52a1effd --- /dev/null +++ b/RateLimiter.Tests/Rules/FixedWindowTest.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using NUnit.Framework; +using RateLimiter.Core.Rules; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class FixedWindowTest + { + [Test] + public void TestAllowAndDeny() + { + // create a fixed window rule with max 3 requests per 10-second window + var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(10)); + string clientToken = "test-client"; + string resourceId = "api/test"; + + // should allow exactly 3 requests + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed"); + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed"); + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed"); + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied"); + } + + [Test] + public void TestWindowReset() + { + // create a fixed window rule with max 1 request per 1 sec window + var rule = new FixedWindowRule(1, TimeSpan.FromSeconds(1)); + string clientToken = "test-client"; + string resourceId = "api/test"; + + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed"); + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Second request in same window should be denied"); + + // wait for token to reset + Thread.Sleep(1100); + + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Request in new window should be allowed"); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/RegionBasedTest.cs b/RateLimiter.Tests/Rules/RegionBasedTest.cs new file mode 100644 index 00000000..f87b63af --- /dev/null +++ b/RateLimiter.Tests/Rules/RegionBasedTest.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using RateLimiter.Core.Rules; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class RegionBasedRuleTest + { + private string GetRegion(string token) + { + if (token.StartsWith("EU")) + return "EU"; + return "US"; + } + + [Test] + public void TestApplyCorrectRuleForRegion() + { + // US rule allows 2 requests, EU rule allows 1 request + var usRule = new FixedWindowRule(2, TimeSpan.FromSeconds(10)); + var euRule = new FixedWindowRule(1, TimeSpan.FromSeconds(10)); + + var regionRules = new Dictionary + { + { "US", usRule }, + { "EU", euRule } + }; + + var rule = new RegionBasedRule(regionRules, GetRegion); + + // US client should get 2 requests + Assert.That(rule.IsAllowed("US-client", "api/test"), Is.True, "First US request should be allowed"); + Assert.That(rule.IsAllowed("US-client", "api/test"), Is.True, "Second US request should be allowed"); + Assert.That(rule.IsAllowed("US-client", "api/test"), Is.False, "Third US request should be denied"); + + // EU client should get 1 request + Assert.That(rule.IsAllowed("EU-client", "api/test"), Is.True, "First EU request should be allowed"); + Assert.That(rule.IsAllowed("EU-client", "api/test"), Is.False, "Second EU request should be denied"); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/TokenBucketTest.cs b/RateLimiter.Tests/Rules/TokenBucketTest.cs new file mode 100644 index 00000000..b5fb6fe4 --- /dev/null +++ b/RateLimiter.Tests/Rules/TokenBucketTest.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using NUnit.Framework; +using RateLimiter.Core.Rules; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class TokenBucketTest + { + [Test] + public void TestTokenBucketAllowAndDeny() + { + // create a token bucket with 3 tokens and no refill + var rule = new TokenBucketRule(3, 0); + string clientToken = "test-client"; + string resourceId = "api/test"; + + // should allow exactly 3 requests + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed"); + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Second request should be allowed"); + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Third request should be allowed"); + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Fourth request should be denied"); + } + + [Test] + public void TestTokenBucketWithRefill() + { + // create a token bucket with 1 token that refills at 1 token per second + var rule = new TokenBucketRule(1, 1.0); + string clientToken = "test-client"; + string resourceId = "api/test"; + + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "First request should be allowed"); + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.False, "Request before refill should be denied"); + + // wait for token to refill + Thread.Sleep(1100); + + Assert.That(rule.IsAllowed(clientToken, resourceId), Is.True, "Request after refill should be allowed"); + } + } +} \ No newline at end of file diff --git a/RateLimiter/Builders/RuleBuilder.cs b/RateLimiter/Builders/RuleBuilder.cs new file mode 100644 index 00000000..6d6609fd --- /dev/null +++ b/RateLimiter/Builders/RuleBuilder.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using RateLimiter.Core.Rules; + +namespace RateLimiter.Builders +{ + public static class RuleBuilder + { + public static IRateLimitRule CreateFixedWindowRule(int maxRequests, TimeSpan window) + { + return new FixedWindowRule(maxRequests, window); + } + + public static IRateLimitRule CreateTokenBucketRule(int capacity, double refillRate) + { + return new TokenBucketRule(capacity, refillRate); + } + + public static IRateLimitRule CreateRegionBasedRule(Dictionary regionRules, Func regionExtractor, IRateLimitRule? defaultRule = null) + { + return new RegionBasedRule(regionRules, regionExtractor, defaultRule); + } + + public static IRateLimitRule CreateCompositeAndRule(params IRateLimitRule[] rules) + { + return new CompositeAndRule(rules); + } + + public static IRateLimitRule CreateCompositeOrRule(params IRateLimitRule[] rules) + { + return new CompositeOrRule(rules); + } + + public static IRateLimitRule CreateRegionRules() + { + // set region rules + var usRule = new FixedWindowRule(10, TimeSpan.FromMinutes(1)); + var euRule = new TokenBucketRule(3, 3/60.0); + + // create region mapping + var regionRules = new Dictionary + { + { "US", usRule }, + { "EU", euRule } + }; + + // create region rules + return CreateRegionBasedRule(regionRules, GetRegion); + } + + private static string GetRegion(string token) + { + if (token.StartsWith("EU")) + { + return "EU"; + } + return "US"; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Controllers/OrdersController.cs b/RateLimiter/Controllers/OrdersController.cs new file mode 100644 index 00000000..554538d8 --- /dev/null +++ b/RateLimiter/Controllers/OrdersController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using System; + +namespace RateLimiter.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class OrdersController : ControllerBase + { + [HttpGet] + public IActionResult Get() + { + return Ok(new + { + message = "Orders API request successful!!", + timestamp = DateTime.UtcNow + }); + } + + [HttpGet("item")] + public IActionResult GetItem() + { + return Ok(new + { + message = "Orders item API request successful!!", + timestamp = DateTime.UtcNow + }); + } + } +} \ No newline at end of file diff --git a/RateLimiter/Controllers/TestController.cs b/RateLimiter/Controllers/TestController.cs new file mode 100644 index 00000000..6ae0cf8e --- /dev/null +++ b/RateLimiter/Controllers/TestController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using System; + +namespace RateLimiter.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class TestController : ControllerBase + { + [HttpGet] + public IActionResult Get() + { + return Ok(new + { + message = "API request successful!!", + timestamp = DateTime.UtcNow + }); + } + + [HttpGet("slow")] + public IActionResult GetSlow() + { + // simulate a slow endpoint + System.Threading.Thread.Sleep(500); + return Ok(new + { + message = "Slow API request successful", + timestamp = DateTime.UtcNow + }); + } + } +} \ No newline at end of file diff --git a/RateLimiter/Controllers/UsersController.cs b/RateLimiter/Controllers/UsersController.cs new file mode 100644 index 00000000..4c3b5ef9 --- /dev/null +++ b/RateLimiter/Controllers/UsersController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using System; + +namespace RateLimiter.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class UsersController : ControllerBase + { + [HttpGet] + public IActionResult Get() + { + return Ok(new + { + message = "Users API request successful!!", + timestamp = DateTime.UtcNow + }); + } + } +} \ No newline at end of file diff --git a/RateLimiter/Core/RateLimiter.cs b/RateLimiter/Core/RateLimiter.cs new file mode 100644 index 00000000..caf2592b --- /dev/null +++ b/RateLimiter/Core/RateLimiter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using RateLimiter.Core.Rules; + +namespace RateLimiter.Core +{ + public class RateLimiter +{ + private readonly ConcurrentDictionary _resourceRules = new(); + + // configure a rule for a resource + public void ConfigureResource(string resourceId, IRateLimitRule rule) + { + _resourceRules[resourceId] = rule; + } + + // check if a request should be allowed + public bool IsRequestAllowed(string clientToken, string resourceId) + { + // if no rule for this resource, allow the request + if (!_resourceRules.TryGetValue(resourceId, out var rule)) { + return true; + } + + // check the rule + return rule.IsAllowed(clientToken, resourceId); + } +} +} \ No newline at end of file diff --git a/RateLimiter/Core/Rules/CompositeAndRule.cs b/RateLimiter/Core/Rules/CompositeAndRule.cs new file mode 100644 index 00000000..64f63e61 --- /dev/null +++ b/RateLimiter/Core/Rules/CompositeAndRule.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Routing; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Core.Rules +{ + public class CompositeAndRule : IRateLimitRule + { + private readonly List _rules; + + public CompositeAndRule(params IRateLimitRule[] rules) + { + _rules = rules.ToList(); + } + + public bool IsAllowed(string clientToken, string resourceId) + { + // all rules must allow the request + return _rules.All(rule => rule.IsAllowed(clientToken, resourceId)); + } + } +} \ No newline at end of file diff --git a/RateLimiter/Core/Rules/CompositeOrRule.cs b/RateLimiter/Core/Rules/CompositeOrRule.cs new file mode 100644 index 00000000..ac28a641 --- /dev/null +++ b/RateLimiter/Core/Rules/CompositeOrRule.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Routing; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Core.Rules +{ + public class CompositeOrRule : IRateLimitRule + { + private readonly List _rules; + + public CompositeOrRule(params IRateLimitRule[] rules) + { + _rules = rules.ToList(); + } + + public bool IsAllowed(string clientToken, string resourceId) + { + // at least one rule must allow the request + return _rules.Any(rule => rule.IsAllowed(clientToken, resourceId)); + } + } +} \ 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..24d51169 --- /dev/null +++ b/RateLimiter/Core/Rules/FixedWindowRule.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Routing; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Core.Rules +{ + public class FixedWindowRule : IRateLimitRule + { + private readonly int _maxRequests; + private readonly TimeSpan _windowSize; + private readonly ConcurrentDictionary> _requestTimestamps = new(); + + public FixedWindowRule(int maxRequests, TimeSpan windowSize) + { + _maxRequests = maxRequests; + _windowSize = windowSize; + } + + public bool IsAllowed(string clientToken, string resourceId) + { + string key = $"{clientToken}:{resourceId}"; + var now = DateTime.UtcNow; + + // get or create timestamp list + var timestamps = _requestTimestamps.GetOrAdd(key, _ => + { + return new List(); + }); + + // remove expired timestamps + lock (timestamps) + { + timestamps.RemoveAll(time => now - time > _windowSize); + + // check if under limit + if (timestamps.Count < _maxRequests) + { + timestamps.Add(now); + return true; + } + + return false; + } + } + } +} \ No newline at end of file diff --git a/RateLimiter/Core/Rules/IRateLimitRule.cs b/RateLimiter/Core/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..4aa16e0e --- /dev/null +++ b/RateLimiter/Core/Rules/IRateLimitRule.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Routing; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Core.Rules +{ + public interface IRateLimitRule + { + bool IsAllowed(string clientToken, string resourceId); + } +} \ No newline at end of file diff --git a/RateLimiter/Core/Rules/RegionBasedRule.cs b/RateLimiter/Core/Rules/RegionBasedRule.cs new file mode 100644 index 00000000..34d637c2 --- /dev/null +++ b/RateLimiter/Core/Rules/RegionBasedRule.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Routing; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Core.Rules +{ + public class RegionBasedRule : IRateLimitRule + { + private readonly Dictionary _regionRules; + private readonly Func _regionExtractor; + private readonly IRateLimitRule _defaultRule; + + public RegionBasedRule(Dictionary regionRules, Func regionExtractor, IRateLimitRule defaultRule = null) + { + _regionRules = regionRules; + _regionExtractor = regionExtractor; + _defaultRule = defaultRule; + } + + public bool IsAllowed(string clientToken, string resourceId) + { + // get region + var region = _regionExtractor(clientToken); + + // get the rule for this region else use default + if (_regionRules.TryGetValue(region, out var rule)) + { + bool result = rule.IsAllowed(clientToken, resourceId); + return result; + } + + // if no rule for region and no default, allow request + if (_defaultRule != null) + { + return _defaultRule.IsAllowed(clientToken, resourceId); + } + + return true; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Core/Rules/TokenBucketRule.cs b/RateLimiter/Core/Rules/TokenBucketRule.cs new file mode 100644 index 00000000..08269875 --- /dev/null +++ b/RateLimiter/Core/Rules/TokenBucketRule.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Routing; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Core.Rules +{ + public class TokenBucketRule : IRateLimitRule + { + private readonly int _capacity; + private readonly double _refillRate; // tokens per second + private readonly ConcurrentDictionary _buckets = new(); + + private class BucketState + { + public double Tokens { get; set; } + public DateTime LastRefillTime { get; set; } + } + + public TokenBucketRule(int capacity, double refillRate) + { + _capacity = capacity; + _refillRate = refillRate; + } + + public bool IsAllowed(string clientToken, string resourceId) + { + // create a unique key combining client & resource + string key = $"{clientToken}:{resourceId}"; + + // get or create bucket for this client/resource + var bucket = _buckets.GetOrAdd(key, val => new BucketState + { + Tokens = _capacity, + LastRefillTime = DateTime.UtcNow + }); + + // refill tokens based on time elapsed + RefillTokens(bucket); + + // check if we have tokens to consume + if (bucket.Tokens >= 1) + { + bucket.Tokens -= 1; + return true; + } + + return false; + } + + private void RefillTokens(BucketState bucket) + { + var now = DateTime.UtcNow; + var elapsed = (now - bucket.LastRefillTime).TotalSeconds; + if (elapsed > 0) + { + // add tokens based on elapsed time + var tokensToAdd = elapsed * _refillRate; + + // cap at max capacity + bucket.Tokens = Math.Min(_capacity, bucket.Tokens + tokensToAdd); + bucket.LastRefillTime = now; + } + } + } +} \ No newline at end of file diff --git a/RateLimiter/Examples/RateLimiterExamples.cs b/RateLimiter/Examples/RateLimiterExamples.cs new file mode 100644 index 00000000..dd5729a9 --- /dev/null +++ b/RateLimiter/Examples/RateLimiterExamples.cs @@ -0,0 +1,27 @@ +using System; +using RateLimiter.Core; +using RateLimiter.Core.Rules; +using RateLimiter.Builders; + +namespace RateLimiter.Examples +{ + public static class RateLimiterExamples + { + public static Core.RateLimiter CreateExampleRateLimiter() + { + var rateLimiter = new Core.RateLimiter(); + + // example configurations + rateLimiter.ConfigureResource("/api/test", RuleBuilder.CreateTokenBucketRule(3, 3/60.0)); + rateLimiter.ConfigureResource("/api/orders", RuleBuilder.CreateFixedWindowRule(10, TimeSpan.FromMinutes(1))); + rateLimiter.ConfigureResource("/api/orders/item", RuleBuilder.CreateRegionRules()); + + // composite rule examples + var rule1 = new TokenBucketRule(1000, 1000/3600.0); + var rule2 = new FixedWindowRule(5, TimeSpan.FromSeconds(10)); + rateLimiter.ConfigureResource("/api/users", new CompositeAndRule(rule1, rule2)); + + return rateLimiter; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Middleware/RateLimitingMiddleware.cs b/RateLimiter/Middleware/RateLimitingMiddleware.cs new file mode 100644 index 00000000..6ab151a3 --- /dev/null +++ b/RateLimiter/Middleware/RateLimitingMiddleware.cs @@ -0,0 +1,62 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using RateLimiter.Core; +using System.Collections.Generic; +using System; + +namespace RateLimiter.Middleware +{ + public class RateLimitingMiddleware + { + private readonly RequestDelegate _next; + private readonly Core.RateLimiter _rateLimiter; + + public RateLimitingMiddleware(RequestDelegate next, Core.RateLimiter rateLimiter) + { + _next = next; + _rateLimiter = rateLimiter; + } + + public async Task InvokeAsync(HttpContext context) + { + // get client token + string clientToken = GetClientToken(context); + + // get resource identifier + string resourceId = context.Request.Path; + + // check if request is allowed + if (_rateLimiter.IsRequestAllowed(clientToken, resourceId)) + { + // continue to next middleware + await _next(context); + } + else + { + // request is rate limited + context.Response.StatusCode = 429; // Too Many Requests! + context.Response.Headers.Append("Retry-After", "60"); // suggest retry after 60 seconds + await context.Response.WriteAsync("Rate limit exceeded..please go away"); + } + } + + private string GetClientToken(HttpContext context) + { + // check for a region header + string region = "US"; // default + if (context.Request.Headers.TryGetValue("X-Region", out var regionHeader)) + { + region = regionHeader; + } + + // get from API key header + if (context.Request.Headers.TryGetValue("X-API-Key", out var apiKey)) + { + return $"{region}-{apiKey}"; + } + + // or use IP address + return $"{region}-{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}"; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Program.cs b/RateLimiter/Program.cs new file mode 100644 index 00000000..43794ff8 --- /dev/null +++ b/RateLimiter/Program.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace RateLimiter +{ + public class Program + { + public static void Main(string[] args) + { + using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole()); + ILogger logger = factory.CreateLogger(); + logger.LogInformation("Hi! Starting up Rate-Limiter"); + + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} \ No newline at end of file diff --git a/RateLimiter/Properties/launchSettings.json b/RateLimiter/Properties/launchSettings.json new file mode 100644 index 00000000..241feb74 --- /dev/null +++ b/RateLimiter/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "RateLimiter": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/test", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..fa23dc5e 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -1,7 +1,19 @@ - + - net6.0 + net8.0 latest enable + RateLimiter + Exe - \ No newline at end of file + + + + + + + + + + + \ No newline at end of file diff --git a/RateLimiter/Startup.cs b/RateLimiter/Startup.cs new file mode 100644 index 00000000..3206715b --- /dev/null +++ b/RateLimiter/Startup.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using RateLimiter.Core; +using RateLimiter.Core.Rules; +using RateLimiter.Middleware; +using RateLimiter.Examples; +using Microsoft.AspNetCore.Hosting; + +namespace RateLimiter +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + + // register rate limiter + services.AddSingleton(RateLimiterExamples.CreateExampleRateLimiter()); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + // add middleware + app.UseMiddleware(); + + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} \ No newline at end of file diff --git a/RateLimiter/appsettings.Development.json b/RateLimiter/appsettings.Development.json new file mode 100644 index 00000000..ff66ba6b --- /dev/null +++ b/RateLimiter/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/RateLimiter/appsettings.json b/RateLimiter/appsettings.json new file mode 100644 index 00000000..4d566948 --- /dev/null +++ b/RateLimiter/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} From 809cde935a27f481d6b31bab8d64b4257c6c86c2 Mon Sep 17 00:00:00 2001 From: Rachel Beneroff Date: Fri, 18 Apr 2025 14:36:32 -0700 Subject: [PATCH 2/3] update readme --- README.md | 26 ++++++++++---------------- RateLimiter/Builders/RuleBuilder.cs | 2 +- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 47e73daa..0c608072 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -**Rate-limiting pattern** +***Rate-limiting pattern*** Rate limiting involves restricting the number of requests that a client can make. A client is identified with an access token, which is used for every request to a resource. @@ -8,20 +8,14 @@ The client makes an API call to a particular resource; the server checks whether If the request is within the limit, then the request goes through. Otherwise, the API call is restricted. -Some examples of request-limiting rules (you could imagine any others) -* X requests per timespan; -* a certain timespan has passed since the last call; -* For US-based tokens, we use X requests per timespan; for EU-based tokens, a certain timespan has passed since the last call. +**To run the examples:** +run `dotnet run` in RateLimiter -The goal is to design a class(-es) that manages each API resource's rate limits by a set of provided *configurable and extendable* rules. For example, for one resource, you could configure the limiter to use Rule A; for another one - Rule B; for a third one - both A + B, etc. Any combination of rules should be possible; keep this fact in mind when designing the classes. +Working example URLs: +- https://localhost:5001/api/test (token bucket rule) +- https://localhost:5001/api/orders (fixed window rule) +- https://localhost:5001/api/orders/item (region based rule) +- https://localhost:5001/api/users (composite rule) -We're more interested in the design itself than in some intelligent and tricky rate-limiting algorithm. There is no need to use a database (in-memory storage is fine) or any web framework. Do not waste time on preparing complex environment, reusable class library covered by a set of tests is more than enough. - -There is a Test Project set up for you to use. However, you are welcome to create your own test project and use whatever test runner you like. - -You are welcome to ask any questions regarding the requirements—treat us as product owners, analysts, or whoever knows the business. -If you have any questions or concerns, please submit them as a [GitHub issue](https://github.com/crexi-dev/rate-limiter/issues). - -You should [fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) the project and [create a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) named as `FirstName LastName` once you are finished. - -Good luck! +**To run tests:** +run `dotnet test` in RateLimiter.Tests \ No newline at end of file diff --git a/RateLimiter/Builders/RuleBuilder.cs b/RateLimiter/Builders/RuleBuilder.cs index 6d6609fd..f7522b9a 100644 --- a/RateLimiter/Builders/RuleBuilder.cs +++ b/RateLimiter/Builders/RuleBuilder.cs @@ -34,7 +34,7 @@ public static IRateLimitRule CreateCompositeOrRule(params IRateLimitRule[] rules public static IRateLimitRule CreateRegionRules() { // set region rules - var usRule = new FixedWindowRule(10, TimeSpan.FromMinutes(1)); + var usRule = new FixedWindowRule(5, TimeSpan.FromMinutes(1)); var euRule = new TokenBucketRule(3, 3/60.0); // create region mapping From ca3bd51ee3bbcf88dcd045587109c2a5e4b3f32e Mon Sep 17 00:00:00 2001 From: Rachel Beneroff Date: Fri, 18 Apr 2025 14:59:44 -0700 Subject: [PATCH 3/3] update comments --- RateLimiter.Tests/RateLimiterTest.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 1150b989..7d56da4b 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -16,7 +16,7 @@ public void TestNoRulesConfigured() string resourceId = "api/no-rule"; // should allow all requests when no rule is configured - for (int i = 0; i < 10; i++) + for (int i = 0; i < 12; i++) { Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, $"Request {i+1} should be allowed when no rule is configured"); @@ -34,7 +34,6 @@ public void TestUsesCorrectRule() string clientToken = "test-client"; string resourceId = "api/test"; - // should follow the configured rule's behavior Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, "First request should be allowed"); Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.True, "Second request should be allowed"); Assert.That(rateLimiter.IsRequestAllowed(clientToken, resourceId), Is.False, "Third request should be denied"); @@ -51,11 +50,11 @@ public void TestMultipleResources() string clientToken = "test-client"; - // resource 1 should allow 1 request + // should allow 1 request Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource1"), Is.True, "First request to resource1 should be allowed"); Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource1"), Is.False, "Second request to resource1 should be denied"); - // resource 2 should allow 2 requests + // should allow 2 requests Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.True, "First request to resource2 should be allowed"); Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.True, "Second request to resource2 should be allowed"); Assert.That(rateLimiter.IsRequestAllowed(clientToken, "api/resource2"), Is.False, "Third request to resource2 should be denied");