From c00fae5311dfb14d4e50a39986b5caf7fcc17d49 Mon Sep 17 00:00:00 2001 From: ClubSpeedJustinG Date: Sun, 23 Feb 2025 16:46:21 -0800 Subject: [PATCH 1/5] initial project push --- .../RateLimitTestControllerTests.cs | 42 +++++++ .../Middleware/RateLimiterMiddlewareTests.cs | 116 ++++++++++++++++++ .../Services/RateLimiterManagerTests.cs | 116 ++++++++++++++++++ .../Rules/FixedWindowRateLimitTests.cs | 71 +++++++++++ .../Rules/SlidingWindowRateLimitTests.cs | 75 +++++++++++ .../Controllers/RateLimitTestController.cs | 21 ++++ RateLimiter/Interfaces/IRateLimitRule.cs | 9 ++ .../Middleware/RateLimiterMiddleware.cs | 63 ++++++++++ RateLimiter/Models/ClientRateLimitConfig.cs | 14 +++ RateLimiter/Models/ClientRegionConfig.cs | 8 ++ RateLimiter/Models/RateLimitEntry.cs | 8 ++ RateLimiter/Models/RateLimitResult.cs | 8 ++ RateLimiter/Models/ResourceRateLimitConfig.cs | 15 +++ RateLimiter/Program.cs | 100 +++++++++++++++ RateLimiter/Properties/launchSettings.json | 12 ++ RateLimiter/RateLimiter.csproj | 22 ++-- RateLimiter/Services/RateLimiterManager.cs | 50 ++++++++ .../Services/Rules/FixedWindowRateLimit.cs | 64 ++++++++++ .../Services/Rules/SlidingWindowRateLimit.cs | 61 +++++++++ 19 files changed, 868 insertions(+), 7 deletions(-) create mode 100644 RateLimiter.Tests/Controllers/RateLimitTestControllerTests.cs create mode 100644 RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs create mode 100644 RateLimiter.Tests/Services/RateLimiterManagerTests.cs create mode 100644 RateLimiter.Tests/Services/Rules/FixedWindowRateLimitTests.cs create mode 100644 RateLimiter.Tests/Services/Rules/SlidingWindowRateLimitTests.cs create mode 100644 RateLimiter/Controllers/RateLimitTestController.cs create mode 100644 RateLimiter/Interfaces/IRateLimitRule.cs create mode 100644 RateLimiter/Middleware/RateLimiterMiddleware.cs create mode 100644 RateLimiter/Models/ClientRateLimitConfig.cs create mode 100644 RateLimiter/Models/ClientRegionConfig.cs 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/Program.cs create mode 100644 RateLimiter/Properties/launchSettings.json create mode 100644 RateLimiter/Services/RateLimiterManager.cs create mode 100644 RateLimiter/Services/Rules/FixedWindowRateLimit.cs create mode 100644 RateLimiter/Services/Rules/SlidingWindowRateLimit.cs diff --git a/RateLimiter.Tests/Controllers/RateLimitTestControllerTests.cs b/RateLimiter.Tests/Controllers/RateLimitTestControllerTests.cs new file mode 100644 index 00000000..fd9d6a42 --- /dev/null +++ b/RateLimiter.Tests/Controllers/RateLimitTestControllerTests.cs @@ -0,0 +1,42 @@ +using NUnit.Framework; +using RateLimiter.Controllers; +using Microsoft.AspNetCore.Mvc; + +namespace RateLimiter.Tests.Controllers +{ + [TestFixture] + public class RateLimitTestControllerTests + { + private RateLimitTestController _controller; + + [SetUp] + public void Setup() + { + _controller = new RateLimitTestController(); + } + + [Test] + public void GetResource1_ReturnsOk() + { + // Act + var result = _controller.GetResource1() as OkObjectResult; + + // Assert + Assert.NotNull(result); + Assert.AreEqual(200, result.StatusCode); + Assert.IsInstanceOf(result); + } + + [Test] + public void GetResource2_ReturnsOk() + { + // Act + var result = _controller.GetResource2() as OkObjectResult; + + // Assert + Assert.NotNull(result); + Assert.AreEqual(200, result.StatusCode); + Assert.IsInstanceOf(result); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs b/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs new file mode 100644 index 00000000..a2e4aa47 --- /dev/null +++ b/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using RateLimiter.Interfaces; +using RateLimiter.Models; +using RateLimiter.Services; +using System.Collections.Generic; +using System.Threading.Tasks; +using System; + +namespace RateLimiter.Tests.Middleware +{ + public class RateLimiterMiddlewareTests + { + private RateLimiterMiddleware _middleware; + private RateLimiterManager _manager; + private DefaultHttpContext _context; + + private const string TEST_RESOURCE = "/api/test/resource"; + private const string CLIENT_A = "ClientA"; + private const string HEADER_CLIENT_ID = "X-Client-Id"; + private const string CONTENT_TYPE_JSON = "application/json"; + private const string SWAGGER_PATH = "/swagger/index.html"; + private const string HEALTH_CHECK_PATH = "/health"; + + private const int HTTP_STATUS_OK = 200; + private const int HTTP_STATUS_BAD_REQUEST = 400; + private const int HTTP_STATUS_TOO_MANY_REQUESTS = 429; + + [SetUp] + public void Setup() + { + _manager = new RateLimiterManager(); + _middleware = new RateLimiterMiddleware( + next: (context) => Task.CompletedTask, + _manager + ); + _context = new DefaultHttpContext(); + } + + [Test] + public async Task GivenSwaggerRequest_WhenInvoked_SkipsRateLimiting() + { + _context.Request.Path = SWAGGER_PATH; + + await _middleware.Invoke(_context); + + Assert.That(_context.Response.StatusCode, Is.EqualTo(HTTP_STATUS_OK)); + } + + [Test] + public async Task GivenHealthCheckRequest_WhenInvoked_SkipsRateLimiting() + { + _context.Request.Path = HEALTH_CHECK_PATH; + + await _middleware.Invoke(_context); + + Assert.That(_context.Response.StatusCode, Is.EqualTo(HTTP_STATUS_OK)); + } + + [Test] + public async Task GivenMissingClientId_WhenInvoked_ReturnsBadRequest() + { + _context.Request.Path = TEST_RESOURCE; + + await _middleware.Invoke(_context); + + Assert.Multiple(() => + { + Assert.That(_context.Response.StatusCode, Is.EqualTo(HTTP_STATUS_BAD_REQUEST)); + Assert.That(_context.Response.ContentType, Is.EqualTo(CONTENT_TYPE_JSON)); + }); + } + + [Test] + public async Task GivenRateLimitExceeded_WhenInvoked_ReturnsTooManyRequests() + { + // Setup client with a rate limit of 1 request per 5 seconds + var config = new ClientRateLimitConfig + { + ClientId = CLIENT_A, + ResourceLimits = new List + { + new ResourceRateLimitConfig + { + Resource = TEST_RESOURCE, + Rules = new List + { + new FixedWindowRateLimit(1, TimeSpan.FromSeconds(5)) + } + } + } + }; + _manager.AddRateLimitRules(new List { config }); + + _context.Request.Path = TEST_RESOURCE; + _context.Request.Headers[HEADER_CLIENT_ID] = CLIENT_A; + + // First request should succeed + await _middleware.Invoke(_context); + Assert.That(_context.Response.StatusCode, Is.EqualTo(HTTP_STATUS_OK)); + + // Reset context for second request + _context = new DefaultHttpContext(); + _context.Request.Path = TEST_RESOURCE; + _context.Request.Headers[HEADER_CLIENT_ID] = CLIENT_A; + + // Second request should fail + await _middleware.Invoke(_context); + Assert.Multiple(() => + { + Assert.That(_context.Response.StatusCode, Is.EqualTo(HTTP_STATUS_TOO_MANY_REQUESTS)); + Assert.That(_context.Response.Headers.ContainsKey("Retry-After"), Is.True); + }); + } + } +} diff --git a/RateLimiter.Tests/Services/RateLimiterManagerTests.cs b/RateLimiter.Tests/Services/RateLimiterManagerTests.cs new file mode 100644 index 00000000..0beb19ad --- /dev/null +++ b/RateLimiter.Tests/Services/RateLimiterManagerTests.cs @@ -0,0 +1,116 @@ +using NUnit.Framework; +using RateLimiter.Interfaces; +using RateLimiter.Models; +using RateLimiter.Services.Rules; +using RateLimiter.Services; +using System.Collections.Generic; +using System; + +namespace RateLimiter.Tests.Services +{ + public class RateLimiterManagerTests + { + private RateLimiterManager _manager; + + private const string TEST_RESOURCE = "/api/test/resource"; + private const string DIFFERENT_RESOURCE = "/different/resource"; + private const string CLIENT_ID = "TestClient"; + private const string UNKNOWN_CLIENT = "UnknownClient"; + + private const int HTTP_STATUS_OK = 200; + private const int RATE_LIMIT_COUNT = 1; + private static readonly TimeSpan RATE_LIMIT_DURATION = TimeSpan.FromSeconds(5); + + [SetUp] + public void Setup() + { + _manager = new RateLimiterManager(); + } + + [Test] + public void GivenUnknownClient_WhenRequestMade_ReturnsFalse() + { + var result = _manager.IsRequestAllowed(UNKNOWN_CLIENT, TEST_RESOURCE); + Assert.That(result.IsAllowed, Is.False); + } + + [Test] + public void GivenUnconfiguredResource_WhenRequestMade_ReturnsFalse() + { + var config = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); + _manager.AddRateLimitRules(new List { config }); + + var result = _manager.IsRequestAllowed(CLIENT_ID, DIFFERENT_RESOURCE); + Assert.That(result.IsAllowed, Is.False); + } + + [Test] + public void GivenValidConfiguration_WhenRequestMade_AllowsRequests() + { + var config = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); + _manager.AddRateLimitRules(new List { config }); + + var result = _manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE); + Assert.That(result.IsAllowed, Is.True); + } + + [Test] + public void GivenDuplicateClientConfig_WhenAdded_ThrowsException() + { + var config = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); + _manager.AddRateLimitRules(new List { config }); + + Assert.Throws(() => + _manager.AddRateLimitRules(new List { config })); + } + + [Test] + public void GivenMultipleRules_WhenOneFails_RequestIsDenied() + { + var config = new ClientRateLimitConfig + { + ClientId = CLIENT_ID, + ResourceLimits = new List + { + new ResourceRateLimitConfig + { + Resource = TEST_RESOURCE, + Rules = new List + { + new FixedWindowRateLimit(RATE_LIMIT_COUNT, RATE_LIMIT_DURATION), + new SlidingWindowRateLimit(RATE_LIMIT_COUNT, RATE_LIMIT_DURATION) + } + } + } + }; + + _manager.AddRateLimitRules(new List { config }); + + // First request should pass both rules + Assert.That(_manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE).IsAllowed, Is.True); + + // Second request should fail both rules + var result = _manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE); + Assert.That(result.IsAllowed, Is.False); + } + + private static ClientRateLimitConfig CreateTestConfig(string clientId, string resource) + { + return new ClientRateLimitConfig + { + ClientId = clientId, + ResourceLimits = new List + { + new ResourceRateLimitConfig + { + Resource = resource, + Rules = new List + { + new FixedWindowRateLimit(RATE_LIMIT_COUNT, RATE_LIMIT_DURATION) + } + } + } + }; + } + } +} diff --git a/RateLimiter.Tests/Services/Rules/FixedWindowRateLimitTests.cs b/RateLimiter.Tests/Services/Rules/FixedWindowRateLimitTests.cs new file mode 100644 index 00000000..a299d044 --- /dev/null +++ b/RateLimiter.Tests/Services/Rules/FixedWindowRateLimitTests.cs @@ -0,0 +1,71 @@ +using NUnit.Framework; +using System; +using System.Threading; + +namespace RateLimiter.Tests.Services.Rules +{ + public class FixedWindowRateLimitTests + { + private FixedWindowRateLimit _rateLimit; + private const string CLIENT_ID = "TestClient"; + + [SetUp] + public void Setup() + { + _rateLimit = new FixedWindowRateLimit(3, TimeSpan.FromSeconds(5)); + } + + [Test] + public void WhenFirstRequest_IsAllowed() + { + var result = _rateLimit.IsRequestAllowed(CLIENT_ID); + Assert.That(result.IsAllowed, Is.True); + } + + [Test] + public void WhenUnderLimit_AllRequestsAllowed() + { + Assert.Multiple(() => + { + Assert.That(_rateLimit.IsRequestAllowed(CLIENT_ID).IsAllowed, Is.True); + Assert.That(_rateLimit.IsRequestAllowed(CLIENT_ID).IsAllowed, Is.True); + Assert.That(_rateLimit.IsRequestAllowed(CLIENT_ID).IsAllowed, Is.True); + }); + } + + [Test] + public void WhenOverLimit_RequestsBlocked() + { + // Use up the limit + for (int i = 0; i < 3; i++) + { + _rateLimit.IsRequestAllowed(CLIENT_ID); + } + + var result = _rateLimit.IsRequestAllowed(CLIENT_ID); + Assert.Multiple(() => + { + Assert.That(result.IsAllowed, Is.False); + Assert.That(result.RetryAfter, Is.GreaterThan(TimeSpan.Zero)); + }); + } + + [Test] + public void WhenWindowExpires_AllowsNewRequests() + { + // Use up the limit + for (int i = 0; i < 3; i++) + { + _rateLimit.IsRequestAllowed(CLIENT_ID); + } + + // Wait for window to expire + Thread.Sleep(5100); + + var result = _rateLimit.IsRequestAllowed(CLIENT_ID); + Assert.That(result.IsAllowed, Is.True); + } + + + } +} diff --git a/RateLimiter.Tests/Services/Rules/SlidingWindowRateLimitTests.cs b/RateLimiter.Tests/Services/Rules/SlidingWindowRateLimitTests.cs new file mode 100644 index 00000000..0edd10db --- /dev/null +++ b/RateLimiter.Tests/Services/Rules/SlidingWindowRateLimitTests.cs @@ -0,0 +1,75 @@ +using NUnit.Framework; +using RateLimiter.Services.Rules; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace RateLimiter.Tests.Services.Rules +{ + public class SlidingWindowRateLimitTests + { + private SlidingWindowRateLimit _rateLimit; + private const string CLIENT_ID = "ClientA"; + + [SetUp] + public void Setup() + { + _rateLimit = new SlidingWindowRateLimit(3, TimeSpan.FromSeconds(5)); + } + + [Test] + public void WhenFirstRequest_IsAllowed() + { + var result = _rateLimit.IsRequestAllowed(CLIENT_ID); + Assert.That(result.IsAllowed, Is.True); + } + + [Test] + public void WhenUnderLimit_AllRequestsAllowed() + { + Assert.Multiple(() => + { + Assert.That(_rateLimit.IsRequestAllowed(CLIENT_ID).IsAllowed, Is.True); + Assert.That(_rateLimit.IsRequestAllowed(CLIENT_ID).IsAllowed, Is.True); + Assert.That(_rateLimit.IsRequestAllowed(CLIENT_ID).IsAllowed, Is.True); + }); + } + + [Test] + public void WhenOverLimit_RequestsBlocked() + { + // Use up the limit + for (int i = 0; i < 3; i++) + { + _rateLimit.IsRequestAllowed(CLIENT_ID); + } + + var result = _rateLimit.IsRequestAllowed(CLIENT_ID); + Assert.Multiple(() => + { + Assert.That(result.IsAllowed, Is.False); + Assert.That(result.RetryAfter, Is.GreaterThan(TimeSpan.Zero)); + }); + } + + [Test] + public void WhenOldRequestsExpire_AllowsNewRequests() + { + // Make initial requests + for (int i = 0; i < 3; i++) + { + _rateLimit.IsRequestAllowed(CLIENT_ID); + } + + // Wait for oldest request to expire + Thread.Sleep(5100); + + var result = _rateLimit.IsRequestAllowed(CLIENT_ID); + Assert.That(result.IsAllowed, Is.True); + } + + } +} diff --git a/RateLimiter/Controllers/RateLimitTestController.cs b/RateLimiter/Controllers/RateLimitTestController.cs new file mode 100644 index 00000000..750ff96b --- /dev/null +++ b/RateLimiter/Controllers/RateLimitTestController.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; + +namespace RateLimiter.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class RateLimitTestController : ControllerBase + { + [HttpGet("resource1")] + public IActionResult GetResource1() + { + return Ok(new { message = "Access granted to resource1!" }); + } + + [HttpGet("resource2")] + public IActionResult GetResource2() + { + return Ok(new { message = "Access granted to resource2!" }); + } + } +} diff --git a/RateLimiter/Interfaces/IRateLimitRule.cs b/RateLimiter/Interfaces/IRateLimitRule.cs new file mode 100644 index 00000000..13c0faff --- /dev/null +++ b/RateLimiter/Interfaces/IRateLimitRule.cs @@ -0,0 +1,9 @@ +using RateLimiter.Models; + +namespace RateLimiter.Interfaces +{ + public interface IRateLimitRule + { + RateLimitResult IsRequestAllowed(string clientId); + } +} \ No newline at end of file diff --git a/RateLimiter/Middleware/RateLimiterMiddleware.cs b/RateLimiter/Middleware/RateLimiterMiddleware.cs new file mode 100644 index 00000000..2ac11f59 --- /dev/null +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -0,0 +1,63 @@ +using RateLimiter.Services; +using System.Text.Json; + +public class RateLimiterMiddleware +{ + private readonly RequestDelegate _next; + private readonly RateLimiterManager _rateLimiterManager; + + public RateLimiterMiddleware(RequestDelegate next, RateLimiterManager rateLimiterManager) + { + _next = next; + _rateLimiterManager = rateLimiterManager; + } + + public async Task Invoke(HttpContext context) + { + var path = context.Request.Path.ToString().ToLower(); + if (path.StartsWith("/swagger") || path.StartsWith("/health")) + { + await _next(context); + return; + } + + // Validate X-Client-Id header + string clientId = context.Request.Headers["X-Client-Id"]; + if (string.IsNullOrEmpty(clientId)) + { + await RespondWithError(context, 400, "MISSING_CLIENT_ID", "Client ID is required in the 'X-Client-Id' header."); + return; + } + + string resource = context.Request.Path.ToString(); + var rateLimitResult = _rateLimiterManager.IsRequestAllowed(clientId, resource); + + if (!rateLimitResult.IsAllowed) + { + context.Response.StatusCode = 429; + context.Response.Headers["Retry-After"] = Math.Ceiling(rateLimitResult.RetryAfter.TotalSeconds).ToString(); + await RespondWithError(context, 429, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded. Try again later.", rateLimitResult.RetryAfter.TotalSeconds); + return; + } + + await _next(context); + } + + private static async Task RespondWithError(HttpContext context, int statusCode, string errorCode, string message, double? retryAfter = null) + { + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + + var errorResponse = new + { + error = new + { + code = errorCode, + message = message, + retry_after = Math.Ceiling(retryAfter ?? 0) // Ensures no long decimal places + } + }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse)); + } +} diff --git a/RateLimiter/Models/ClientRateLimitConfig.cs b/RateLimiter/Models/ClientRateLimitConfig.cs new file mode 100644 index 00000000..ae238e9e --- /dev/null +++ b/RateLimiter/Models/ClientRateLimitConfig.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Models +{ + public class ClientRateLimitConfig + { + public string ClientId { get; set; } + public List ResourceLimits { get; set; } = new(); + } +} diff --git a/RateLimiter/Models/ClientRegionConfig.cs b/RateLimiter/Models/ClientRegionConfig.cs new file mode 100644 index 00000000..ba1d6d26 --- /dev/null +++ b/RateLimiter/Models/ClientRegionConfig.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Models +{ + public class ClientRegionConfig + { + public string ClientId { get; set; } + public string Region { get; set; } + } +} diff --git a/RateLimiter/Models/RateLimitEntry.cs b/RateLimiter/Models/RateLimitEntry.cs new file mode 100644 index 00000000..4e61981e --- /dev/null +++ b/RateLimiter/Models/RateLimitEntry.cs @@ -0,0 +1,8 @@ +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..acf5bcb3 --- /dev/null +++ b/RateLimiter/Models/RateLimitResult.cs @@ -0,0 +1,8 @@ +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..2d0f9368 --- /dev/null +++ b/RateLimiter/Models/ResourceRateLimitConfig.cs @@ -0,0 +1,15 @@ +using RateLimiter.Interfaces; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Models +{ + public class ResourceRateLimitConfig + { + public string Resource { get; set; } + public List Rules { get; set; } = new(); + } +} diff --git a/RateLimiter/Program.cs b/RateLimiter/Program.cs new file mode 100644 index 00000000..3fa962c5 --- /dev/null +++ b/RateLimiter/Program.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.OpenApi.Models; +using RateLimiter.Interfaces; +using RateLimiter.Models; +using RateLimiter.Services; +using RateLimiter.Services.Rules; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); + +// Add Health Checks +builder.Services.AddHealthChecks(); + +// 🔹 Add Swagger (with API Key Authentication) +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Rate Limiter API", + Version = "v1", + Description = "API with per-client rate limiting" + }); + + // 🔹 Add API Key Authentication (X-Client-Id) + c.AddSecurityDefinition("X-Client-Id", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Name = "X-Client-Id", + Type = SecuritySchemeType.ApiKey, + Description = "Client ID required for rate limiting" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "X-Client-Id" } + }, + Array.Empty() + } + }); +}); + +//Register Rate Limiter Services +var rateLimiterManager = new RateLimiterManager(); + +var clientRegions = new List +{ + new ClientRegionConfig { ClientId = "ClientA", Region = "US" }, + new ClientRegionConfig { ClientId = "ClientB", Region = "EU" } +}; + +var clientRateLimits = clientRegions.Select(client => new ClientRateLimitConfig +{ + ClientId = client.ClientId, + ResourceLimits = new List + { + new ResourceRateLimitConfig + { + Resource = "/api/RateLimitTest/resource1", + Rules = client.Region == "US" + ? new List { new FixedWindowRateLimit(5, TimeSpan.FromSeconds(10)) } + : new List { new SlidingWindowRateLimit(1, TimeSpan.FromSeconds(5)) } + }, + new ResourceRateLimitConfig + { + Resource = "/api/RateLimitTest/resource2", + Rules = client.Region == "US" + ? new List { new FixedWindowRateLimit(10, TimeSpan.FromSeconds(20)) } + : new List { new SlidingWindowRateLimit(1, TimeSpan.FromSeconds(10)) } + } + } +}).ToList(); + +rateLimiterManager.AddRateLimitRules(clientRateLimits); +builder.Services.AddSingleton(rateLimiterManager); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +//Good when deploying to ECS +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = async (context, report) => + { + await context.Response.WriteAsJsonAsync(new { status = report.Status.ToString() }); + } +}); + + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseMiddleware(); // Rate limiter middleware first +app.MapControllers(); // Map API routes + +app.Run(); diff --git a/RateLimiter/Properties/launchSettings.json b/RateLimiter/Properties/launchSettings.json new file mode 100644 index 00000000..48733bc4 --- /dev/null +++ b/RateLimiter/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "RateLimiter": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:50374;http://localhost:50375" + } + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..5546d24b 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -1,7 +1,15 @@ - - - net6.0 - latest - enable - - \ No newline at end of file + + + + net6.0 + enable + enable + + + + + + + + + \ No newline at end of file diff --git a/RateLimiter/Services/RateLimiterManager.cs b/RateLimiter/Services/RateLimiterManager.cs new file mode 100644 index 00000000..6b8cd2d5 --- /dev/null +++ b/RateLimiter/Services/RateLimiterManager.cs @@ -0,0 +1,50 @@ +using RateLimiter.Models; + +namespace RateLimiter.Services +{ + public class RateLimiterManager + { + private readonly List _clientRateLimits = new(); + + public void AddRateLimitRules(List clientConfigs) + { + foreach (var clientConfig in clientConfigs) + { + if (_clientRateLimits.Any(c => c.ClientId == clientConfig.ClientId)) + { + throw new ArgumentException($"Client ID '{clientConfig.ClientId}' is already registered."); + } + + _clientRateLimits.Add(clientConfig); + } + } + + public RateLimitResult IsRequestAllowed(string clientId, string resource) + { + var result = new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + + var clientConfig = _clientRateLimits.FirstOrDefault(c => c.ClientId == clientId); + if (clientConfig == null) + { + return new RateLimitResult { IsAllowed = false, RetryAfter = TimeSpan.Zero }; + } + + var resourceConfig = clientConfig.ResourceLimits.FirstOrDefault(r => r.Resource == resource); + if (resourceConfig == null) + { + return new RateLimitResult { IsAllowed = false, RetryAfter = TimeSpan.Zero }; + } + + foreach (var rule in resourceConfig.Rules) + { + var ruleResult = rule.IsRequestAllowed(clientId); + if (!ruleResult.IsAllowed && ruleResult.RetryAfter > result.RetryAfter) + { + result = ruleResult; // Store the rule with the longest retry time + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Services/Rules/FixedWindowRateLimit.cs b/RateLimiter/Services/Rules/FixedWindowRateLimit.cs new file mode 100644 index 00000000..745a5b2c --- /dev/null +++ b/RateLimiter/Services/Rules/FixedWindowRateLimit.cs @@ -0,0 +1,64 @@ +using RateLimiter.Interfaces; +using RateLimiter.Models; + +/// +// Max Requests: 5 , Time Window: 1 minute +/// 4:27:10 - Allowed (1/5) +/// 4:27:20 - Allowed (2/5) +/// 4:27:30 - Allowed (3/5) +/// 4:27:45 - Allowed (4/5) +/// 4:27:55 - Allowed (5/5) +/// 4:27:58 - Blocked! (Wait until 4:28:00) +/// At 4:28:00, requests reset. +/// +public class FixedWindowRateLimit : IRateLimitRule +{ + private readonly int _maxRequests; + private readonly TimeSpan _timeWindow; + private readonly Dictionary _clientRequestCounts = new(); + + public FixedWindowRateLimit(int maxRequests, TimeSpan timeWindow) + { + _maxRequests = maxRequests; + _timeWindow = timeWindow; + } + + public RateLimitResult IsRequestAllowed(string clientId) + { + lock (_clientRequestCounts) + { + DateTime now = DateTime.UtcNow; + + // Remove expired entries to free up memory + List expiredClients = new(); + foreach (var kvp in _clientRequestCounts) + { + if (now >= kvp.Value.ResetTime) + expiredClients.Add(kvp.Key); + } + foreach (var client in expiredClients) { + _clientRequestCounts.Remove(client); + } + + if (!_clientRequestCounts.ContainsKey(clientId) || now >= _clientRequestCounts[clientId].ResetTime) + { + _clientRequestCounts[clientId] = new RateLimitEntry() { Count = 1, ResetTime = now + _timeWindow }; + return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + } + + var entry = _clientRequestCounts[clientId]; + + if (entry.Count < _maxRequests) + { + _clientRequestCounts[clientId] = new RateLimitEntry() { Count = entry.Count + 1, ResetTime = entry.ResetTime }; + return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + } + + return new RateLimitResult + { + IsAllowed = false, + RetryAfter = entry.ResetTime - now + }; + } + } +} diff --git a/RateLimiter/Services/Rules/SlidingWindowRateLimit.cs b/RateLimiter/Services/Rules/SlidingWindowRateLimit.cs new file mode 100644 index 00000000..17767756 --- /dev/null +++ b/RateLimiter/Services/Rules/SlidingWindowRateLimit.cs @@ -0,0 +1,61 @@ +using RateLimiter.Interfaces; +using RateLimiter.Models; + +namespace RateLimiter.Services.Rules +{ + + /// + /// Example: 5 requests per 1-minute window + /// 4:27:10 - Allowed (1/5) + /// 4:27:20 - Allowed (2/5) + /// 4:27:30 - Allowed (3/5) + /// 4:27:45 - Allowed (4/5) + /// 4:27:55 - Allowed (5/5) + /// 4:27:58 - Blocked! (Limit reached, must wait) + /// + /// **When requests expire:** + /// - At **4:28:10**, the request from **4:27:10** expires → 4:28:10 now allowed + /// + public class SlidingWindowRateLimit : IRateLimitRule + { + private readonly int _maxRequests; + private readonly TimeSpan _timeWindow; + private readonly Dictionary> _clientRequestHistory = new(); + + public SlidingWindowRateLimit(int maxRequests, TimeSpan timeWindow) + { + _maxRequests = maxRequests; + _timeWindow = timeWindow; + } + + public RateLimitResult IsRequestAllowed(string clientId) + { + lock (_clientRequestHistory) + { + DateTime now = DateTime.UtcNow; + DateTime expirationThreshold = now - _timeWindow; + + // Get or create request history for client + if (!_clientRequestHistory.TryGetValue(clientId, out var requestTimes)) + { + requestTimes = new List(); + _clientRequestHistory[clientId] = requestTimes; + } + + // Remove expired timestamps + requestTimes.RemoveAll(time => time < expirationThreshold); + + // Allow request if within limit + if (requestTimes.Count < _maxRequests) + { + requestTimes.Add(now); + return new RateLimitResult { IsAllowed = true, RetryAfter = TimeSpan.Zero }; + } + + // Rate limit exceeded, calculate retry time + TimeSpan retryAfter = (requestTimes[0] + _timeWindow) - now; + return new RateLimitResult { IsAllowed = false, RetryAfter = retryAfter }; + } + } + } +} From 2d0ef3ad5c089fdcaefe9cb6c439c0d4becb3e26 Mon Sep 17 00:00:00 2001 From: ClubSpeedJustinG Date: Sun, 23 Feb 2025 16:49:25 -0800 Subject: [PATCH 2/5] Delete RateLimiterTest.cs --- RateLimiter.Tests/RateLimiterTest.cs | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 RateLimiter.Tests/RateLimiterTest.cs diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs deleted file mode 100644 index 172d44a7..00000000 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file From 04117baee647d8371087f95da562b6bad2a85d64 Mon Sep 17 00:00:00 2001 From: ClubSpeedJustinG Date: Sun, 23 Feb 2025 16:56:21 -0800 Subject: [PATCH 3/5] inject via constructor --- .../Middleware/RateLimiterMiddlewareTests.cs | 17 +++++++--- .../Services/RateLimiterManagerTests.cs | 33 ++++++++----------- RateLimiter/Program.cs | 7 ++-- RateLimiter/Services/RateLimiterManager.cs | 23 ++++++++----- 4 files changed, 44 insertions(+), 36 deletions(-) diff --git a/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs b/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs index a2e4aa47..1eb7b5a1 100644 --- a/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs +++ b/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs @@ -29,7 +29,8 @@ public class RateLimiterMiddlewareTests [SetUp] public void Setup() { - _manager = new RateLimiterManager(); + // Initialize with empty config list - specific tests will create their own configs + _manager = new RateLimiterManager(new List()); _middleware = new RateLimiterMiddleware( next: (context) => Task.CompletedTask, _manager @@ -74,7 +75,7 @@ public async Task GivenMissingClientId_WhenInvoked_ReturnsBadRequest() [Test] public async Task GivenRateLimitExceeded_WhenInvoked_ReturnsTooManyRequests() { - // Setup client with a rate limit of 1 request per 5 seconds + // Create a new manager instance with the rate limit configuration var config = new ClientRateLimitConfig { ClientId = CLIENT_A, @@ -90,7 +91,15 @@ public async Task GivenRateLimitExceeded_WhenInvoked_ReturnsTooManyRequests() } } }; - _manager.AddRateLimitRules(new List { config }); + + // Create new manager with the config + _manager = new RateLimiterManager(new List { config }); + + // Create new middleware with the updated manager + _middleware = new RateLimiterMiddleware( + next: (context) => Task.CompletedTask, + _manager + ); _context.Request.Path = TEST_RESOURCE; _context.Request.Headers[HEADER_CLIENT_ID] = CLIENT_A; @@ -113,4 +122,4 @@ public async Task GivenRateLimitExceeded_WhenInvoked_ReturnsTooManyRequests() }); } } -} +} \ No newline at end of file diff --git a/RateLimiter.Tests/Services/RateLimiterManagerTests.cs b/RateLimiter.Tests/Services/RateLimiterManagerTests.cs index 0beb19ad..2ffa0655 100644 --- a/RateLimiter.Tests/Services/RateLimiterManagerTests.cs +++ b/RateLimiter.Tests/Services/RateLimiterManagerTests.cs @@ -10,8 +10,6 @@ namespace RateLimiter.Tests.Services { public class RateLimiterManagerTests { - private RateLimiterManager _manager; - private const string TEST_RESOURCE = "/api/test/resource"; private const string DIFFERENT_RESOURCE = "/different/resource"; private const string CLIENT_ID = "TestClient"; @@ -21,16 +19,11 @@ public class RateLimiterManagerTests private const int RATE_LIMIT_COUNT = 1; private static readonly TimeSpan RATE_LIMIT_DURATION = TimeSpan.FromSeconds(5); - [SetUp] - public void Setup() - { - _manager = new RateLimiterManager(); - } - [Test] public void GivenUnknownClient_WhenRequestMade_ReturnsFalse() { - var result = _manager.IsRequestAllowed(UNKNOWN_CLIENT, TEST_RESOURCE); + var manager = new RateLimiterManager(new List()); + var result = manager.IsRequestAllowed(UNKNOWN_CLIENT, TEST_RESOURCE); Assert.That(result.IsAllowed, Is.False); } @@ -38,9 +31,9 @@ public void GivenUnknownClient_WhenRequestMade_ReturnsFalse() public void GivenUnconfiguredResource_WhenRequestMade_ReturnsFalse() { var config = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); - _manager.AddRateLimitRules(new List { config }); + var manager = new RateLimiterManager(new List { config }); - var result = _manager.IsRequestAllowed(CLIENT_ID, DIFFERENT_RESOURCE); + var result = manager.IsRequestAllowed(CLIENT_ID, DIFFERENT_RESOURCE); Assert.That(result.IsAllowed, Is.False); } @@ -48,20 +41,20 @@ public void GivenUnconfiguredResource_WhenRequestMade_ReturnsFalse() public void GivenValidConfiguration_WhenRequestMade_AllowsRequests() { var config = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); - _manager.AddRateLimitRules(new List { config }); + var manager = new RateLimiterManager(new List { config }); - var result = _manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE); + var result = manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE); Assert.That(result.IsAllowed, Is.True); } [Test] - public void GivenDuplicateClientConfig_WhenAdded_ThrowsException() + public void GivenDuplicateClientConfig_WhenCreated_ThrowsException() { var config = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); - _manager.AddRateLimitRules(new List { config }); + var duplicateConfig = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); Assert.Throws(() => - _manager.AddRateLimitRules(new List { config })); + new RateLimiterManager(new List { config, duplicateConfig })); } [Test] @@ -84,13 +77,13 @@ public void GivenMultipleRules_WhenOneFails_RequestIsDenied() } }; - _manager.AddRateLimitRules(new List { config }); + var manager = new RateLimiterManager(new List { config }); // First request should pass both rules - Assert.That(_manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE).IsAllowed, Is.True); + Assert.That(manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE).IsAllowed, Is.True); // Second request should fail both rules - var result = _manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE); + var result = manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE); Assert.That(result.IsAllowed, Is.False); } @@ -113,4 +106,4 @@ private static ClientRateLimitConfig CreateTestConfig(string clientId, string re }; } } -} +} \ No newline at end of file diff --git a/RateLimiter/Program.cs b/RateLimiter/Program.cs index 3fa962c5..81e88bcd 100644 --- a/RateLimiter/Program.cs +++ b/RateLimiter/Program.cs @@ -44,8 +44,7 @@ }); }); -//Register Rate Limiter Services -var rateLimiterManager = new RateLimiterManager(); + var clientRegions = new List { @@ -75,7 +74,9 @@ } }).ToList(); -rateLimiterManager.AddRateLimitRules(clientRateLimits); +//Register Rate Limiter Services +var rateLimiterManager = new RateLimiterManager(clientRateLimits); + builder.Services.AddSingleton(rateLimiterManager); builder.Services.AddSingleton(); diff --git a/RateLimiter/Services/RateLimiterManager.cs b/RateLimiter/Services/RateLimiterManager.cs index 6b8cd2d5..5483fe6e 100644 --- a/RateLimiter/Services/RateLimiterManager.cs +++ b/RateLimiter/Services/RateLimiterManager.cs @@ -4,19 +4,24 @@ namespace RateLimiter.Services { public class RateLimiterManager { - private readonly List _clientRateLimits = new(); + private readonly IReadOnlyList _clientRateLimits; - public void AddRateLimitRules(List clientConfigs) + public RateLimiterManager(IEnumerable clientConfigs) { - foreach (var clientConfig in clientConfigs) + // Validate for duplicate client IDs + var duplicateClients = clientConfigs + .GroupBy(c => c.ClientId) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateClients.Any()) { - if (_clientRateLimits.Any(c => c.ClientId == clientConfig.ClientId)) - { - throw new ArgumentException($"Client ID '{clientConfig.ClientId}' is already registered."); - } - - _clientRateLimits.Add(clientConfig); + throw new ArgumentException( + $"Duplicate client IDs found: {string.Join(", ", duplicateClients)}"); } + + _clientRateLimits = clientConfigs.ToList(); } public RateLimitResult IsRequestAllowed(string clientId, string resource) From 9ac8a6de603eeab80665e5cfaa125813619fa092 Mon Sep 17 00:00:00 2001 From: ClubSpeedJustinG Date: Sun, 23 Feb 2025 17:05:13 -0800 Subject: [PATCH 4/5] changes --- .../Middleware/RateLimiterMiddleware.cs | 51 ++++++++++++++----- RateLimiter/Models/ErrorDetails.cs | 16 ++++++ RateLimiter/Models/ErrorResponse.cs | 12 +++++ RateLimiter/Models/RateLimitErrorCode.cs | 13 +++++ 4 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 RateLimiter/Models/ErrorDetails.cs create mode 100644 RateLimiter/Models/ErrorResponse.cs create mode 100644 RateLimiter/Models/RateLimitErrorCode.cs diff --git a/RateLimiter/Middleware/RateLimiterMiddleware.cs b/RateLimiter/Middleware/RateLimiterMiddleware.cs index 2ac11f59..ad0e3b40 100644 --- a/RateLimiter/Middleware/RateLimiterMiddleware.cs +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -1,6 +1,7 @@ -using RateLimiter.Services; +using RateLimiter.Models; +using RateLimiter.Services; using System.Text.Json; - +using System.Text.Json.Serialization; public class RateLimiterMiddleware { private readonly RequestDelegate _next; @@ -21,11 +22,15 @@ public async Task Invoke(HttpContext context) return; } - // Validate X-Client-Id header string clientId = context.Request.Headers["X-Client-Id"]; if (string.IsNullOrEmpty(clientId)) { - await RespondWithError(context, 400, "MISSING_CLIENT_ID", "Client ID is required in the 'X-Client-Id' header."); + await RespondWithError( + context, + StatusCodes.Status400BadRequest, + RateLimitErrorCode.MissingClientId, + "Client ID is required in the 'X-Client-Id' header." + ); return; } @@ -34,30 +39,48 @@ public async Task Invoke(HttpContext context) if (!rateLimitResult.IsAllowed) { - context.Response.StatusCode = 429; - context.Response.Headers["Retry-After"] = Math.Ceiling(rateLimitResult.RetryAfter.TotalSeconds).ToString(); - await RespondWithError(context, 429, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded. Try again later.", rateLimitResult.RetryAfter.TotalSeconds); + var retryAfterSeconds = Math.Ceiling(rateLimitResult.RetryAfter.TotalSeconds); + context.Response.Headers["Retry-After"] = retryAfterSeconds.ToString(); + + await RespondWithError( + context, + StatusCodes.Status429TooManyRequests, + RateLimitErrorCode.RateLimitExceeded, + "Rate limit exceeded. Try again later.", + retryAfterSeconds + ); return; } await _next(context); } - private static async Task RespondWithError(HttpContext context, int statusCode, string errorCode, string message, double? retryAfter = null) + private static async Task RespondWithError( + HttpContext context, + int statusCode, + RateLimitErrorCode errorCode, + string message, + double? retryAfter = null) { context.Response.StatusCode = statusCode; context.Response.ContentType = "application/json"; - var errorResponse = new + var errorResponse = new ErrorResponse { - error = new + Error = new ErrorDetails { - code = errorCode, - message = message, - retry_after = Math.Ceiling(retryAfter ?? 0) // Ensures no long decimal places + Code = errorCode, + Message = message, + RetryAfter = retryAfter } }; - await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse)); + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, options)); } } diff --git a/RateLimiter/Models/ErrorDetails.cs b/RateLimiter/Models/ErrorDetails.cs new file mode 100644 index 00000000..64569ae0 --- /dev/null +++ b/RateLimiter/Models/ErrorDetails.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace RateLimiter.Models +{ + public class ErrorDetails + { + [JsonPropertyName("code")] + public RateLimitErrorCode Code { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } + + [JsonPropertyName("retry_after")] + public double? RetryAfter { get; set; } + } +} diff --git a/RateLimiter/Models/ErrorResponse.cs b/RateLimiter/Models/ErrorResponse.cs new file mode 100644 index 00000000..e54a82c4 --- /dev/null +++ b/RateLimiter/Models/ErrorResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace RateLimiter.Models +{ + public class ErrorResponse + { + [JsonPropertyName("error")] + public ErrorDetails Error { get; set; } + + + } +} diff --git a/RateLimiter/Models/RateLimitErrorCode.cs b/RateLimiter/Models/RateLimitErrorCode.cs new file mode 100644 index 00000000..148b9650 --- /dev/null +++ b/RateLimiter/Models/RateLimitErrorCode.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace RateLimiter.Models +{ + public enum RateLimitErrorCode + { + [JsonPropertyName("MISSING_CLIENT_ID")] + MissingClientId, + + [JsonPropertyName("RATE_LIMIT_EXCEEDED")] + RateLimitExceeded + } +} From d6b7403ef706d15ac5f5896c2c50b882ce7b6395 Mon Sep 17 00:00:00 2001 From: ClubSpeedJustinG Date: Sun, 23 Feb 2025 17:10:09 -0800 Subject: [PATCH 5/5] changes --- RateLimiter/Models/ClientRateLimitConfig.cs | 2 +- RateLimiter/Models/ResourceRateLimitConfig.cs | 2 +- RateLimiter/Services/Rules/FixedWindowRateLimit.cs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/RateLimiter/Models/ClientRateLimitConfig.cs b/RateLimiter/Models/ClientRateLimitConfig.cs index ae238e9e..af364b8e 100644 --- a/RateLimiter/Models/ClientRateLimitConfig.cs +++ b/RateLimiter/Models/ClientRateLimitConfig.cs @@ -9,6 +9,6 @@ namespace RateLimiter.Models public class ClientRateLimitConfig { public string ClientId { get; set; } - public List ResourceLimits { get; set; } = new(); + public List ResourceLimits { get; set; } = new List { }; } } diff --git a/RateLimiter/Models/ResourceRateLimitConfig.cs b/RateLimiter/Models/ResourceRateLimitConfig.cs index 2d0f9368..23c63e4e 100644 --- a/RateLimiter/Models/ResourceRateLimitConfig.cs +++ b/RateLimiter/Models/ResourceRateLimitConfig.cs @@ -10,6 +10,6 @@ namespace RateLimiter.Models public class ResourceRateLimitConfig { public string Resource { get; set; } - public List Rules { get; set; } = new(); + public List Rules { get; set; } = new List(); } } diff --git a/RateLimiter/Services/Rules/FixedWindowRateLimit.cs b/RateLimiter/Services/Rules/FixedWindowRateLimit.cs index 745a5b2c..9f21f689 100644 --- a/RateLimiter/Services/Rules/FixedWindowRateLimit.cs +++ b/RateLimiter/Services/Rules/FixedWindowRateLimit.cs @@ -8,8 +8,8 @@ /// 4:27:30 - Allowed (3/5) /// 4:27:45 - Allowed (4/5) /// 4:27:55 - Allowed (5/5) -/// 4:27:58 - Blocked! (Wait until 4:28:00) -/// At 4:28:00, requests reset. +/// 4:27:58 - Blocked! (Wait until 4:28:10) +/// At 4:28:10, requests reset. /// public class FixedWindowRateLimit : IRateLimitRule {