using Microsoft.Extensions.Caching.Distributed; using QRRapidoApp.Models; namespace QRRapidoApp.Services { /// /// Fixed-window rate limiter backed by IDistributedCache (works with MemoryCache or Redis). /// Minor race conditions at window boundaries are acceptable for rate limiting purposes. /// public class ApiRateLimitService : IApiRateLimitService { private readonly IDistributedCache _cache; private readonly ILogger _logger; public ApiRateLimitService(IDistributedCache cache, ILogger logger) { _cache = cache; _logger = logger; } public async Task CheckAndIncrementAsync(string keyPrefix, ApiPlanTier tier) { var (perMin, perMonth) = ApiPlanLimits.GetLimits(tier); var now = DateTime.UtcNow; var minuteKey = $"rl:min:{keyPrefix}:{now:yyyyMMddHHmm}"; var monthKey = $"rl:mo:{keyPrefix}:{now:yyyyMM}"; var nextMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc) .AddMinutes(1); var resetTimestamp = ((DateTimeOffset)nextMinute).ToUnixTimeSeconds(); var minStr = await _cache.GetStringAsync(minuteKey); var moStr = await _cache.GetStringAsync(monthKey); int minCount = int.TryParse(minStr, out var mc) ? mc : 0; int moCount = int.TryParse(moStr, out var mo) ? mo : 0; bool monthlyExceeded = false; bool allowed = true; if (tier != ApiPlanTier.Enterprise) { if (moCount >= perMonth) { monthlyExceeded = true; allowed = false; } else if (minCount >= perMin) { allowed = false; } } if (allowed) { var minOpts = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) }; var moOpts = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(33) }; await Task.WhenAll( _cache.SetStringAsync(minuteKey, (minCount + 1).ToString(), minOpts), _cache.SetStringAsync(monthKey, (moCount + 1).ToString(), moOpts) ); } int reportedMinLimit = perMin == int.MaxValue ? -1 : perMin; int reportedMoLimit = perMonth == int.MaxValue ? -1 : perMonth; int usedMin = minCount + (allowed ? 1 : 0); int usedMo = moCount + (allowed ? 1 : 0); return new RateLimitResult( Allowed: allowed, PerMinuteLimit: reportedMinLimit, PerMinuteUsed: usedMin, ResetUnixTimestamp: resetTimestamp, MonthlyLimit: reportedMoLimit, MonthlyUsed: usedMo, MonthlyExceeded: monthlyExceeded ); } } }