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   16759 use strict;
  1         1  
  1         21  
4 1     1   3 use warnings;
  1         2  
  1         16  
5              
6 1     1   4 use Carp;
  1         0  
  1         34  
7 1     1   544 use Moo;
  1         9842  
  1         5  
8              
9             our $VERSION = '0.02';
10              
11             =head1 NAME
12              
13             RateLimitations::Pluggable - pluggabe manager of per-service rate limitations
14              
15             =head1 STATUS
16              
17             =begin HTML
18              
19            

20            
21            

22              
23             =end HTML
24              
25             =head1 SYNOPSIS
26              
27             my $storage = {};
28              
29             my $rl = RateLimitations::Pluggable->new({
30             limits => {
31             sample_service => {
32             60 => 2, # per minute limits
33             3600 => 5, # per hour limits
34             }
35             },
36             # define an subroutine where hits are stored: redis, db, file, in-memory, cookies
37             getter => sub {
38             my ($service, $consumer) = @_;
39             return $storage->{$service}->{$consumer};
40             },
41             # optional, notify back when hits are updated
42             setter => sub {
43             my ($service, $consumer, $hits) = @_;
44             $storage->{$service}->{$consumer} = $hits;
45             },
46             });
47              
48             $rl->within_rate_limits('sample_service', 'some_client_id'); # true!
49             $rl->within_rate_limits('sample_service', 'some_client_id'); # true!
50             $rl->within_rate_limits('sample_service', 'some_client_id'), # false!
51              
52              
53             =head1 DESCRIPTION
54              
55             The module access to build-in C
56             C method, and checks whether limits are hits or not.
57              
58             Each time the method C is invoked it appends
59             to the array of hit current time. It check that array will not
60             grow endlessly, and holds in per $service (or per $service/$consumer)
61             upto max_time integers.
62              
63             The array can be stored anywhere (disk, redis, DB, in-memory), hence the module
64             name is.
65              
66             =cut
67              
68             =head1 ATTRIBUTES
69              
70             =head2 limits
71              
72             Defines per-service limits. Below
73              
74             {
75             service_1 => {
76             60 => 20, # up to 20 service_1 invocations per 1 minute
77             3600 => 50, # OR up to 50 service_1 invocations per 1 hour
78             },
79              
80             service_2 => {
81             60 => 25,
82             3600 => 60,
83             }
84              
85             }
86              
87             Mandatory.
88              
89             =head2 getter->($service, $consumer)
90              
91             Mandatory coderef which returns an array of hits for the service and some
92             C.
93              
94              
95             =head2 setter->($service, $consumer, $hits)
96              
97             Optional callback for storing per service/consumer array of hits.
98              
99             =cut
100              
101             has limits => (
102             is => 'ro',
103             required => 1,
104             isa => sub {
105             croak "limits must be a hashref"
106             unless (ref($_[0]) // '') eq 'HASH';
107             },
108             );
109             has getter => (
110             is => 'ro',
111             required => 1,
112             isa => sub {
113             croak "limits must be a coderef"
114             unless (ref($_[0]) // '') eq 'CODE';
115             },
116             );
117              
118             has setter => (
119             is => 'ro',
120             required => 0,
121             isa => sub {
122             croak "limits must be a coderef"
123             if defined($_[0]) && (ref($_[0] ne 'CODE'));
124             },
125             );
126              
127             # key: service name
128             # value: sorted by $seconds array of pairs [$seconds, $rate]
129             has _limits_for => (is => 'rw');
130              
131             =for Pod::Coverage BUILD getter setter
132              
133             =cut
134              
135             sub BUILD {
136 1     1 0 8 my $self = shift;
137 1         3 my %limits_for;
138 1         1 for my $service (keys %{$self->limits}) {
  1         7  
139             my @service_limits =
140 1         4 sort { $a->[0] <=> $b->[0] }
141             map {
142 2         3 my $seconds = $_;
143 2         5 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         5 for my $idx (0 .. @service_limits - 1) {
149 2         3 my $pair = $service_limits[$idx];
150 2         4 my ($seconds, $limit) = @$pair;
151              
152             # validate: seconds should be natural number
153 2 50       4 croak("'$seconds' seconds is not integer for service $service")
154             if $seconds != int($seconds);
155 2 50       4 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       3 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       5 if ($idx > 0) {
166 1         2 my $prev_pair = $service_limits[$idx - 1];
167 1         2 my $lesser_limit = $prev_pair->[1];
168 1         1 my $current_limit = $limit;
169 1 50       5 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         2 $limits_for{$service} = \@service_limits;
180             }
181 1         24 return $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 11512 my ($self, $service, $consumer) = @_;
202 4029 50       4867 croak "service should be defined" unless defined $service;
203 4029 50       4628 croak "consumer should be defined" unless defined $consumer;
204              
205 4029         3688 my $limits = $self->_limits_for->{$service};
206 4029 50       4691 croak "unknown service: '$service'" unless defined $limits;
207              
208 4029   100     4659 my $hits = $self->getter->($service, $consumer) // [];
209 4029         10968 my $within_limits = 1;
210 4029         3932 my $now = time;
211             # We push first so that we hit limits more often in heavy (DoS) conditions
212 4029         6357 push @$hits, $now;
213             # Remove extra oldest hits, as they do not participate it checks anyway
214 4029         5664 shift @$hits while (@$hits > $limits->[-1]->[0]);
215              
216             # optionally notify updated service hits
217 4029         3084 my $setter = $self->setter;
218 4029 50       6176 $setter->($service, $consumer, $hits) if $setter;
219              
220 4029         9384 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     5797 my $past_hit_time = $hits->[($rate->[1] + 1) * -1] // 0;
226 4041         2838 my $allowed_past_hit_time = $now - $rate->[0];
227 4041 100       4504 if ($past_hit_time > $allowed_past_hit_time) {
228 4019         2357 $within_limits = 0;
229 4019         3039 last;
230             }
231             }
232              
233 4029         4373 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              
251             =cut
252              
253             1;