RedisQuota.php 1.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
  1. <?php
  2. declare(strict_types=1);
  3. namespace app\miniapi\protection;
  4. use support\Redis;
  5. /** All-or-nothing counters with server-side expiry and one denial notice per window. */
  6. final class RedisQuota
  7. {
  8. private const SCRIPT = <<<'LUA'
  9. local values = {}
  10. local blocked = 0
  11. local retry = 0
  12. for i, key in ipairs(KEYS) do
  13. local offset = (i - 1) * 3
  14. local limit = tonumber(ARGV[offset + 1])
  15. local seconds = tonumber(ARGV[offset + 2])
  16. local cost = tonumber(ARGV[offset + 3])
  17. local count = tonumber(redis.call('GET', key) or '0')
  18. local ttl = redis.call('TTL', key)
  19. if ttl < 0 then count = 0 end
  20. values[i] = count
  21. if count + cost > limit and math.max(1, ttl) > retry then
  22. blocked = i
  23. retry = math.max(1, ttl)
  24. end
  25. end
  26. if blocked > 0 then
  27. local notice = redis.call('SET', KEYS[blocked] .. ':notice', '1', 'EX', retry, 'NX')
  28. return {blocked, retry, notice and 1 or 0}
  29. end
  30. for i, key in ipairs(KEYS) do
  31. local offset = (i - 1) * 3
  32. if values[i] == 0 then
  33. redis.call('SET', key, ARGV[offset + 3], 'EX', ARGV[offset + 2])
  34. else
  35. redis.call('INCRBY', key, ARGV[offset + 3])
  36. end
  37. end
  38. return {0, 0, 0}
  39. LUA;
  40. /** @param array<array{key:string,limit:int,seconds:int,cost:int,rule:string}> $buckets */
  41. public function consume(array $buckets): array
  42. {
  43. $keys = array_column($buckets, 'key');
  44. $arguments = [];
  45. foreach ($buckets as $bucket) {
  46. if ($bucket['limit'] < 1 || $bucket['seconds'] < 1 || $bucket['cost'] < 1 || $bucket['cost'] > $bucket['limit']) {
  47. throw new \LogicException('Invalid protection quota configuration');
  48. }
  49. array_push($arguments, $bucket['limit'], $bucket['seconds'], $bucket['cost']);
  50. }
  51. return Redis::connection(config('mini_protection.connection', 'default'))
  52. ->eval(self::SCRIPT, count($keys), ...array_merge($keys, $arguments));
  53. }
  54. }