Files
2026-10-14 06:59:51 +02:00

90 lines
2.8 KiB
C#

using System.Collections.Concurrent;
public static class RateLimit
{
private static readonly ConcurrentDictionary<string, DateTime> _requests = new();
public static bool TryConsume(
string key,
int cooldownSeconds,
out int retryAfterSeconds)
{
retryAfterSeconds = 0;
var now = DateTime.UtcNow;
var last = _requests.GetOrAdd(key, _ => DateTime.MinValue);
var elapsed = (now - last).TotalSeconds;
if (elapsed < cooldownSeconds)
{
retryAfterSeconds = (int)Math.Ceiling(cooldownSeconds - elapsed);
return false;
}
_requests[key] = now;
Cleanup(now);
return true;
}
private static void Cleanup(DateTime now)
{
if (_requests.Count < 10_000) return;
foreach (var kv in _requests)
{
if ((now - kv.Value).TotalMinutes > 10)
_requests.TryRemove(kv.Key, out _);
}
}
// ---- sliding-window quota ------------------------------------------------------------
//
// TryConsume above is a COOLDOWN: a minimum gap between two calls. On its own that does
// not stop abuse - someone can keep uploading forever as long as they wait out the gap.
// For anything that writes files to disk you want both: a cooldown to stop bursts, and a
// quota to cap the total over a window.
private static readonly ConcurrentDictionary<string, List<DateTime>> _windows = new();
private static readonly object _windowLock = new();
/// <summary>
/// Allows at most <paramref name="maxInWindow"/> calls per key per window. Returns false
/// (with seconds until the oldest call ages out) once the quota is spent.
/// </summary>
public static bool TryConsumeQuota(
string key,
int maxInWindow,
int windowSeconds,
out int retryAfterSeconds)
{
retryAfterSeconds = 0;
var now = DateTime.UtcNow;
var cutoff = now.AddSeconds(-windowSeconds);
// Lock rather than lock-free: the read-modify-write below is not atomic, and without
// it two concurrent uploads can both see a non-full window and both be admitted.
lock (_windowLock)
{
var stamps = _windows.GetOrAdd(key, _ => new List<DateTime>());
stamps.RemoveAll(t => t < cutoff);
if (stamps.Count >= maxInWindow)
{
retryAfterSeconds = (int)Math.Ceiling((stamps[0] - cutoff).TotalSeconds);
if (retryAfterSeconds < 1) retryAfterSeconds = 1;
return false;
}
stamps.Add(now);
if (_windows.Count > 10_000)
{
foreach (var kv in _windows)
if (kv.Value.Count == 0) _windows.TryRemove(kv.Key, out _);
}
return true;
}
}
}