File Coverage

blib/lib/Redis/RateLimit.pm
Criterion Covered Total %
statement 32 117 27.3
branch 0 24 0.0
condition 0 10 0.0
subroutine 11 33 33.3
pod 9 9 100.0
total 52 193 26.9


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