File Coverage

blib/lib/Redis/RateLimit.pm
Criterion Covered Total %
statement 36 117 30.7
branch 1 24 4.1
condition 1 10 10.0
subroutine 12 33 36.3
pod 9 9 100.0
total 59 193 30.5


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