using Application.Abstractions.RateLimit;
using StackExchange.Redis;
namespace Infrastructure.RateLimit;
///
/// Redis sorted-set 기반 sliding window rate limiter.
/// Lua script 로 원자성 보장 (read-modify-write 한 번에).
///
public sealed class RedisSlidingWindowLimiter(IConnectionMultiplexer redis) : IRateLimiter
{
private readonly IDatabase _db = redis.GetDatabase();
private const string LuaScript = @"
local key = KEYS[1]
local now = tonumber(ARGV[1])
local minScore = tonumber(ARGV[2])
local windowSeconds = tonumber(ARGV[3])
local limit = tonumber(ARGV[4])
local member = ARGV[5]
redis.call('ZREMRANGEBYSCORE', key, '-inf', minScore)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, windowSeconds)
return { 1, limit - count - 1 }
else
return { 0, 0 }
end
";
public async Task CheckAsync(string key, int limit, TimeSpan window, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var windowMs = (long)window.TotalMilliseconds;
var minScore = now - windowMs;
var fullKey = $"ratelimit:{key}";
var uniqueMember = $"{now}:{Guid.NewGuid():N}";
var raw = await _db.ScriptEvaluateAsync(
LuaScript,
keys: [fullKey],
values: [now, minScore, (long)window.TotalSeconds, limit, uniqueMember]
);
var array = (RedisResult[])raw!;
var allowed = (long)array[0] == 1;
var remaining = (int)(long)array[1];
var resetAt = DateTimeOffset.FromUnixTimeMilliseconds(now + windowMs).UtcDateTime;
return new RateLimitResult(allowed, limit, remaining, resetAt);
}
}