From 9276e08562e7dc90a83975b9aa73a289f81a349b Mon Sep 17 00:00:00 2001 From: JoelHJames21 Date: Thu, 6 Mar 2025 20:13:47 -0500 Subject: [PATCH] Added comments and comprehensive tests --- RateLimiter.Tests/RateLimiterTest.cs | 258 +++++++++++++++++- RateLimiter/CompositeRule.cs | 88 ++++++ RateLimiter/IRateLimitRule.cs | 25 ++ RateLimiter/RateLimiter.cs | 82 ++++++ RateLimiter/RegionalRule.cs | 77 ++++++ RateLimiter/RequestTracker.cs | 96 +++++++ RateLimiter/RequestsPerTimespanRule.cs | 60 ++++ .../TimeIntervalBetweenRequestsRule.cs | 61 +++++ 8 files changed, 741 insertions(+), 6 deletions(-) create mode 100644 RateLimiter/CompositeRule.cs create mode 100644 RateLimiter/IRateLimitRule.cs create mode 100644 RateLimiter/RateLimiter.cs create mode 100644 RateLimiter/RegionalRule.cs create mode 100644 RateLimiter/RequestTracker.cs create mode 100644 RateLimiter/RequestsPerTimespanRule.cs create mode 100644 RateLimiter/TimeIntervalBetweenRequestsRule.cs diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..7194c7ab 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,259 @@ using NUnit.Framework; +using System; +using System.Threading; namespace RateLimiter.Tests; [TestFixture] public class RateLimiterTest { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file + [Test] + public void Example() + { + Assert.That(true, Is.True); + } + + [Test] + public void TimeIntervalBetweenRequestsRule_ShouldAllowFirstRequest() + { + // Arrange + var requestTracker = new RequestTracker(); + var rule = new TimeIntervalBetweenRequestsRule(requestTracker, TimeSpan.FromSeconds(1)); + + // Act + bool isAllowed = rule.IsRequestAllowed("token1", "resource1"); + + // Assert + Assert.That(isAllowed, Is.True); + } + + [Test] + public void TimeIntervalBetweenRequestsRule_ShouldBlockRequestsWithinInterval() + { + // Arrange + var requestTracker = new RequestTracker(); + var rule = new TimeIntervalBetweenRequestsRule(requestTracker, TimeSpan.FromSeconds(1)); + + // Act + requestTracker.RecordRequest("token1", "resource1"); + bool isAllowed = rule.IsRequestAllowed("token1", "resource1"); + + // Assert + Assert.That(isAllowed, Is.False); + } + + [Test] + public void TimeIntervalBetweenRequestsRule_ShouldAllowRequestsAfterInterval() + { + // Arrange + var requestTracker = new RequestTracker(); + var rule = new TimeIntervalBetweenRequestsRule(requestTracker, TimeSpan.FromMilliseconds(50)); + + // Act + requestTracker.RecordRequest("token1", "resource1"); + Thread.Sleep(100); // Wait longer than the interval + bool isAllowed = rule.IsRequestAllowed("token1", "resource1"); + + // Assert + Assert.That(isAllowed, Is.True); + } + + [Test] + public void RequestsPerTimespanRule_ShouldAllowRequestsWithinLimit() + { + // Arrange + var requestTracker = new RequestTracker(); + var rule = new RequestsPerTimespanRule(requestTracker, 3, TimeSpan.FromSeconds(10)); + + // Act + requestTracker.RecordRequest("token1", "resource1"); + requestTracker.RecordRequest("token1", "resource1"); + bool isAllowed = rule.IsRequestAllowed("token1", "resource1"); + + // Assert + Assert.That(isAllowed, Is.True); + } + + [Test] + public void RequestsPerTimespanRule_ShouldBlockRequestsOverLimit() + { + // Arrange + var requestTracker = new RequestTracker(); + var rule = new RequestsPerTimespanRule(requestTracker, 2, TimeSpan.FromSeconds(10)); + + // Act + requestTracker.RecordRequest("token1", "resource1"); + requestTracker.RecordRequest("token1", "resource1"); + bool isAllowed = rule.IsRequestAllowed("token1", "resource1"); + + // Assert + Assert.That(isAllowed, Is.False); + } + + [Test] + public void CompositeRule_And_ShouldRequireAllRulesToPass() + { + // Arrange + var requestTracker = new RequestTracker(); + var rule1 = new RequestsPerTimespanRule(requestTracker, 3, TimeSpan.FromSeconds(10)); + var rule2 = new TimeIntervalBetweenRequestsRule(requestTracker, TimeSpan.FromMilliseconds(50)); + + var compositeRule = new CompositeRule(CompositeRule.LogicalOperator.And); + compositeRule.AddRule(rule1); + compositeRule.AddRule(rule2); + + // Act - First request should pass both rules + bool firstRequest = compositeRule.IsRequestAllowed("token1", "resource1"); + + // Record the request + requestTracker.RecordRequest("token1", "resource1"); + + // Second request should fail the interval rule but pass the count rule + bool secondRequest = compositeRule.IsRequestAllowed("token1", "resource1"); + + // Wait for the interval to pass + Thread.Sleep(100); + + // Third request should now pass both rules again + bool thirdRequest = compositeRule.IsRequestAllowed("token1", "resource1"); + + // Assert + Assert.That(firstRequest, Is.True); + Assert.That(secondRequest, Is.False); + Assert.That(thirdRequest, Is.True); + } + + [Test] + public void CompositeRule_Or_ShouldRequireAnyRuleToPass() + { + // Arrange + var requestTracker = new RequestTracker(); + var rule1 = new RequestsPerTimespanRule(requestTracker, 1, TimeSpan.FromSeconds(10)); + var rule2 = new TimeIntervalBetweenRequestsRule(requestTracker, TimeSpan.FromMilliseconds(50)); + + var compositeRule = new CompositeRule(CompositeRule.LogicalOperator.Or); + compositeRule.AddRule(rule1); + compositeRule.AddRule(rule2); + + // Act - First request should pass both rules + bool firstRequest = compositeRule.IsRequestAllowed("token1", "resource1"); + + // Record the request + requestTracker.RecordRequest("token1", "resource1"); + + // Second request should fail the count rule but pass the interval rule after waiting + Thread.Sleep(100); + bool secondRequest = compositeRule.IsRequestAllowed("token1", "resource1"); + + // Record the second request + requestTracker.RecordRequest("token1", "resource1"); + + // Third request should fail both rules (over count limit and within interval) + bool thirdRequest = compositeRule.IsRequestAllowed("token1", "resource1"); + + // Assert + Assert.That(firstRequest, Is.True); + Assert.That(secondRequest, Is.True); + Assert.That(thirdRequest, Is.False); + } + + [Test] + public void RegionalRule_ShouldApplyDifferentRulesBasedOnRegion() + { + // Arrange + var requestTracker = new RequestTracker(); + + // Strict rule for US region - only 1 request allowed + var usRule = new RequestsPerTimespanRule(requestTracker, 1, TimeSpan.FromSeconds(10)); + + // Lenient rule for EU region - 3 requests allowed + var euRule = new RequestsPerTimespanRule(requestTracker, 3, TimeSpan.FromSeconds(10)); + + // Function to resolve region from token + Func regionResolver = token => + { + if (token.StartsWith("us-")) + return Region.US; + else if (token.StartsWith("eu-")) + return Region.EU; + else + return Region.Other; + }; + + var regionalRule = new RegionalRule(regionResolver); + regionalRule.SetRuleForRegion(Region.US, usRule); + regionalRule.SetRuleForRegion(Region.EU, euRule); + + // Act & Assert + + // US token - first request allowed + Assert.That(regionalRule.IsRequestAllowed("us-token", "resource1"), Is.True); + requestTracker.RecordRequest("us-token", "resource1"); + + // US token - second request blocked (over limit) + Assert.That(regionalRule.IsRequestAllowed("us-token", "resource1"), Is.False); + + // EU token - first request allowed + Assert.That(regionalRule.IsRequestAllowed("eu-token", "resource1"), Is.True); + requestTracker.RecordRequest("eu-token", "resource1"); + + // EU token - second request allowed + Assert.That(regionalRule.IsRequestAllowed("eu-token", "resource1"), Is.True); + requestTracker.RecordRequest("eu-token", "resource1"); + + // EU token - third request allowed + Assert.That(regionalRule.IsRequestAllowed("eu-token", "resource1"), Is.True); + requestTracker.RecordRequest("eu-token", "resource1"); + + // EU token - fourth request blocked (over limit) + Assert.That(regionalRule.IsRequestAllowed("eu-token", "resource1"), Is.False); + + // Other region - no rule set, so allowed + Assert.That(regionalRule.IsRequestAllowed("other-token", "resource1"), Is.True); + } + + [Test] + public void RateLimiter_ShouldApplyRulesForResources() + { + // Arrange + var rateLimiter = new RateLimiter(); + var requestTracker = rateLimiter.GetRequestTracker(); + + // Create rules + var rule1 = new RequestsPerTimespanRule(requestTracker, 2, TimeSpan.FromSeconds(10)); + var rule2 = new TimeIntervalBetweenRequestsRule(requestTracker, TimeSpan.FromMilliseconds(50)); + + // Set rules for different resources + rateLimiter.SetRuleForResource("resource1", rule1); + rateLimiter.SetRuleForResource("resource2", rule2); + + // Act & Assert + + // Resource 1 - first request allowed + Assert.That(rateLimiter.IsRequestAllowed("token1", "resource1"), Is.True); + requestTracker.RecordRequest("token1", "resource1"); + + // Resource 1 - second request allowed + Assert.That(rateLimiter.IsRequestAllowed("token1", "resource1"), Is.True); + requestTracker.RecordRequest("token1", "resource1"); + + // Resource 1 - third request blocked (over limit) + Assert.That(rateLimiter.IsRequestAllowed("token1", "resource1"), Is.False); + + // Resource 2 - first request allowed + Assert.That(rateLimiter.IsRequestAllowed("token1", "resource2"), Is.True); + requestTracker.RecordRequest("token1", "resource2"); + + // Resource 2 - second request blocked (within interval) + Assert.That(rateLimiter.IsRequestAllowed("token1", "resource2"), Is.False); + + // Wait for interval to pass + Thread.Sleep(100); + + // Resource 2 - third request allowed (after interval) + Assert.That(rateLimiter.IsRequestAllowed("token1", "resource2"), Is.True); + + // Resource 3 - no rule set, so allowed + Assert.That(rateLimiter.IsRequestAllowed("token1", "resource3"), Is.True); + } +} diff --git a/RateLimiter/CompositeRule.cs b/RateLimiter/CompositeRule.cs new file mode 100644 index 00000000..c9ab094e --- /dev/null +++ b/RateLimiter/CompositeRule.cs @@ -0,0 +1,88 @@ +/* + * Author Joel Hernandez James + * Current Date 3/6/2025 + * Class CompositeRule + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter +{ + /// + /// A rule that bundles multiple rules together and applies them as a group. + /// Think of it like a parent rule that manages a collection of child rules. + /// + public class CompositeRule : IRateLimitRule + { + /// + /// Ways to combine multiple rules - like choosing between "all must pass" or "any can pass" + /// + public enum LogicalOperator + { + /// + /// All rules must say "yes" for the request to be allowed (like a unanimous vote) + /// + And, + + /// + /// At least one rule must say "yes" for the request to be allowed (like needing just one vote) + /// + Or + } + + private readonly List _rules; + private readonly LogicalOperator _operator; + + /// + /// Creates a new rule group with the specified way of combining results + /// + /// How to combine the results - "And" means all must pass, "Or" means any can pass + public CompositeRule(LogicalOperator @operator) + { + _rules = new List(); + _operator = @operator; + } + + /// + /// Adds a rule to our collection of rules + /// + /// The rule to add to the group + public void AddRule(IRateLimitRule rule) + { + if (rule == null) + throw new ArgumentNullException(nameof(rule)); + + _rules.Add(rule); + } + + /// + /// Checks if a request is allowed by applying all our rules and combining the results + /// + /// Who's making the request (their ID) + /// What they're trying to access + /// Yes (true) if allowed based on our combining method, No (false) if blocked + public bool IsRequestAllowed(string token, string resourceId) + { + // No rules? No problem! Let them through + if (_rules.Count == 0) + return true; + + // Apply our rules based on how we want to combine them + switch (_operator) + { + case LogicalOperator.And: + // Everyone must agree - if any rule says no, the answer is no + return _rules.All(rule => rule.IsRequestAllowed(token, resourceId)); + + case LogicalOperator.Or: + // Just need one yes - if any rule says yes, the answer is yes + return _rules.Any(rule => rule.IsRequestAllowed(token, resourceId)); + + default: + throw new ArgumentOutOfRangeException(); + } + } + } +} diff --git a/RateLimiter/IRateLimitRule.cs b/RateLimiter/IRateLimitRule.cs new file mode 100644 index 00000000..421952b5 --- /dev/null +++ b/RateLimiter/IRateLimitRule.cs @@ -0,0 +1,25 @@ +/* + * Author Joel Hernandez James + * Current Date 3/6/2025 + * Class IRateLimitRule + */ + +using System; + +namespace RateLimiter +{ + /// + /// A blueprint for all rate limiting rules. Any class that wants to be a rate limiting rule + /// needs to follow this pattern. Think of it like a contract that all rules must sign. + /// + public interface IRateLimitRule + { + /// + /// Decides whether to let a request through or block it + /// + /// Who's making the request (their ID or access key) + /// What they're trying to access + /// Green light (true) if allowed, red light (false) if blocked + bool IsRequestAllowed(string token, string resourceId); + } +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..2b6b6876 --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,82 @@ +/* + * Author Joel Hernandez James + * Current Date 3/6/2025 + * Class RateLimiter + */ + +using System; +using System.Collections.Generic; + +namespace RateLimiter +{ + /// + /// The main traffic cop that controls access to resources based on rate limits. + /// Think of it as the gatekeeper that decides who gets in and who has to wait. + /// + public class RateLimiter + { + private readonly RequestTracker _requestTracker; + private readonly Dictionary _resourceRules; + + /// + /// Creates a fresh rate limiter with no rules yet + /// + public RateLimiter() + { + _requestTracker = new RequestTracker(); + _resourceRules = new Dictionary(); + } + + /// + /// Assigns a specific rule to control access to a particular resource + /// + /// Which resource to protect + /// The rule that decides who can access it and when + public void SetRuleForResource(string resourceId, IRateLimitRule rule) + { + if (string.IsNullOrEmpty(resourceId)) + throw new ArgumentException("Resource ID cannot be null or empty", nameof(resourceId)); + + if (rule == null) + throw new ArgumentNullException(nameof(rule)); + + _resourceRules[resourceId] = rule; + } + + /// + /// The main decision maker - checks if a user can access a resource right now + /// + /// Who's trying to get access (their ID) + /// What they're trying to access + /// Green light (true) if they can proceed, red light (false) if they need to wait + public bool IsRequestAllowed(string token, string resourceId) + { + if (string.IsNullOrEmpty(token)) + throw new ArgumentException("Token cannot be null or empty", nameof(token)); + + if (string.IsNullOrEmpty(resourceId)) + throw new ArgumentException("Resource ID cannot be null or empty", nameof(resourceId)); + + // Check if this resource has any special access rules + if (_resourceRules.TryGetValue(resourceId, out var rule)) + { + // Ask the rule if this request should be allowed + bool isAllowed = rule.IsRequestAllowed(token, resourceId); + + return isAllowed; + } + + // No special rules for this resource? Open access! + return true; + } + + /// + /// Provides access to the request history tracker + /// + /// The tracker that remembers all request history + public RequestTracker GetRequestTracker() + { + return _requestTracker; + } + } +} diff --git a/RateLimiter/RegionalRule.cs b/RateLimiter/RegionalRule.cs new file mode 100644 index 00000000..4db1eb31 --- /dev/null +++ b/RateLimiter/RegionalRule.cs @@ -0,0 +1,77 @@ +/* + * Author Joel Hernandez James + * Current Date 3/6/2025 + * Class RegionalRule + */ + +using System; +using System.Collections.Generic; + +namespace RateLimiter +{ + /// + /// Different geographic areas we can apply specific rules to + /// + public enum Region + { + US, + EU, + Other + } + + /// + /// A rule that applies different limits based on where the user is located. + /// Like having different speed limits in different countries. + /// + public class RegionalRule : IRateLimitRule + { + private readonly Dictionary _regionRules; + private readonly Func _regionResolver; + + /// + /// Creates a new rule that can apply different limits based on region + /// + /// A function that figures out which region a user belongs to + public RegionalRule(Func regionResolver) + { + _regionResolver = regionResolver ?? throw new ArgumentNullException(nameof(regionResolver)); + _regionRules = new Dictionary(); + } + + /// + /// Assigns a specific rule to apply for users in a particular region + /// + /// Which region this rule applies to + /// The specific rule to use for that region + public void SetRuleForRegion(Region region, IRateLimitRule rule) + { + if (rule == null) + throw new ArgumentNullException(nameof(rule)); + + _regionRules[region] = rule; + } + + /// + /// Checks if a request is allowed by first determining the user's region, + /// then applying the appropriate rule for that region + /// + /// Who's making the request (their ID) + /// What they're trying to access + /// Yes (true) if allowed for their region, No (false) if blocked + public bool IsRequestAllowed(string token, string resourceId) + { + // Figure out which region this user belongs to + Region region = _regionResolver(token); + + // See if we have a special rule for their region + if (_regionRules.TryGetValue(region, out var rule)) + { + // Apply that region's specific rule + return rule.IsRequestAllowed(token, resourceId); + } + + // No special rule for their region? Let them through + return true; + } + } +} diff --git a/RateLimiter/RequestTracker.cs b/RateLimiter/RequestTracker.cs new file mode 100644 index 00000000..38f7bcc0 --- /dev/null +++ b/RateLimiter/RequestTracker.cs @@ -0,0 +1,96 @@ +/* + * Author Joel Hernandez James + * Current Date 3/6/2025 + * Class RequestTracker + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter +{ + /// + /// Keeps a record of who made requests and when they happened. + /// Like a logbook that remembers all the requests that come through. + /// + public class RequestTracker + { + // Our memory storage - like a filing cabinet where we store timestamps + // We organize by who made the request and what they accessed + // Format: "userID_resourceID" → list of when their requests happened + private readonly ConcurrentDictionary> _requestHistory; + + /// + /// Creates a fresh, empty request tracker ready to start logging requests + /// + public RequestTracker() + { + _requestHistory = new ConcurrentDictionary>(); + } + + // Writes down a new request in our logbook + // Like adding an entry: "User X accessed Resource Y at this time" + public void RecordRequest(string token, string resourceId) + { + // Create a unique label by combining who and what + string key = GetKey(token, resourceId); + + // Note down exactly when this happened + DateTime now = DateTime.UtcNow; + + // Add this timestamp to our records + _requestHistory.AddOrUpdate( + key, + // If this is their first request, start a new page in the logbook + _ => new List { now }, + // If they've been here before, just add another entry to their page + (_, timestamps) => + { + timestamps.Add(now); + return timestamps; + }); + } + + // Looks up all the times a specific user accessed a specific resource + // Like flipping through the logbook to find all entries for a particular user + public List GetRequestHistory(string token, string resourceId) + { + string key = GetKey(token, resourceId); + + // Find their history or return an empty list if they've never been here + return _requestHistory.GetValueOrDefault(key, new List()); + } + + /// + /// Cleans out old entries from our records that we don't need anymore + /// + /// Who made the requests (their ID) + /// What they accessed + /// How far back to keep records (older ones get tossed) + public void CleanupHistory(string token, string resourceId, TimeSpan timespan) + { + string key = GetKey(token, resourceId); + + // Figure out the cutoff point - anything older gets removed + DateTime cutoff = DateTime.UtcNow.Subtract(timespan); + + // Throw away all the timestamps that are too old + if (_requestHistory.TryGetValue(key, out var timestamps)) + { + timestamps.RemoveAll(timestamp => timestamp < cutoff); + } + } + + /// + /// Creates a lookup key by combining user ID and resource ID + /// + /// Who's making the request (their ID) + /// What they're trying to access + /// A combined key like "userID_resourceID" + private string GetKey(string token, string resourceId) + { + return $"{token}_{resourceId}"; + } + } +} diff --git a/RateLimiter/RequestsPerTimespanRule.cs b/RateLimiter/RequestsPerTimespanRule.cs new file mode 100644 index 00000000..187855d5 --- /dev/null +++ b/RateLimiter/RequestsPerTimespanRule.cs @@ -0,0 +1,60 @@ +/* + * Author Joel Hernandez James + * Current Date 3/6/2025 + * Class RequestsPerTimespanRule + */ + +using System; + +namespace RateLimiter +{ + /// + /// A rule that limits how many requests a user can make within a certain time window. + /// Like saying "you can only make 100 requests per hour" or "5 requests per minute". + /// + public class RequestsPerTimespanRule : IRateLimitRule + { + private readonly RequestTracker _requestTracker; + private readonly int _maxRequests; + private readonly TimeSpan _timespan; + + /// + /// Creates a new rule that limits requests based on quantity and time + /// + /// Keeps track of when requests happened + /// How many requests are allowed in the time window + /// The time window to count requests in (like 1 minute, 1 hour, etc.) + public RequestsPerTimespanRule(RequestTracker requestTracker, int maxRequests, TimeSpan timespan) + { + // Validate parameters + if (maxRequests <= 0) + throw new ArgumentException("Maximum requests must be greater than zero", nameof(maxRequests)); + + if (timespan <= TimeSpan.Zero) + throw new ArgumentException("Timespan must be greater than zero", nameof(timespan)); + + _requestTracker = requestTracker ?? throw new ArgumentNullException(nameof(requestTracker)); + _maxRequests = maxRequests; + _timespan = timespan; + } + + /// + /// Checks if a request is allowed based on how many requests the user has already made + /// + /// Who's making the request (their ID) + /// What they're trying to access + /// Yes (true) if they haven't hit their limit yet, No (false) if they've used up their quota + public bool IsRequestAllowed(string token, string resourceId) + { + // First, clean out any old requests that are outside our time window + _requestTracker.CleanupHistory(token, resourceId, _timespan); + + // Get the list of recent requests within our time window + var requestHistory = _requestTracker.GetRequestHistory(token, resourceId); + + // Check if they still have room in their quota + // If they've made fewer requests than their limit, let them through + return requestHistory.Count < _maxRequests; + } + } +} diff --git a/RateLimiter/TimeIntervalBetweenRequestsRule.cs b/RateLimiter/TimeIntervalBetweenRequestsRule.cs new file mode 100644 index 00000000..4771fa86 --- /dev/null +++ b/RateLimiter/TimeIntervalBetweenRequestsRule.cs @@ -0,0 +1,61 @@ +/* + * Author Joel Hernandez James + * Current Date 3/6/2025 + * Class TimeIntervalBetweenRequestsRule + */ + +using System; +using System.Linq; + +namespace RateLimiter +{ + /// + /// This rule makes sure users wait a minimum amount of time between requests. + /// Think of it like a "cooldown period" before allowing the next request. + /// + public class TimeIntervalBetweenRequestsRule : IRateLimitRule + { + private readonly RequestTracker _requestTracker; + private readonly TimeSpan _minInterval; + + /// + /// Sets up a new rule that enforces waiting time between requests + /// + /// Keeps track of when requests happened + /// How long users need to wait between requests + public TimeIntervalBetweenRequestsRule(RequestTracker requestTracker, TimeSpan minInterval) + { + // Validate parameters + if (minInterval <= TimeSpan.Zero) + throw new ArgumentException("Minimum interval must be greater than zero", nameof(minInterval)); + + _requestTracker = requestTracker ?? throw new ArgumentNullException(nameof(requestTracker)); + _minInterval = minInterval; + } + + /// + /// Decides if a new request is allowed based on when the last one happened + /// + /// Who's making the request (like their ID or key) + /// What they're trying to access + /// Yes (true) if enough time has passed, No (false) if they need to wait longer + public bool IsRequestAllowed(string token, string resourceId) + { + // Look up when this user made requests before + var requestHistory = _requestTracker.GetRequestHistory(token, resourceId); + + // First time? No problem, go right ahead! + if (requestHistory.Count == 0) + return true; + + // Find when their most recent request happened + DateTime lastRequestTime = requestHistory.Max(); + + // Figure out how much time has passed since then + TimeSpan elapsed = DateTime.UtcNow - lastRequestTime; + + // If they've waited long enough, allow the request. Otherwise, they need to wait longer + return elapsed >= _minInterval; + } + } +}