File Coverage

blib/lib/RateLimitations/Pluggable.pm
Criterion Covered Total %
statement 55 56 98.2
branch 13 22 59.0
condition 4 4 100.0
subroutine 6 6 100.0
pod 1 2 50.0
total 79 90 87.7


line stmt bran cond sub pod time code
1             package RateLimitations::Pluggable;
2              
3 1     1   13783 use strict;
  1         1  
  1         21  
4 1     1   3 use warnings;
  1         1  
  1         16  
5              
6 1     1   3 use Carp;
  1         3  
  1         33  
7 1     1   486 use Moo;
  1         9161  
  1         3  
8              
9             our $VERSION = '0.01';
10              
11             =head1 NAME
12              
13             RateLimitations::Pluggable - pluggabe manager of per-service rate limitations
14              
15             =head1 VERSION
16              
17             0.01
18              
19             =head1 STATUS
20              
21             =begin HTML
22              
23            

24            
25            

26              
27             =end HTML
28              
29             =head1 SYNOPSIS
30              
31             my $storage = {};
32              
33             my $rl = RateLimitations::Pluggable->new({
34             limits => {
35             sample_service => {
36             60 => 2, # per minute limits
37             3600 => 5, # per hour limits
38             }
39             },
40             # define an subroutine where hits are stored: redis, db, file, in-memory, cookies
41             getter => sub {
42             my ($service, $consumer) = @_;
43             return $storage->{$service}->{$consumer};
44             },
45             # optional, notify back when hits are updated
46             setter => sub {
47             my ($service, $consumer, $hits) = @_;
48             $storage->{$service}->{$consumer} = $hits;
49             },
50             });
51              
52             $rl->within_rate_limits('sample_service', 'some_client_id'); # true!
53             $rl->within_rate_limits('sample_service', 'some_client_id'); # true!
54             $rl->within_rate_limits('sample_service', 'some_client_id'), # false!
55              
56              
57             =head1 DESCRIPTION
58              
59             The module access to build-in C
60             C method, and checks whether limits are hits or not.
61              
62             Each time the method C is invoked it appends
63             to the array of hit current time. It check that array will not
64             grow endlessly, and holds in per $service (or per $service/$consumer)
65             upto max_time integers.
66              
67             The array can be stored anywhere (disk, redis, DB, in-memory), hence the module
68             name is.
69              
70             =cut
71              
72             =head1 ATTRIBUTES
73              
74             =head2 limits
75              
76             Defines per-service limits. Below
77              
78             {
79             service_1 => {
80             60 => 20, # up to 20 service_1 invocations per 1 minute
81             3600 => 50, # OR up to 50 service_1 invocations per 1 hour
82             },
83              
84             service_2 => {
85             60 => 25,
86             3600 => 60,
87             }
88              
89             }
90              
91             Mandatory.
92              
93             =head2 getter->($service, $consumer)
94              
95             Mandatory coderef which returns an array of hits for the service and some
96             C.
97              
98              
99             =head2 setter->($service, $consumer, $hits)
100              
101             Optional callback for storing per service/consumer array of hits.
102              
103             =cut
104              
105             has limits => (
106             is => 'ro',
107             required => 1,
108             isa => sub {
109             croak "limits must be a hashref"
110             unless (ref($_[0]) // '') eq 'HASH';
111             },
112             );
113             has getter => (
114             is => 'ro',
115             required => 1,
116             isa => sub {
117             croak "limits must be a coderef"
118             unless (ref($_[0]) // '') eq 'CODE';
119             },
120             );
121              
122             has setter => (
123             is => 'ro',
124             required => 0,
125             isa => sub {
126             croak "limits must be a coderef"
127             if defined($_[0]) && (ref($_[0] ne 'CODE'));
128             },
129             );
130              
131             # key: service name
132             # value: sorted by $seconds array of pairs [$seconds, $rate]
133             has _limits_for => (is => 'rw');
134              
135             sub BUILD {
136 1     1 0 8 my $self = shift;
137 1         1 my %limits_for;
138 1         1 for my $service (keys %{$self->limits}) {
  1         6  
139             my @service_limits =
140 1         5 sort { $a->[0] <=> $b->[0] }
141             map {
142 2         2 my $seconds = $_;
143 2         4 my $limit = $self->limits->{$service}->{$seconds};
144 2         5 [$seconds, $limit];
145 1         1 } keys %{$self->limits->{$service}};
  1         4  
146              
147             # do various validations
148 1         3 for my $idx (0 .. @service_limits - 1) {
149 2         3 my $pair = $service_limits[$idx];
150 2         3 my ($seconds, $limit) = @$pair;
151              
152             # validate: seconds should be natural number
153 2 50       5 croak("'$seconds' seconds is not integer for service $service")
154             if $seconds != int($seconds);
155 2 50       6 croak("'$seconds' seconds is not positive for service $service")
156             if $seconds <= 0;
157              
158             # validate: limit should be natural number
159 2 50       4 croak("limit '$limit' is not integer for service $service")
160             if $limit != int($limit);
161 2 50       4 croak("limit '$limit' is not positive for service $service")
162             if $limit <= 0;
163              
164             # validate: limit for greater time interval should be greater
165 2 100       3 if ($idx > 0) {
166 1         4 my $prev_pair = $service_limits[$idx - 1];
167 1         1 my $lesser_limit = $prev_pair->[1];
168 1         2 my $current_limit = $limit;
169 1 50       2 if ($current_limit <= $lesser_limit) {
170 0         0 croak "limit ($current_limit) for "
171             . $seconds
172             . " seconds"
173             . " should be greater then limit ($lesser_limit) for "
174             . $prev_pair->[0]
175             . "seconds";
176             }
177             }
178             }
179 1         3 $limits_for{$service} = \@service_limits;
180             }
181 1         23 $self->_limits_for(\%limits_for);
182             }
183              
184             =head1 METHODS
185              
186             =head2 within_rate_limits
187              
188             within_rate_limits($service, $consumer)
189              
190             Appends service/consumer hits array with additional hit.
191              
192             Returns true if the service limits aren't exhausted.
193              
194             The C<$service> string must be defined in the C attribute;
195             the C<$consumer> string is arbitrary object defined by application
196             logic. Cannot be C
197              
198             =cut
199              
200             sub within_rate_limits {
201 4029     4029 1 9540 my ($self, $service, $consumer) = @_;
202 4029 50       4348 croak "service should be defined" unless defined $service;
203 4029 50       4331 croak "consumer should be defined" unless defined $consumer;
204              
205 4029         3502 my $limits = $self->_limits_for->{$service};
206 4029 50       4210 croak "unknown service: '$service'" unless defined $limits;
207              
208 4029   100     4284 my $hits = $self->getter->($service, $consumer) // [];
209 4029         10272 my $within_limits = 1;
210 4029         3893 my $now = time;
211             # We push first so that we hit limits more often in heavy (DoS) conditions
212 4029         5693 push @$hits, $now;
213             # Remove extra oldest hits, as they do not participate it checks anyway
214 4029         5364 shift @$hits while (@$hits > $limits->[-1]->[0]);
215              
216             # optionally notify updated service hits
217 4029         2891 my $setter = $self->setter;
218 4029 50       6097 $setter->($service, $consumer, $hits) if $setter;
219              
220 4029         8913 for my $rate (@$limits) {
221             # take the service time hit which occur exactly $max_rate times ago
222             # might be undefined.
223             # +1 is added because we already inserted $now hit above, which
224             # should be out of the consideration
225 4041   100     5455 my $past_hit_time = $hits->[($rate->[1] + 1) * -1] // 0;
226 4041         2545 my $allowed_past_hit_time = $now - $rate->[0];
227 4041 100       4178 if ($past_hit_time > $allowed_past_hit_time) {
228 4019         2258 $within_limits = 0;
229 4019         2707 last;
230             }
231             }
232              
233 4029         3845 return $within_limits;
234             }
235              
236             =head1 SOURCE CODE
237              
238             L
239              
240              
241             =head1 AUTHOR
242              
243             binary.com, C<< >>
244              
245             =head1 BUGS
246              
247             Please report any bugs or feature requests to
248             L.
249              
250             =head1 LICENSE AND COPYRIGHT
251              
252             Copyright (C) 2016 binary.com
253              
254             This program is free software; you can redistribute it and/or modify it
255             under the terms of the the Artistic License (2.0). You may obtain a
256             copy of the full license at:
257              
258             L
259              
260             Any use, modification, and distribution of the Standard or Modified
261             Versions is governed by this Artistic License. By using, modifying or
262             distributing the Package, you accept this license. Do not use, modify,
263             or distribute the Package, if you do not accept this license.
264              
265             If your Modified Version has been derived from a Modified Version made
266             by someone other than you, you are nevertheless required to ensure that
267             your Modified Version complies with the requirements of this license.
268              
269             This license does not grant you the right to use any trademark, service
270             mark, tradename, or logo of the Copyright Holder.
271              
272             This license includes the non-exclusive, worldwide, free-of-charge
273             patent license to make, have made, use, offer to sell, sell, import and
274             otherwise transfer the Package with respect to any patent claims
275             licensable by the Copyright Holder that are necessarily infringed by the
276             Package. If you institute patent litigation (including a cross-claim or
277             counterclaim) against any party alleging that the Package constitutes
278             direct or contributory patent infringement, then this Artistic License
279             to you shall terminate on the date that such litigation is filed.
280              
281             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
282             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
283             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
284             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
285             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
286             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
287             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
288             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
289              
290             =cut
291              
292             1;