Documentation

How to implement a Fixed Window Rate Limiting app using ASP.NET Core & Redis

Steve Lorello
Author
Steve Lorello, Senior Field Engineer at Redis

Prerequisites#

Startup Redis#

docker run -dp 6379:6379 redis

Create Project#

dotnet new webapi -n FixedRateLimiter --no-https
 namespace FixedRateLimiter.Controllers
 {
    [ApiController]
    [Route("api/[controller]")]
    public class RateLimitedController : ControllerBase
    {

    }
 }

Initialize The Multiplexer#

services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost"));

Inject the ConnectionMultiplexer#

 private readonly IDatabase _db;
 public RateLimitedController(IConnectionMultiplexer mux)
 {
    _db = mux.GetDatabase();
 }

Add a Simple Route#

 [HttpPost("simple")]
 public async Task<IActionResult> Simple([FromHeader]string authorization)
 {
    var encoded = string.Empty;
    if(!string.IsNullOrEmpty(authorization)) encoded = AuthenticationHeaderValue.Parse(authorization).Parameter;
    if (string.IsNullOrEmpty(encoded)) return new UnauthorizedResult();
    var apiKey = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)).Split(':')[0];
    return Ok();
 }
curl -X POST -H "Content-Length: 0" --user "foobar:password" http://localhost:5000/api/RateLimited/simple

Fixed Window Rate Limiting Lua Script#

  1. 1.
  2. 2.
  3. 3.
  4. 4.
  5. 5.
 local key = KEYS[1]
 local max_requests = tonumber(ARGV[1])
 local expiry = tonumber(ARGV[2])
 local requests = redis.call('INCR',key)
 redis.call('EXPIRE', key, expiry)
 if requests < max_requests then
     return 0
 else
    return 1
 end
 local requests = redis.call('INCR',@key)
 redis.call('EXPIRE', @key, @expiry)
 if requests < tonumber(@maxRequests) then
     return 0
 else
     return 1
 end

Loading the Script#

 using StackExchange.Redis;
 namespace FixedRateLimiter
 {
     public static class Scripts
     {
         public static LuaScript RateLimitScript => LuaScript.Prepare(RATE_LIMITER);

        private const string RATE_LIMITER = @"
            local requests = redis.call('INCR',@key)
            redis.call('EXPIRE', @key, @expiry)
            if requests < tonumber(@maxRequests) then
                return 0
            else
                return 1
            end
            ";
    }
 }

Executing the Script#

 var script = Scripts.RateLimitScript;
 var key = $"{Request.Path.Value}:{apiKey}:{DateTime.Now:hh:mm}";
 var res = await _db.ScriptEvaluateAsync(script, new {key = new RedisKey(key), expiry = 60, maxRequests = 10});
 if ((int) res == 1)
     return new StatusCodeResult(429);
 [HttpPost("simple")]
 public async Task<IActionResult> Simple([FromHeader]string authorization)
 {
     var encoded = string.Empty;
    if(!string.IsNullOrEmpty(authorization)) encoded = AuthenticationHeaderValue.Parse(authorization).Parameter;
    if (string.IsNullOrEmpty(encoded)) return new UnauthorizedResult();
    var apiKey = Encoding.UTF8.GetString(Convert.FromBase64String(encoded)).Split(':')[0];
    var script = Scripts.RateLimitScript;
    var key = $"{Request.Path.Value}:{apiKey}:{DateTime.UtcNow:hh:mm}";
    var res = await _db.ScriptEvaluateAsync(script, new {key = new RedisKey(key), expiry = 60, maxRequests = 10});
    if ((int) res == 1)
        return new StatusCodeResult(429);
    return new JsonResult(new {key});
 }
for n in {1..21}; do echo $(curl -s -w " HTTP %{http_code}, %{time_total} s" -X POST -H "Content-Length: 0" --user "foobar:password" http://localhost:5000/api/ratelimited/simple); sleep 0.5; done
 HTTP 200, 0.002680 s
 HTTP 200, 0.001535 s
 HTTP 200, 0.001653 s
 HTTP 200, 0.001449 s
 HTTP 200, 0.001604 s
 HTTP 200, 0.001423 s
 HTTP 200, 0.001492 s
 HTTP 200, 0.001449 s
 HTTP 200, 0.001551 s
 {"status":429,"traceId":"00-16e9da63f77c994db719acff5333c509-f79ac0c862c5a04c-00"} HTTP 429, 0.001803 s
 {"status":429,"traceId":"00-3d2e4e8af851024db121935705d5425f-0e23eb80eae0d549-00"} HTTP 429, 0.001521 s
 {"status":429,"traceId":"00-b5e824c9ebc4f94aa0bda2a414afa936-8020a7b8f2845544-00"} HTTP 429, 0.001475 s
 {"status":429,"traceId":"00-bd6237c5d0362a409c436dcffd0d4a7a-87b544534f397247-00"} HTTP 429, 0.001549 s
 {"status":429,"traceId":"00-532d64033c54a148a98d8efe1f9f53b2-b1dbdc7d8fbbf048-00"} HTTP 429, 0.001476 s
 {"status":429,"traceId":"00-8c210b1c1178554fb10aa6a7540d3488-0fedba48e38fdd4b-00"} HTTP 429, 0.001606 s
 {"status":429,"traceId":"00-633178f569dc8c46badb937c0363cda8-ab1d1214b791644d-00"} HTTP 429, 0.001661 s
 {"status":429,"traceId":"00-12f01e448216c64b8bfe674f242a226f-d90ff362926aa14e-00"} HTTP 429, 0.001858 s
 {"status":429,"traceId":"00-63ef51cee3bcb6488b04395f09d94def-be9e4d6d6057754a-00"} HTTP 429, 0.001622 s
 {"status":429,"traceId":"00-80a971db60fdf543941e2457e35ac2fe-3555f5cb9c907e4c-00"} HTTP 429, 0.001710 s
 {"status":429,"traceId":"00-f718734ae0285343ac927df617eeef92-91a49e127f2e4245-00"} HTTP 429, 0.001582 s
 {"status":429,"traceId":"00-9da2569cce4d714480dd4f0edc0506d2-8a1ce375b1a9504f-00"} HTTP 429, 0.001629 s

Resources#