From dd8f6a7a70d403867b0b7056f1f2185e7e9966a4 Mon Sep 17 00:00:00 2001 From: Justin Nelson Date: Thu, 13 Mar 2025 15:35:35 -0400 Subject: [PATCH 1/2] initial commit --- README.md | 85 ++++++++++++++------- RateLimiter.Tests/RateLimiterTest.cs | 106 +++++++++++++++++++++++++-- RateLimiter/RateLimiter.cs | 20 +++++ RateLimiter/Rules/FixedWindowRule.cs | 45 ++++++++++++ RateLimiter/Rules/IRateLimitRule.cs | 6 ++ instructions.md | 27 +++++++ 6 files changed, 256 insertions(+), 33 deletions(-) create mode 100644 RateLimiter/RateLimiter.cs create mode 100644 RateLimiter/Rules/FixedWindowRule.cs create mode 100644 RateLimiter/Rules/IRateLimitRule.cs create mode 100644 instructions.md diff --git a/README.md b/README.md index 47e73daa..b27a264a 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,58 @@ -**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. -To prevent abuse of the server, APIs enforce rate-limiting techniques. -The rate-limiting application can decide whether to allow the request based on the client. -The client makes an API call to a particular resource; the server checks whether the request for this client is within the limit. -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. - -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. - -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! +# Rate Limiter - Fixed Window Algorithm + +## Overview +This project implements a **Fixed Window Rate Limiter** to control API request rates per client within a given time window. It ensures **clean code, concurrency support, and scalability** while adhering to best coding practices. + +## Features +- **Fixed Window Algorithm**: Limits requests within a fixed time window. +- **Concurrency & Scalability**: Supports multiple clients independently. +- **Extensibility**: Allows multiple rate-limiting rules. +- **Thread Safety**: Ensures correct behavior under concurrent requests. + +## Installation & Usage +### **Prerequisites** +- .NET 6.0+ +- NUnit for running tests + +### **Running the Application** +Clone the repository and build the solution: +```sh +cd rate-limiter +dotnet build +``` + +## Test Cases +The test suite ensures **correct request blocking, time window resets, and concurrency handling**. + +### **Test Coverage** +✔ Validates **request limits** per client +✔ Ensures **time window resets correctly** +✔ Supports **multiple clients independently** +✔ Handles **concurrent and burst requests** + +### **Test Cases** +| **Test Name** | **Description** | +|--------------|----------------| +| `AllowRequest_AfterTimeWindow_ShouldResetCounter` | Ensures requests are allowed again **after the window resets**. | +| `AllowRequest_WithinLimit_ShouldReturnTrue` | Validates requests **within the allowed limit**. | +| `AllowRequest_ExceedsLimit_ShouldReturnFalse` | Ensures **requests beyond the limit** are blocked. | +| `AllowRequest_MultipleClients_ShouldBeTrackedIndividually` | Confirms **each client has independent rate limits**. | +| `AllowRequest_MultipleRules_AllRulesMustPass` | Tests **multiple rules running together**. | +| `AllowRequest_BurstRequests_ShouldBeBlocked` | Blocks **rapid successive requests**. | +| `AllowRequest_ConcurrentClients_ShouldNotInterfere` | Ensures **one client’s rate limit doesn’t affect another**. | +| `AllowRequest_ShortTimeWindow_ShouldResetQuickly` | Verifies short **time windows expire correctly**. | + +## Running Tests +Run all tests: +```sh +dotnet test +``` +Run a specific test: +```sh +dotnet test --filter "FullyQualifiedName=RateLimiter.Tests.RateLimiterTest.AllowRequest_AfterTimeWindow_ShouldResetCounter" +``` + +## Future Enhancements +- **Sliding Window Rate Limiter** for smoother rate enforcement. +- **Distributed rate limiting** using Redis. +- **Per-endpoint & per-user rules**. diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..3caf442b 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,107 @@ using NUnit.Framework; +using RateLimiter; +using RateLimiter.Rules; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; namespace RateLimiter.Tests; [TestFixture] public class RateLimiterTest { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file + [Test] + public async Task AllowRequest_AfterTimeWindow_ShouldResetCounter() + { + var rateLimiter = new RateLimiter(new List { new FixedWindowRule(1, TimeSpan.FromMilliseconds(500)) }); + + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsFalse(rateLimiter.AllowRequest("client1")); + + await Task.Delay(600); + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + } + + [Test] + public void AllowRequest_WithinLimit_ShouldReturnTrue() + { + var rateLimiter = new RateLimiter(new List { new FixedWindowRule(3, TimeSpan.FromMinutes(1)) }); + + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + } + + [Test] + public void AllowRequest_ExceedsLimit_ShouldReturnFalse() + { + var rateLimiter = new RateLimiter(new List { new FixedWindowRule(2, TimeSpan.FromMinutes(1)) }); + + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsFalse(rateLimiter.AllowRequest("client1")); + } + + [Test] + public void AllowRequest_MultipleClients_ShouldBeTrackedIndividually() + { + var rateLimiter = new RateLimiter(new List { new FixedWindowRule(2, TimeSpan.FromMinutes(1)) }); + + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rateLimiter.AllowRequest("client2")); + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsFalse(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rateLimiter.AllowRequest("client2")); + } + + [Test] + public void AllowRequest_MultipleRules_AllRulesMustPass() + { + var rateLimiter = new RateLimiter(new List + { + new FixedWindowRule(2, TimeSpan.FromMinutes(1)), // Max 2 per minute + new FixedWindowRule(1, TimeSpan.FromSeconds(10)) // Max 1 per 10 sec + }); + + Assert.IsTrue(rateLimiter.AllowRequest("client1")); // First rule allows + Assert.IsFalse(rateLimiter.AllowRequest("client1")); // Second rule blocks + } + + [Test] + public async Task AllowRequest_BurstRequests_ShouldBeBlocked() + { + var rateLimiter = new RateLimiter(new List { new FixedWindowRule(3, TimeSpan.FromSeconds(1)) }); + + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsFalse(rateLimiter.AllowRequest("client1")); // 4th request should be blocked immediately + + await Task.Delay(1100); // Wait for time window to reset + Assert.IsTrue(rateLimiter.AllowRequest("client1")); // Should be allowed after reset + } + + [Test] + public void AllowRequest_ConcurrentClients_ShouldNotInterfere() + { + var rateLimiter = new RateLimiter(new List { new FixedWindowRule(2, TimeSpan.FromSeconds(1)) }); + + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rateLimiter.AllowRequest("client2")); + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsFalse(rateLimiter.AllowRequest("client1")); // Blocked for client1, still open for client2 + Assert.IsTrue(rateLimiter.AllowRequest("client2")); // Client2 should still be allowed + } + + [Test] + public async Task AllowRequest_ShortTimeWindow_ShouldResetQuickly() + { + var rateLimiter = new RateLimiter(new List { new FixedWindowRule(1, TimeSpan.FromMilliseconds(200)) }); + + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsFalse(rateLimiter.AllowRequest("client1")); + + await Task.Delay(250); // Wait beyond the reset window + Assert.IsTrue(rateLimiter.AllowRequest("client1")); + } +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..a4ee96f0 --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Linq; +using RateLimiter.Rules; + +namespace RateLimiter; + +public class RateLimiter +{ + private readonly List _rules; + + public RateLimiter(IEnumerable rules) + { + _rules = rules.ToList(); + } + + public bool AllowRequest(string clientId) + { + return _rules.All(rule => rule.AllowRequest(clientId)); + } +} diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs new file mode 100644 index 00000000..753268c3 --- /dev/null +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter.Rules; + +public class FixedWindowRule : IRateLimitRule +{ + private readonly int _maxRequests; + private readonly long _windowSizeMs; + private readonly Dictionary _requestCounts = new(); + private readonly Dictionary _windowStartTimes = new(); + + public FixedWindowRule(int maxRequests, TimeSpan windowSize) + { + _maxRequests = maxRequests; + _windowSizeMs = (long)windowSize.TotalMilliseconds; + } + + public bool AllowRequest(string clientId) + { + long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + if (!_windowStartTimes.ContainsKey(clientId)) + { + _windowStartTimes[clientId] = currentTime; + _requestCounts[clientId] = 0; + } + + long windowStartTime = _windowStartTimes[clientId]; + + if (currentTime - windowStartTime >= _windowSizeMs) + { + _windowStartTimes[clientId] = currentTime; + _requestCounts[clientId] = 0; + } + + if (_requestCounts[clientId] < _maxRequests) + { + _requestCounts[clientId]++; + return true; + } + + return false; + } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..20fbe4e2 --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,6 @@ +namespace RateLimiter.Rules; + +public interface IRateLimitRule +{ + bool AllowRequest(string clientId); +} \ No newline at end of file diff --git a/instructions.md b/instructions.md new file mode 100644 index 00000000..47e73daa --- /dev/null +++ b/instructions.md @@ -0,0 +1,27 @@ +**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. +To prevent abuse of the server, APIs enforce rate-limiting techniques. +The rate-limiting application can decide whether to allow the request based on the client. +The client makes an API call to a particular resource; the server checks whether the request for this client is within the limit. +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. + +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. + +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! From 614f567a835bfc4d46f4b3ac2600f87ec4519af8 Mon Sep 17 00:00:00 2001 From: Justin Nelson Date: Thu, 13 Mar 2025 16:37:20 -0400 Subject: [PATCH 2/2] feat(rate-limiter): Added sliding window, multi-client support --- README.md | 85 +++---- RateLimiter.Tests/RateLimiterTest.cs | 229 ++++++++++++++---- RateLimiter/Models/RateLimitEntry.cs | 9 + RateLimiter/Models/RateLimitResult.cs | 9 + RateLimiter/Models/ResourceRateLimitConfig.cs | 10 + RateLimiter/RateLimiter.cs | 14 +- RateLimiter/RateLimiterManager.cs | 35 +++ RateLimiter/Rules/FixedWindowRule.cs | 42 ++-- RateLimiter/Rules/IRateLimitRule.cs | 6 +- RateLimiter/Rules/SlidingWindowRule.cs | 41 ++++ instructions.md | 27 --- 11 files changed, 337 insertions(+), 170 deletions(-) create mode 100644 RateLimiter/Models/RateLimitEntry.cs create mode 100644 RateLimiter/Models/RateLimitResult.cs create mode 100644 RateLimiter/Models/ResourceRateLimitConfig.cs create mode 100644 RateLimiter/RateLimiterManager.cs create mode 100644 RateLimiter/Rules/SlidingWindowRule.cs delete mode 100644 instructions.md diff --git a/README.md b/README.md index b27a264a..083d43fa 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,27 @@ -# Rate Limiter - Fixed Window Algorithm - -## Overview -This project implements a **Fixed Window Rate Limiter** to control API request rates per client within a given time window. It ensures **clean code, concurrency support, and scalability** while adhering to best coding practices. - -## Features -- **Fixed Window Algorithm**: Limits requests within a fixed time window. -- **Concurrency & Scalability**: Supports multiple clients independently. -- **Extensibility**: Allows multiple rate-limiting rules. -- **Thread Safety**: Ensures correct behavior under concurrent requests. - -## Installation & Usage -### **Prerequisites** -- .NET 6.0+ -- NUnit for running tests - -### **Running the Application** -Clone the repository and build the solution: -```sh -cd rate-limiter -dotnet build -``` - -## Test Cases -The test suite ensures **correct request blocking, time window resets, and concurrency handling**. - -### **Test Coverage** -✔ Validates **request limits** per client -✔ Ensures **time window resets correctly** -✔ Supports **multiple clients independently** -✔ Handles **concurrent and burst requests** - -### **Test Cases** -| **Test Name** | **Description** | -|--------------|----------------| -| `AllowRequest_AfterTimeWindow_ShouldResetCounter` | Ensures requests are allowed again **after the window resets**. | -| `AllowRequest_WithinLimit_ShouldReturnTrue` | Validates requests **within the allowed limit**. | -| `AllowRequest_ExceedsLimit_ShouldReturnFalse` | Ensures **requests beyond the limit** are blocked. | -| `AllowRequest_MultipleClients_ShouldBeTrackedIndividually` | Confirms **each client has independent rate limits**. | -| `AllowRequest_MultipleRules_AllRulesMustPass` | Tests **multiple rules running together**. | -| `AllowRequest_BurstRequests_ShouldBeBlocked` | Blocks **rapid successive requests**. | -| `AllowRequest_ConcurrentClients_ShouldNotInterfere` | Ensures **one client’s rate limit doesn’t affect another**. | -| `AllowRequest_ShortTimeWindow_ShouldResetQuickly` | Verifies short **time windows expire correctly**. | - -## Running Tests -Run all tests: -```sh -dotnet test -``` -Run a specific test: -```sh -dotnet test --filter "FullyQualifiedName=RateLimiter.Tests.RateLimiterTest.AllowRequest_AfterTimeWindow_ShouldResetCounter" -``` - -## Future Enhancements -- **Sliding Window Rate Limiter** for smoother rate enforcement. -- **Distributed rate limiting** using Redis. -- **Per-endpoint & per-user rules**. +**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. +To prevent abuse of the server, APIs enforce rate-limiting techniques. +The rate-limiting application can decide whether to allow the request based on the client. +The client makes an API call to a particular resource; the server checks whether the request for this client is within the limit. +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. + +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. + +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! diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 3caf442b..c36214b2 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,6 +1,7 @@ using NUnit.Framework; -using RateLimiter; +using RateLimiter.Models; using RateLimiter.Rules; +using RateLimiter.Services; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -8,100 +9,224 @@ namespace RateLimiter.Tests; [TestFixture] -public class RateLimiterTest +public class RateLimiterTests { [Test] - public async Task AllowRequest_AfterTimeWindow_ShouldResetCounter() + public void FixedWindowRule_ShouldLimitRequests() { - var rateLimiter = new RateLimiter(new List { new FixedWindowRule(1, TimeSpan.FromMilliseconds(500)) }); + var rule = new FixedWindowRule(2, TimeSpan.FromSeconds(5)); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsFalse(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed); + } - await Task.Delay(600); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); + [Test] + public void SlidingWindowRule_ShouldLimitRequests() + { + var rule = new SlidingWindowRule(3, TimeSpan.FromSeconds(5)); + + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed); + } + + [Test] + public void RateLimiterManager_ShouldApplyMultipleRules() + { + var manager = new RateLimiterManager(new List + { + new ResourceRateLimitConfig + { + Resource = "/api/test", + Rules = new List + { + new FixedWindowRule(2, TimeSpan.FromSeconds(5)), + new SlidingWindowRule(1, TimeSpan.FromSeconds(2)) + } + } + }); + + Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/test").IsAllowed); + Assert.IsFalse(manager.IsRequestAllowed("client1", "/api/test").IsAllowed); } [Test] - public void AllowRequest_WithinLimit_ShouldReturnTrue() + public void MultipleClients_ShouldBeTrackedIndependently() { - var rateLimiter = new RateLimiter(new List { new FixedWindowRule(3, TimeSpan.FromMinutes(1)) }); + var rule = new FixedWindowRule(2, TimeSpan.FromSeconds(5)); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("client2").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("client2").IsAllowed); } [Test] - public void AllowRequest_ExceedsLimit_ShouldReturnFalse() + public void MultipleResources_ShouldHaveSeparateLimits() { - var rateLimiter = new RateLimiter(new List { new FixedWindowRule(2, TimeSpan.FromMinutes(1)) }); + var manager = new RateLimiterManager(new List + { + new ResourceRateLimitConfig + { + Resource = "/api/resource1", + Rules = new List { new FixedWindowRule(2, TimeSpan.FromSeconds(5)) } + }, + new ResourceRateLimitConfig + { + Resource = "/api/resource2", + Rules = new List { new FixedWindowRule(1, TimeSpan.FromSeconds(5)) } + } + }); + + Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/resource1").IsAllowed); + Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/resource1").IsAllowed); + Assert.IsFalse(manager.IsRequestAllowed("client1", "/api/resource1").IsAllowed); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsFalse(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/resource2").IsAllowed); + Assert.IsFalse(manager.IsRequestAllowed("client1", "/api/resource2").IsAllowed); } [Test] - public void AllowRequest_MultipleClients_ShouldBeTrackedIndividually() + public async Task RequestsShouldResetAfterTimeWindow() { - var rateLimiter = new RateLimiter(new List { new FixedWindowRule(2, TimeSpan.FromMinutes(1)) }); + var rule = new FixedWindowRule(1, TimeSpan.FromMilliseconds(500)); + + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed); + + await Task.Delay(600); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsTrue(rateLimiter.AllowRequest("client2")); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsFalse(rateLimiter.AllowRequest("client1")); - Assert.IsTrue(rateLimiter.AllowRequest("client2")); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); } [Test] - public void AllowRequest_MultipleRules_AllRulesMustPass() + public void FixedAndSlidingWindowTogether_ShouldApplyStricterRule() { - var rateLimiter = new RateLimiter(new List + var manager = new RateLimiterManager(new List { - new FixedWindowRule(2, TimeSpan.FromMinutes(1)), // Max 2 per minute - new FixedWindowRule(1, TimeSpan.FromSeconds(10)) // Max 1 per 10 sec + new ResourceRateLimitConfig + { + Resource = "/api/strict", + Rules = new List + { + new FixedWindowRule(3, TimeSpan.FromSeconds(10)), + new SlidingWindowRule(1, TimeSpan.FromSeconds(2)) + } + } }); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); // First rule allows - Assert.IsFalse(rateLimiter.AllowRequest("client1")); // Second rule blocks + Assert.IsTrue(manager.IsRequestAllowed("client1", "/api/strict").IsAllowed); + Assert.IsFalse(manager.IsRequestAllowed("client1", "/api/strict").IsAllowed); + } + + [Test] + public async Task SlidingWindowRule_ShouldAllowNewRequestsAfterOldExpire() + { + var rule = new SlidingWindowRule(3, TimeSpan.FromSeconds(5)); + + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + Assert.IsFalse(rule.IsRequestAllowed("client1").IsAllowed); + + await Task.Delay(5100); + + Assert.IsTrue(rule.IsRequestAllowed("client1").IsAllowed); + } + + [Test] + public void InsanelyHighRequestVolume_ShouldFailQuickly() + { + var rule = new FixedWindowRule(10, TimeSpan.FromSeconds(1)); + + for (int i = 0; i < 10; i++) + { + Assert.IsTrue(rule.IsRequestAllowed("crazy_user_1").IsAllowed); + } + + Assert.IsFalse(rule.IsRequestAllowed("crazy_user_1").IsAllowed); + } + + [Test] + public async Task TimeWindowBoundary_ShouldResetExactlyOnTime() + { + var rule = new FixedWindowRule(2, TimeSpan.FromMilliseconds(500)); + + Assert.IsTrue(rule.IsRequestAllowed("boundary_user").IsAllowed); + Assert.IsTrue(rule.IsRequestAllowed("boundary_user").IsAllowed); + Assert.IsFalse(rule.IsRequestAllowed("boundary_user").IsAllowed); + + await Task.Delay(500); + + Assert.IsTrue(rule.IsRequestAllowed("boundary_user").IsAllowed); } [Test] - public async Task AllowRequest_BurstRequests_ShouldBeBlocked() + public void MultipleUsersAndIPs_ShouldNotInterfere() { - var rateLimiter = new RateLimiter(new List { new FixedWindowRule(3, TimeSpan.FromSeconds(1)) }); + var rule = new FixedWindowRule(5, TimeSpan.FromSeconds(1)); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsFalse(rateLimiter.AllowRequest("client1")); // 4th request should be blocked immediately + var users = new List(); + for (int i = 0; i < 1000; i++) + { + users.Add($"User{i}_IP_192.168.1.{i % 255}"); + } + + foreach (var user in users) + { + Assert.IsTrue(rule.IsRequestAllowed(user).IsAllowed); + } - await Task.Delay(1100); // Wait for time window to reset - Assert.IsTrue(rateLimiter.AllowRequest("client1")); // Should be allowed after reset + Assert.IsTrue(rule.IsRequestAllowed("User999_IP_192.168.1.1").IsAllowed); } [Test] - public void AllowRequest_ConcurrentClients_ShouldNotInterfere() + public void MultipleRulesConflict_ShouldEnforceStrictestRule() { - var rateLimiter = new RateLimiter(new List { new FixedWindowRule(2, TimeSpan.FromSeconds(1)) }); + var manager = new RateLimiterManager(new List + { + new ResourceRateLimitConfig + { + Resource = "/api/conflict", + Rules = new List + { + new FixedWindowRule(10, TimeSpan.FromSeconds(10)), + new SlidingWindowRule(2, TimeSpan.FromSeconds(5)) + } + } + }); + + var clientKey = "user1_IP_192.168.1.1:/api/conflict"; - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsTrue(rateLimiter.AllowRequest("client2")); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsFalse(rateLimiter.AllowRequest("client1")); // Blocked for client1, still open for client2 - Assert.IsTrue(rateLimiter.AllowRequest("client2")); // Client2 should still be allowed + Assert.IsTrue(manager.IsRequestAllowed(clientKey, "/api/conflict").IsAllowed); + Assert.IsTrue(manager.IsRequestAllowed(clientKey, "/api/conflict").IsAllowed); + + Assert.IsFalse(manager.IsRequestAllowed(clientKey, "/api/conflict").IsAllowed); } [Test] - public async Task AllowRequest_ShortTimeWindow_ShouldResetQuickly() + public void RandomizedClientsAndEndpoints_ShouldAllBeTrackedSeparately() { - var rateLimiter = new RateLimiter(new List { new FixedWindowRule(1, TimeSpan.FromMilliseconds(200)) }); + var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(5)); + + var random = new Random(); + var clients = new HashSet(); + + for (int i = 0; i < 500; i++) + { + var user = $"User{random.Next(1, 100)}"; + var ip = $"192.168.{random.Next(1, 255)}.{random.Next(1, 255)}"; + var endpoint = $"/api/{random.Next(1, 10)}"; + + var clientKey = $"{user}_{ip}:{endpoint}"; + clients.Add(clientKey); - Assert.IsTrue(rateLimiter.AllowRequest("client1")); - Assert.IsFalse(rateLimiter.AllowRequest("client1")); + Assert.IsTrue(rule.IsRequestAllowed(clientKey).IsAllowed); + } - await Task.Delay(250); // Wait beyond the reset window - Assert.IsTrue(rateLimiter.AllowRequest("client1")); + Assert.AreEqual(500, clients.Count); } } diff --git a/RateLimiter/Models/RateLimitEntry.cs b/RateLimiter/Models/RateLimitEntry.cs new file mode 100644 index 00000000..f82837e4 --- /dev/null +++ b/RateLimiter/Models/RateLimitEntry.cs @@ -0,0 +1,9 @@ +using System; + +namespace RateLimiter.Models; + +public class RateLimitEntry +{ + public int Count { get; set; } + public DateTime ResetTime { get; set; } +} diff --git a/RateLimiter/Models/RateLimitResult.cs b/RateLimiter/Models/RateLimitResult.cs new file mode 100644 index 00000000..7dd1eec1 --- /dev/null +++ b/RateLimiter/Models/RateLimitResult.cs @@ -0,0 +1,9 @@ +using System; + +namespace RateLimiter.Models; + +public class RateLimitResult +{ + public bool IsAllowed { get; set; } + public TimeSpan RetryAfter { get; set; } +} diff --git a/RateLimiter/Models/ResourceRateLimitConfig.cs b/RateLimiter/Models/ResourceRateLimitConfig.cs new file mode 100644 index 00000000..a0cfe825 --- /dev/null +++ b/RateLimiter/Models/ResourceRateLimitConfig.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using RateLimiter.Rules; + +namespace RateLimiter.Models; + +public class ResourceRateLimitConfig +{ + public string? Resource { get; set; } + public List Rules { get; set; } = new(); +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs index a4ee96f0..8a6ae24e 100644 --- a/RateLimiter/RateLimiter.cs +++ b/RateLimiter/RateLimiter.cs @@ -1,20 +1,18 @@ -using System.Collections.Generic; -using System.Linq; -using RateLimiter.Rules; +using RateLimiter.Services; namespace RateLimiter; public class RateLimiter { - private readonly List _rules; + private readonly RateLimiterManager _rateLimiterManager; - public RateLimiter(IEnumerable rules) + public RateLimiter(RateLimiterManager rateLimiterManager) { - _rules = rules.ToList(); + _rateLimiterManager = rateLimiterManager; } - public bool AllowRequest(string clientId) + public bool AllowRequest(string clientId, string resource) { - return _rules.All(rule => rule.AllowRequest(clientId)); + return _rateLimiterManager.IsRequestAllowed(clientId, resource).IsAllowed; } } diff --git a/RateLimiter/RateLimiterManager.cs b/RateLimiter/RateLimiterManager.cs new file mode 100644 index 00000000..68493266 --- /dev/null +++ b/RateLimiter/RateLimiterManager.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RateLimiter.Models; + +namespace RateLimiter.Services; + +public class RateLimiterManager +{ + private readonly Dictionary _resourceLimits; + + public RateLimiterManager(IEnumerable resourceLimits) + { + _resourceLimits = resourceLimits.ToDictionary(r => r.Resource ?? string.Empty); + } + + public RateLimitResult IsRequestAllowed(string clientId, string resource) + { + if (!_resourceLimits.TryGetValue(resource, out var config)) + { + return new RateLimitResult { IsAllowed = false, RetryAfter = TimeSpan.Zero }; + } + + foreach (var rule in config.Rules) + { + var result = rule.IsRequestAllowed(clientId); + if (!result.IsAllowed) + { + return result; + } + } + + return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + } +} diff --git a/RateLimiter/Rules/FixedWindowRule.cs b/RateLimiter/Rules/FixedWindowRule.cs index 753268c3..066106c5 100644 --- a/RateLimiter/Rules/FixedWindowRule.cs +++ b/RateLimiter/Rules/FixedWindowRule.cs @@ -1,45 +1,41 @@ using System; using System.Collections.Generic; +using RateLimiter.Models; +using RateLimiter.Rules; namespace RateLimiter.Rules; public class FixedWindowRule : IRateLimitRule { private readonly int _maxRequests; - private readonly long _windowSizeMs; - private readonly Dictionary _requestCounts = new(); - private readonly Dictionary _windowStartTimes = new(); + private readonly TimeSpan _windowSize; + private readonly Dictionary _clientRequestCounts = new(); public FixedWindowRule(int maxRequests, TimeSpan windowSize) { _maxRequests = maxRequests; - _windowSizeMs = (long)windowSize.TotalMilliseconds; + _windowSize = windowSize; } - public bool AllowRequest(string clientId) + public RateLimitResult IsRequestAllowed(string clientId) { - long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - if (!_windowStartTimes.ContainsKey(clientId)) + lock (_clientRequestCounts) { - _windowStartTimes[clientId] = currentTime; - _requestCounts[clientId] = 0; - } + DateTime now = DateTime.UtcNow; - long windowStartTime = _windowStartTimes[clientId]; + if (!_clientRequestCounts.TryGetValue(clientId, out var entry) || now >= entry.ResetTime) + { + _clientRequestCounts[clientId] = new RateLimitEntry { Count = 1, ResetTime = now + _windowSize }; + return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + } - if (currentTime - windowStartTime >= _windowSizeMs) - { - _windowStartTimes[clientId] = currentTime; - _requestCounts[clientId] = 0; - } + if (entry.Count < _maxRequests) + { + entry.Count++; + return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + } - if (_requestCounts[clientId] < _maxRequests) - { - _requestCounts[clientId]++; - return true; + return new RateLimitResult { IsAllowed = false, RetryAfter = entry.ResetTime - now }; } - - return false; } } diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs index 20fbe4e2..4b1554cd 100644 --- a/RateLimiter/Rules/IRateLimitRule.cs +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -1,6 +1,8 @@ +using RateLimiter.Models; + namespace RateLimiter.Rules; public interface IRateLimitRule { - bool AllowRequest(string clientId); -} \ No newline at end of file + RateLimitResult IsRequestAllowed(string clientId); +} diff --git a/RateLimiter/Rules/SlidingWindowRule.cs b/RateLimiter/Rules/SlidingWindowRule.cs new file mode 100644 index 00000000..6453554c --- /dev/null +++ b/RateLimiter/Rules/SlidingWindowRule.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RateLimiter.Models; +using RateLimiter.Rules; + +namespace RateLimiter.Rules; + +public class SlidingWindowRule : IRateLimitRule +{ + private readonly int _maxRequests; + private readonly TimeSpan _windowSize; + private readonly Dictionary> _clientRequests = new(); + + public SlidingWindowRule(int maxRequests, TimeSpan windowSize) + { + _maxRequests = maxRequests; + _windowSize = windowSize; + } + + public RateLimitResult IsRequestAllowed(string clientId) + { + lock (_clientRequests) + { + DateTime now = DateTime.UtcNow; + _clientRequests.TryGetValue(clientId, out var timestamps); + + timestamps ??= new List(); + timestamps.RemoveAll(t => now - t > _windowSize); + + if (timestamps.Count < _maxRequests) + { + timestamps.Add(now); + _clientRequests[clientId] = timestamps; + return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + } + + return new RateLimitResult { IsAllowed = false, RetryAfter = timestamps.First() + _windowSize - now }; + } + } +} diff --git a/instructions.md b/instructions.md deleted file mode 100644 index 47e73daa..00000000 --- a/instructions.md +++ /dev/null @@ -1,27 +0,0 @@ -**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. -To prevent abuse of the server, APIs enforce rate-limiting techniques. -The rate-limiting application can decide whether to allow the request based on the client. -The client makes an API call to a particular resource; the server checks whether the request for this client is within the limit. -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. - -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. - -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!