File Coverage

blib/lib/RateLimitations.pm
Criterion Covered Total %
statement 69 109 63.3
branch 5 16 31.2
condition 5 17 29.4
subroutine 14 20 70.0
pod 6 6 100.0
total 99 168 58.9


line stmt bran cond sub pod time code
1             package RateLimitations;
2              
3 2     2   168800 use strict;
  2         2  
  2         46  
4 2     2   6 use warnings;
  2         2  
  2         58  
5             our $VERSION = '0.05';
6              
7 2     2   6 use Carp;
  2         4  
  2         72  
8              
9 2     2   736 use Cache::RedisDB;
  2         109928  
  2         54  
10 2     2   788 use Config::Onion;
  2         61532  
  2         72  
11 2     2   10 use List::Util qw(all);
  2         4  
  2         140  
12 2     2   826 use Time::Duration::Concise;
  2         7612  
  2         52  
13              
14 2     2   10 use base qw( Exporter );
  2         4  
  2         184  
15             our @EXPORT_OK = qw(
16             all_service_consumers
17             flush_all_service_consumers
18             rate_limited_services
19             rate_limits_for_service
20             verify_rate_limitations_config
21             within_rate_limits
22             );
23 2     2   8 use constant KEYSPACE => 'RATELIMITATIONS'; # Everything will fall under this
  2         0  
  2         86  
24 2     2   22 use constant SEPARATOR => '::'; # How to join strings together
  2         2  
  2         316  
25              
26             my $rates_file_content;
27             my %limits;
28              
29             BEGIN {
30 2     2   10 my $cfg = Config::Onion->new;
31 2         2148 $cfg->set_default(
32             rl_internal_testing => {
33             '10s' => 2,
34             '5m' => 6
35             });
36 2         598 $cfg->load('/etc/perl_rate_limitations', '/etc/rmg/perl_rate_limitations');
37 2         37356 $rates_file_content = $cfg->get;
38 2         848 foreach my $svc (sort keys %$rates_file_content) {
39 2         2 my @service_limits;
40 2         4 foreach my $time (keys %{$rates_file_content->{$svc}}) {
  2         6  
41 4         122 my $ti = Time::Duration::Concise->new(interval => $time);
42 4         136 my $count = $rates_file_content->{$svc}->{$time};
43 4         12 push @service_limits, [$ti->seconds, $count];
44             }
45 2         34 @service_limits = sort { $a->[0] <=> $b->[0] } @service_limits;
  2         6  
46 2         1296 $limits{$svc} = {
47             rates => \@service_limits,
48             seconds => $service_limits[-1]->[0],
49             entries => $service_limits[-1]->[1],
50             };
51             }
52             }
53              
54             sub verify_rate_limitations_config {
55 1     1 1 332126 my $proper = 1; # Assume it is proper until we find a bad entry
56 1         9 foreach my $svc (sort keys %$rates_file_content) {
57 1         2 my @service_limits;
58 1         2 foreach my $time (keys %{$rates_file_content->{$svc}}) {
  1         6  
59 2         30 my $ti = Time::Duration::Concise->new(interval => $time);
60 2         43 my $count = $rates_file_content->{$svc}->{$time};
61 2         10 push @service_limits, [$ti->seconds, $count, $count / $ti->seconds, undef, $time];
62             }
63 1         16 @service_limits = sort { $a->[0] <=> $b->[0] } @service_limits;
  1         4  
64              
65 1         9 while (my $this_limit = shift @service_limits) {
66 2         40 my ($improper, $index) = ($this_limit->[3], $#service_limits);
67 2   66     15 while (not $improper and $index > -1) {
68 1         3 my $that_limit = $service_limits[$index];
69             # This one is improper if that longer period has the same or smaller count
70 1 50       5 $improper = 'count should be lower than ' . $that_limit->[4] . ' count' if ($that_limit->[1] <= $this_limit->[1]);
71             # That one is improper if this shorter period has the smaller rate
72 1 50 33     6 $service_limits[$index]->[3] = 'rate should be lower than ' . $this_limit->[4] . ' rate'
73             if (not $improper and $this_limit->[2] < $that_limit->[2]);
74 1         4 $index--;
75             }
76 2 50       8 if ($improper) {
77             # If any entry is improper we will fail and warn.
78             # We still check the rest for completeness
79 0         0 $proper = 0;
80 0         0 carp $svc . ' - ' . $this_limit->[4] . ' entry improper: ' . $improper;
81             }
82             }
83             }
84 1         9 return $proper;
85             }
86              
87             sub within_rate_limits {
88 0     0 1 0 my $args = shift;
89              
90 0 0       0 croak 'Must supply args as a hash reference' unless ref $args eq 'HASH';
91 0         0 my ($service, $consumer) = @{$args}{'service', 'consumer'};
  0         0  
92 0 0   0   0 croak 'Must supply both "service" and "consumer" arguments' unless all { defined } ($service, $consumer);
  0         0  
93 0         0 my $limit = $limits{$service};
94 0 0       0 croak 'Unknown service supplied: ' . $service unless $limit;
95              
96 0         0 my $redis = Cache::RedisDB->redis;
97 0         0 my $key = _make_key($service, $consumer);
98 0         0 my $within_limits = 1;
99 0         0 my $now = time;
100 0         0 $redis->lpush($key, $now); # We push first so that we hit limits more often in heavy (DoS) conditions
101 0         0 $redis->ltrim($key, 0, $limit->{entries}); # Our new entry is now in index 0.. we keep 1 extra entry.
102 0         0 $redis->expire($key, $limit->{seconds});
103 0         0 foreach my $rate (@{$limit->{rates}}) {
  0         0  
104 0 0 0     0 if (($redis->lindex($key, $rate->[1]) // 0) > $now - $rate->[0]) {
105 0         0 $within_limits = 0;
106 0         0 last;
107             }
108             }
109              
110 0         0 return $within_limits;
111             }
112              
113             sub flush_all_service_consumers {
114 0     0 1 0 my $redis = Cache::RedisDB->redis;
115 0         0 my $count = 0;
116              
117 0         0 foreach my $key (_all_keys($redis)) {
118 0         0 $count += $redis->del($key);
119             }
120              
121 0         0 return $count;
122             }
123              
124 0   0 0   0 sub _all_keys { my $redis = shift // Cache::RedisDB->redis; return @{$redis->keys(_make_key('*', '*')) // []}; }
  0   0     0  
  0         0  
125              
126 1     1 1 2236 sub rate_limited_services { return (sort keys %limits); }
127              
128             sub rate_limits_for_service {
129 2   100 2 1 2329 my $service = shift // 'undef';
130 2         5 my $svc_limits = $limits{$service};
131 2 100       15 croak 'Unknown service supplied: ' . $service unless $svc_limits;
132              
133 1         1 return @{$svc_limits->{rates}};
  1         13  
134             }
135              
136             sub all_service_consumers {
137              
138 0     0 1   my %consumers;
139              
140 0           foreach my $pair (map { [(split SEPARATOR, $_)[-2, -1]] } _all_keys()) {
  0            
141 0   0       $consumers{$pair->[0]} //= [];
142 0           push @{$consumers{$pair->[0]}}, $pair->[1];
  0            
143             }
144              
145 0           return \%consumers;
146             }
147              
148             sub _make_key {
149 0     0     my ($service, $consumer) = @_;
150              
151 0           return join(SEPARATOR, KEYSPACE, $service, $consumer);
152             }
153              
154             1;
155             __END__
156              
157             =encoding utf-8
158              
159             =head1 NAME
160              
161             RateLimitations - manage per-service rate limitations
162              
163             =head1 SYNOPSIS
164              
165             use 5.010;
166              
167             use RateLimitations qw(
168             rate_limited_services
169             rate_limits_for_service
170             within_rate_limits
171             all_service_consumers
172             );
173              
174             # Example using the built-in default "rl_internal_testing" service:
175             # rl_internal_testing:
176             # 10s: 2
177             # 5m: 6
178              
179             my @rl_services = rate_limited_services();
180             # ("rl_internal_testing")
181              
182             my @test_limits = rate_limits_for_service('rl_internal_testing');
183             # ([10 => 2], [300 => 6])
184              
185             foreach my $i (1 .. 6) {
186             my $guy = ($i % 2) ? 'OddGuy' : 'EvenGuy';
187             my $result = (
188             within_rate_limits({
189             service => 'rl_internal_testing',
190             consumer => $guy,
191             })) ? 'permitted' : 'denied';
192             say $result . ' for ' . $guy;
193             }
194             # permitted for OddGuy
195             # permitted for EvenGuy
196             # permitted for OddGuy
197             # permitted for EvenGuy
198             # denied for OddGuy
199             # denied for EvenGuy
200              
201             my $consumers = all_service_consumers();
202             # { rl_internal_testing => ['EvenGuy', 'OddGuy']}
203              
204             =head1 DESCRIPTION
205              
206             RateLimitations is a module to help enforce per-service rate limits.
207              
208             The rate limits are checked via a backing Redis store. This persistence allows for
209             multiple processes to maintain a shared view of resource usage. Acceptable rates
210             are defined in the F</etc/perl_rate_limitations.yml> file.
211              
212             Several utility functions are provided to help examine the inner state to help confirm
213             proper operation.
214              
215             Nothing is exported from this package by default.
216              
217             =head1 FUNCTIONS
218              
219             =over
220              
221             =item within_rate_limits({service => $service, consumer => $consumer_id})
222              
223             Returns B<1> if C<$consumer_id> is permitted further access to C<$service>
224             under the rate limiting rules for the service; B<0> is returned if this
225             access would exceed those limits.
226              
227             Will croak unless both elements are supplied and C<$service> is valid.
228              
229             Note that this call will update the known request rate, even if it is eventually
230             determined that the request is not within limits. This is a conservative approach
231             since we cannot know for certain how the results of this call are used. As such,
232             it is best to use this call B<only> when legitimately gating service access and
233             to allow a bit of extra slack in the permitted limits.
234              
235             =item verify_rate_limitations_config()
236              
237             Attempts to load the F</etc/perl_rate_limitations.yml> file and confirm that its
238             contents make sense. Parsing the file in much the same way as importing the
239             module, additional sanity checks are performed on the supplied rates.
240              
241             Returns B<1> if the file appears to be OK; B<0> otherwise.
242              
243             =item rate_limited_services()
244              
245             Returns an array of all known services which have applied rate limits.
246              
247             =item rate_limits_for_service($service)
248              
249             Returns an array of rate limits applied to requests for a known C<$service>.
250             Each member of the array is an array reference with two elements:
251              
252             [number_of_seconds, number_of_accesses_permitted_in_those_seconds]
253              
254             =item all_service_consumers()
255              
256             Returns a hash reference with all services and their consumers. May be useful
257             for verifying consumer names are well-formed.
258              
259             { service1 => [consumer1, consumer2],
260             service2 => [consumer1, consumer2],
261             }
262              
263             =item flush_all_service_consumers()
264              
265             Clears the full list of consumers. Returns the number of items cleared.
266              
267             =back
268              
269             =head1 CONFIG FILE FORMAT
270              
271             The services to be limited are defined in the F</etc/perl_rate_limitations.yml>
272             file. This file should be laid out as follows:
273              
274             service_name:
275             time: count
276             time: count
277             service_name:
278             time: count
279             time: count
280              
281             B<service_name> is an arbitrary string to uniquely identify the service
282              
283             B<time> is a string which can be interpreted by B<Time::Duration::Concise>. This
284             may include using an integer number of seconds.
285              
286             B<count> is an integer which sets the maximum permitted B<service_name> accesses
287             per B<time>
288              
289             =head1 AUTHOR
290              
291             Binary.com E<lt>perl@binary.comE<gt>
292              
293             =head1 COPYRIGHT
294              
295             Copyright 2015-
296              
297             =head1 LICENSE
298              
299             This library is free software; you can redistribute it and/or modify
300             it under the same terms as Perl itself.
301              
302             =head1 SEE ALSO
303              
304             =cut