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..1eb7b5a1 --- /dev/null +++ b/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs @@ -0,0 +1,125 @@ +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() + { + // 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 + ); + _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() + { + // Create a new manager instance with the rate limit configuration + var config = new ClientRateLimitConfig + { + ClientId = CLIENT_A, + ResourceLimits = new List + { + new ResourceRateLimitConfig + { + Resource = TEST_RESOURCE, + Rules = new List + { + new FixedWindowRateLimit(1, TimeSpan.FromSeconds(5)) + } + } + } + }; + + // 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; + + // 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); + }); + } + } +} \ No newline at end of file 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 diff --git a/RateLimiter.Tests/Services/RateLimiterManagerTests.cs b/RateLimiter.Tests/Services/RateLimiterManagerTests.cs new file mode 100644 index 00000000..2ffa0655 --- /dev/null +++ b/RateLimiter.Tests/Services/RateLimiterManagerTests.cs @@ -0,0 +1,109 @@ +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 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); + + [Test] + public void GivenUnknownClient_WhenRequestMade_ReturnsFalse() + { + var manager = new RateLimiterManager(new List()); + 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); + var manager = new RateLimiterManager(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); + var manager = new RateLimiterManager(new List { config }); + + var result = manager.IsRequestAllowed(CLIENT_ID, TEST_RESOURCE); + Assert.That(result.IsAllowed, Is.True); + } + + [Test] + public void GivenDuplicateClientConfig_WhenCreated_ThrowsException() + { + var config = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); + var duplicateConfig = CreateTestConfig(CLIENT_ID, TEST_RESOURCE); + + Assert.Throws(() => + new RateLimiterManager(new List { config, duplicateConfig })); + } + + [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) + } + } + } + }; + + var manager = new RateLimiterManager(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) + } + } + } + }; + } + } +} \ No newline at end of file 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..ad0e3b40 --- /dev/null +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -0,0 +1,86 @@ +using RateLimiter.Models; +using RateLimiter.Services; +using System.Text.Json; +using System.Text.Json.Serialization; +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; + } + + string clientId = context.Request.Headers["X-Client-Id"]; + if (string.IsNullOrEmpty(clientId)) + { + await RespondWithError( + context, + StatusCodes.Status400BadRequest, + RateLimitErrorCode.MissingClientId, + "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) + { + 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, + RateLimitErrorCode errorCode, + string message, + double? retryAfter = null) + { + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + + var errorResponse = new ErrorResponse + { + Error = new ErrorDetails + { + Code = errorCode, + Message = message, + RetryAfter = retryAfter + } + }; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse, options)); + } +} diff --git a/RateLimiter/Models/ClientRateLimitConfig.cs b/RateLimiter/Models/ClientRateLimitConfig.cs new file mode 100644 index 00000000..af364b8e --- /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 List { }; + } +} 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/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/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/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 + } +} 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..23c63e4e --- /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 List(); + } +} diff --git a/RateLimiter/Program.cs b/RateLimiter/Program.cs new file mode 100644 index 00000000..81e88bcd --- /dev/null +++ b/RateLimiter/Program.cs @@ -0,0 +1,101 @@ +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() + } + }); +}); + + + +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(); + +//Register Rate Limiter Services +var rateLimiterManager = new RateLimiterManager(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..5483fe6e --- /dev/null +++ b/RateLimiter/Services/RateLimiterManager.cs @@ -0,0 +1,55 @@ +using RateLimiter.Models; + +namespace RateLimiter.Services +{ + public class RateLimiterManager + { + private readonly IReadOnlyList _clientRateLimits; + + public RateLimiterManager(IEnumerable 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()) + { + throw new ArgumentException( + $"Duplicate client IDs found: {string.Join(", ", duplicateClients)}"); + } + + _clientRateLimits = clientConfigs.ToList(); + } + + 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..9f21f689 --- /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:10) +/// At 4:28:10, 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 }; + } + } + } +}