File Coverage

blib/lib/Redis/RateLimit.pm
Criterion Covered Total %
statement 32 113 28.3
branch 0 22 0.0
condition 0 10 0.0
subroutine 11 30 36.6
pod 9 9 100.0
total 52 184 28.2


line stmt bran cond sub pod time code
1             package Redis::RateLimit;
2             # ABSTRACT: Sliding window rate limiting with Redis
3             $Redis::RateLimit::VERSION = '1.0000';
4 1     1   1149249 use 5.14.1;
  1         5  
5 1     1   1080 use Moo;
  1         20752  
  1         7  
6 1     1   2407 use Carp;
  1         2  
  1         90  
7 1     1   8 use Digest::SHA1 qw/sha1_hex/;
  1         1  
  1         68  
8 1     1   643 use File::Share qw/dist_file/;
  1         10026  
  1         117  
9 1     1   1091 use File::Slurp::Tiny qw/read_file/;
  1         4210  
  1         102  
10 1     1   888 use JSON::MaybeXS;
  1         6521  
  1         105  
11 1     1   13 use List::Util qw/any max min/;
  1         4  
  1         174  
12 1     1   1270 use Redis;
  1         29105  
  1         70  
13 1     1   1133 use Redis::ScriptCache;
  1         6823  
  1         61  
14 1     1   878 use namespace::clean;
  1         14922  
  1         8  
15              
16             #pod =attr redis
17             #pod
18             #pod Redis client. If none is provided, a default is constructed for 127.0.0.1:6379.
19             #pod
20             #pod =cut
21              
22             has redis => (
23             is => 'ro',
24             lazy => 1,
25             default => sub { Redis->new },
26             handles => { map +( "redis_$_" => $_ ), qw/hget keys sadd srem/ },
27             );
28              
29             #pod =attr prefix
30             #pod
31             #pod A prefix to be included on each redis key. This prevents collisions with
32             #pod multiple applications using the same Redis DB. Defaults to 'ratelimit'.
33             #pod
34             #pod =cut
35              
36             has prefix => (
37             is => 'ro',
38             lazy => 1,
39             default => sub { 'ratelimit' },
40             );
41              
42             #pod =attr client_prefix
43             #pod
44             #pod Set this to a true value if using a Redis client that supports transparent
45             #pod prefixing. Defaults to 0.
46             #pod
47             #pod =cut
48              
49             has client_prefix => (
50             is => 'ro',
51             default => sub { 0 },
52             );
53              
54             #pod =attr rules
55             #pod
56             #pod An arrayref of rules, each of which is a hashref with C, C,
57             #pod and optionally C values.
58             #pod
59             #pod =cut
60              
61             has rules => (
62             is => 'ro',
63             required => 1,
64             );
65              
66             around BUILDARGS => sub {
67             my ( $next, $self ) = splice @_, 0, 2;
68              
69             my $args = $self->$next(@_);
70             my $rules = delete $args->{rules};
71             $args->{rules} = [
72             map [ grep defined, @{$_}{qw/interval limit precision/} ], @$rules
73             ];
74              
75             return $args;
76             };
77              
78             # Note: 1 is returned for a normal rate limited action, 2 is returned for a
79             # blacklisted action. Must sync with return codes in lua/check_limit.lua
80 0     0     sub _DENIED_NUMS { (1, 2) }
81              
82             sub _read_lua {
83 0     0     my ( $self, $filename ) = @_;
84              
85 0           my $path = dist_file('Redis-RateLimit', "$filename.lua");
86 0           read_file($path, binmode => ':utf8');
87             }
88              
89             sub _check_limit_script {
90 0     0     my $self = shift;
91              
92 0           join("\n", map(
93             $self->_read_lua($_), qw/
94             unpack_args
95             check_whitelist_blacklist
96             check_limit
97             /),
98             'return 0'
99             );
100             }
101              
102             sub _check_limit_incr_script {
103 0     0     my $self = shift;
104              
105 0           join("\n", map(
106             $self->_read_lua($_), qw/
107             unpack_args
108             check_whitelist_blacklist
109             check_limit
110             check_incr_limit
111             /),
112             );
113             }
114              
115             has _script_cache => (
116             is => 'ro',
117             lazy => 1,
118             builder => '_build__script_cache',
119             handles => {
120             exec => 'run_script',
121             },
122             );
123              
124             sub _build__script_cache {
125 0     0     my $self = shift;
126              
127 0           my $cache = Redis::ScriptCache->new(redis_conn => $self->redis);
128 0           $cache->register_script(check_rate_limit => $self->_check_limit_script);
129 0           $cache->register_script(check_limit_incr => $self->_check_limit_incr_script);
130              
131 0           return $cache;
132             }
133              
134             # transform exec arguments to the from expected by Redis::ScriptCache
135             around exec => sub {
136             my ( $next, $self, $name, $keys, $args ) = @_;
137             my @keys = @{ $keys // [] };
138             my @args = @{ $args // [] };
139              
140             $self->$next($name, [ 0+@keys, @keys, @args ]);
141             };
142              
143             has _json_encoder => (
144             is => 'ro',
145             default => sub { JSON::MaybeXS->new(utf8 => 1) },
146             handles => {
147             json_encode => 'encode',
148             },
149             );
150              
151             has _whitelist_key => (
152             is => 'ro',
153             default => sub { shift->_prefix_key(whitelist => 1) },
154             );
155              
156             has _blacklist_key => (
157             is => 'ro',
158             default => sub { shift->_prefix_key(blacklist => 1) },
159             );
160              
161             sub _prefix_key {
162 0     0     my ( $self, $key, $force ) = @_;
163              
164 0           my @parts = $key;
165              
166             # Support prefixing with an optional `force` argument, but omit prefix by
167             # default if the client library supports transparent prefixing.
168 0 0 0       unshift @parts, $self->prefix if $force || !$self->client_prefix;
169              
170             # The compact handles a falsy prefix
171             #_.compact(parts).join ':'
172 0           join ':', @parts;
173             }
174              
175             sub _script_args {
176 0     0     my ( $self, $keys, $weight ) = @_;
177 0   0       $weight //= 1;
178              
179 0           my @adjusted_keys = map $self->_prefix_key($_), grep length, @$keys;
180 0 0         croak "Bad keys: @$keys" unless @adjusted_keys;
181              
182 0           my $rules = $self->json_encode($self->rules);
183 0           $weight = max($weight, 1);
184             return (
185 0           \@adjusted_keys,
186             [ $rules, time, $weight, $self->_whitelist_key, $self->_blacklist_key ],
187             );
188             }
189              
190             #pod =method check($key | \@keys)
191             #pod
192             #pod Returns true if any of the keys are rate limited.
193             #pod
194             #pod =cut
195              
196             sub check {
197 0     0 1   my $self = shift;
198 0 0         my $keys = ref $_[0] ? shift : \@_;
199              
200 0           ( $keys, my $args ) = $self->_script_args($keys);
201              
202 0           my $result = $self->exec(check_rate_limit => $keys, $args);
203 0     0     return any { $result == $_ } _DENIED_NUMS;
  0            
204             }
205              
206             #pod =method incr($key | \@keys [, $weight ])
207             #pod
208             #pod Returns true if any of the keys are rate limited, otherwise, it increments
209             #pod counts and returns false.
210             #pod
211             #pod =cut
212              
213             sub incr {
214 0     0 1   my ( $self, $keys, $weight ) = @_;
215 0 0         $keys = [ $keys ] unless ref $keys;
216              
217 0           ( $keys, my $args ) = $self->_script_args($keys, $weight);
218              
219 0           my $result = $self->exec(check_limit_incr => $keys, $args);
220 0     0     return any { $result == $_ } _DENIED_NUMS;
  0            
221             }
222              
223             #pod =method keys
224             #pod
225             #pod Returns all of the rate limiter's with prefixes removed.
226             #pod
227             #pod =cut
228              
229             sub keys {
230 0     0 1   my $self = shift;
231              
232 0           my @results = $self->redis_keys($self->_prefix_key('*'));
233 0           my $re = $self->_prefix_key('(.+)');
234 0           map /^$re/, @results;
235             }
236              
237             #pod =method violated_rules($key | \@keys)
238             #pod
239             #pod Returns a list of rate limit rules violated for any of the keys, or an empty
240             #pod list.
241             #pod
242             #pod =cut
243              
244             sub violated_rules {
245 0     0 1   my $self = shift;
246 0 0         my $keys = ref $_[0] ? shift : \@_;
247              
248             my $check_key = sub {
249 0     0     my $key = shift;
250              
251             my $check_rule = sub {
252 0           my $rule = shift;
253             # Note: this mirrors precision computation in `check_limit.lua`
254             # on lines 7 and 8 and count key construction on line 16
255 0           my ( $interval, $limit, $precision ) = @$rule;
256 0   0       $precision = min($precision // $interval, $interval);
257 0           my $count_key = "$interval:$precision:";
258              
259 0           my $count = $self->redis_hget($self->_prefix_key($key), $count_key);
260 0   0       $count //= -1;
261 0 0         return unless $count >= $limit;
262              
263 0           return { interval => $interval, limit => $limit };
264 0           };
265              
266 0           map $check_rule->($_), @{ $self->rules };
  0            
267 0           };
268              
269 0           return map $check_key->($_), @$keys;
270             }
271              
272             #pod =method limited_keys($key | \@keys)
273             #pod
274             #pod Returns a list of limited keys.
275             #pod
276             #pod =cut
277              
278             sub limited_keys {
279 0     0 1   my $self = shift;
280 0 0         my $keys = ref $_[0] ? shift : \@_;
281              
282 0           grep $self->check($_), @$keys;
283             }
284              
285             #pod =method whitelist($key | \@keys)
286             #pod
287             #pod Adds the keys to the whitelist so they are never rate limited.
288             #pod
289             #pod =cut
290              
291             sub whitelist {
292 0     0 1   my $self = shift;
293 0 0         my $keys = ref $_[0] ? shift : \@_;
294              
295 0           for ( @$keys ) {
296 0           my $key = $self->_prefix_key($_);
297 0           $self->redis_srem($self->_blacklist_key, $key);
298 0           $self->redis_sadd($self->_whitelist_key, $key);
299             }
300             }
301              
302             #pod =method unwhitelist($key | \@keys)
303             #pod
304             #pod Removes the keys from the whitelist.
305             #pod
306             #pod =cut
307              
308             sub unwhitelist {
309 0     0 1   my $self = shift;
310 0 0         my $keys = ref $_[0] ? shift : \@_;
311              
312 0           for ( @$keys ) {
313 0           my $key = $self->_prefix_key($_);
314 0           $self->redis_srem($self->_whitelist_key, $key);
315             }
316             }
317              
318             #pod =method blacklist($key | \@keys)
319             #pod
320             #pod Adds the keys to the blacklist so they are always rate limited.
321             #pod
322             #pod =cut
323              
324             sub blacklist {
325 0     0 1   my $self = shift;
326 0 0         my $keys = ref $_[0] ? shift : \@_;
327              
328 0           for ( @$keys ) {
329 0           my $key = $self->_prefix_key($_);
330 0           $self->redis_srem($self->_whitelist_key, $key);
331 0           $self->redis_sadd($self->_blacklist_key, $key);
332             }
333             }
334              
335             #pod =method unblacklist($key | \@keys)
336             #pod
337             #pod Removes the keys from the blacklist.
338             #pod
339             #pod =cut
340              
341             sub unblacklist {
342 0     0 1   my $self = shift;
343 0 0         my $keys = ref $_[0] ? shift : \@_;
344              
345 0           for ( @$keys ) {
346 0           my $key = $self->_prefix_key($_);
347 0           $self->redis_srem($self->_blacklist_key, $key);
348             }
349             }
350              
351             1;
352              
353             __END__