File Coverage

blib/lib/PAGI/Middleware/RateLimit.pm
Criterion Covered Total %
statement 86 88 97.7
branch 17 20 85.0
condition 16 22 72.7
subroutine 15 15 100.0
pod 1 2 50.0
total 135 147 91.8


line stmt bran cond sub pod time code
1             package PAGI::Middleware::RateLimit;
2              
3 2     2   399724 use strict;
  2         2  
  2         73  
4 2     2   8 use warnings;
  2         6  
  2         101  
5 2     2   727 use parent 'PAGI::Middleware';
  2         575  
  2         9  
6 2     2   118 use Future::AsyncAwait;
  2         3  
  2         8  
7              
8             =head1 NAME
9              
10             PAGI::Middleware::RateLimit - Request rate limiting middleware
11              
12             =head1 SYNOPSIS
13              
14             use PAGI::Middleware::Builder;
15              
16             my $app = builder {
17             enable 'RateLimit',
18             requests_per_second => 10,
19             burst => 20,
20             key_generator => sub {
21             my ($scope) = @_; exists $scope->{client} ? $scope->{client}[0] : 'unknown' };
22             $my_app;
23             };
24              
25             =head1 DESCRIPTION
26              
27             PAGI::Middleware::RateLimit implements token bucket rate limiting per client.
28             Clients exceeding the rate limit receive 429 Too Many Requests.
29              
30             =head1 CONFIGURATION
31              
32             =over 4
33              
34             =item * requests_per_second (default: 10)
35              
36             Average requests allowed per second.
37              
38             =item * burst (default: 20)
39              
40             Maximum burst size (bucket capacity).
41              
42             =item * key_generator (default: client IP)
43              
44             Coderef to generate rate limit key from $scope.
45              
46             =item * backend (default: in-memory)
47              
48             Rate limit storage backend. Can be 'memory' or a custom object
49             implementing get/set methods.
50              
51             =item * cleanup_interval (default: 60)
52              
53             Seconds between periodic cleanup of stale buckets.
54              
55             =item * max_buckets (default: 10000)
56              
57             Maximum number of tracked client buckets. When exceeded, the oldest
58             half are evicted as a safety valve.
59              
60             =back
61              
62             =cut
63              
64             my %buckets; # In-memory storage
65             my $_time_offset = 0;
66              
67 8     8   203055 sub _clear_buckets { %buckets = (); $_time_offset = 0; }
  8         14  
68 6     6   131 sub _bucket_count { return scalar keys %buckets }
69 13     13   20 sub _advance_time_for_test { $_time_offset += $_[1] }
70 45     45   71 sub _now { return time() + $_time_offset }
71              
72             sub _init {
73 8     8   14 my ($self, $config) = @_;
74              
75 8   50     24 $self->{requests_per_second} = $config->{requests_per_second} // 10;
76 8   50     21 $self->{burst} = $config->{burst} // 20;
77             $self->{key_generator} = $config->{key_generator} // sub {
78 38     38   50 my ($scope) = @_;
79 38 50 50     106 return exists $scope->{client} ? ($scope->{client}[0] // 'unknown') : 'unknown';
80 8   66     73 };
81 8   50     31 $self->{backend} = $config->{backend} // 'memory';
82 8   100     28 $self->{cleanup_interval} = $config->{cleanup_interval} // 60;
83 8   100     26 $self->{max_buckets} = $config->{max_buckets} // 10_000;
84             }
85              
86             sub wrap {
87 8     8 1 44 my ($self, $app) = @_;
88              
89 41     41   9663 return async sub {
90 41         55 my ($scope, $receive, $send) = @_;
91 41 50       93 if ($scope->{type} ne 'http') {
92 0         0 await $app->($scope, $receive, $send);
93 0         0 return;
94             }
95              
96 41         70 my $key = $self->{key_generator}->($scope);
97 41         93 my ($allowed, $remaining, $reset) = $self->_check_rate_limit($key);
98              
99 41 100       70 if (!$allowed) {
100 4         12 await $self->_send_rate_limited($send, $remaining, $reset);
101 4         188 return;
102             }
103              
104             # Add rate limit headers to response
105 74         2467 my $wrapped_send = async sub {
106 74         76 my ($event) = @_;
107 74 100       109 if ($event->{type} eq 'http.response.start') {
108 37   50     34 my @headers = @{$event->{headers} // []};
  37         75  
109 37         56 push @headers, ['X-RateLimit-Limit', $self->{burst}];
110 37         46 push @headers, ['X-RateLimit-Remaining', $remaining];
111 37         59 push @headers, ['X-RateLimit-Reset', $reset];
112 37         119 await $send->({
113             %$event,
114             headers => \@headers,
115             });
116             } else {
117 37         54 await $send->($event);
118             }
119 37         123 };
120              
121 37         69 await $app->($scope, $receive, $wrapped_send);
122 8         40 };
123             }
124              
125             sub _check_rate_limit {
126 41     41   48 my ($self, $key) = @_;
127              
128 41         64 my $now = _now();
129 41         54 my $rate = $self->{requests_per_second};
130 41         59 my $burst = $self->{burst};
131              
132             # Get or initialize bucket
133 41   100     144 my $bucket = $buckets{$key} //= {
134             tokens => $burst,
135             last_time => $now,
136             };
137              
138             # Refill tokens based on time elapsed
139 41         70 my $elapsed = $now - $bucket->{last_time};
140 41         49 my $refill = $elapsed * $rate;
141 41         55 $bucket->{tokens} = $bucket->{tokens} + $refill;
142 41 100       64 $bucket->{tokens} = $burst if $bucket->{tokens} > $burst;
143 41         44 $bucket->{last_time} = $now;
144              
145             # Determine rate limit result
146 41         35 my @result;
147 41 100       60 if ($bucket->{tokens} >= 1) {
148 37         38 $bucket->{tokens} -= 1;
149 37         40 my $remaining = int($bucket->{tokens});
150 37         92 my $reset = $now + int(($burst - $bucket->{tokens}) / $rate);
151 37         59 @result = (1, $remaining, $reset); # Allowed
152             } else {
153 4         8 my $wait_time = (1 - $bucket->{tokens}) / $rate;
154 4         7 my $reset = $now + int($wait_time) + 1;
155 4         7 @result = (0, 0, $reset); # Not allowed
156             }
157              
158             # Periodic cleanup of stale buckets
159 41 100 100     132 if (!$self->{_last_cleanup} || ($now - $self->{_last_cleanup}) >= $self->{cleanup_interval}) {
160 10         12 $self->{_last_cleanup} = $now;
161 10         17 my $stale_threshold = $now - (2 * $burst / $rate);
162 10         19 for my $k (keys %buckets) {
163 16 100       35 delete $buckets{$k} if $buckets{$k}{last_time} < $stale_threshold;
164             }
165             }
166              
167             # Safety valve: evict oldest buckets when over max
168 41 100       69 if (keys %buckets > $self->{max_buckets}) {
169 1         7 my @sorted = sort { $buckets{$a}{last_time} <=> $buckets{$b}{last_time} } keys %buckets;
  27         30  
170 1         3 my $to_remove = @sorted - int($self->{max_buckets} / 2);
171 1         8 delete $buckets{$_} for @sorted[0 .. $to_remove - 1];
172             }
173              
174 41         81 return @result;
175             }
176              
177 4     4   5 async sub _send_rate_limited {
178 4         8 my ($self, $send, $remaining, $reset) = @_;
179              
180 4         6 my $retry_after = $reset - _now();
181 4 50       7 $retry_after = 1 if $retry_after < 1;
182              
183 4         6 my $body = 'Rate limit exceeded. Try again later.';
184              
185             await $send->({
186             type => 'http.response.start',
187             status => 429,
188             headers => [
189             ['Content-Type', 'text/plain'],
190             ['Content-Length', length($body)],
191             ['Retry-After', $retry_after],
192 4         30 ['X-RateLimit-Limit', $self->{burst}],
193             ['X-RateLimit-Remaining', 0],
194             ['X-RateLimit-Reset', $reset],
195             ],
196             });
197 4         155 await $send->({
198             type => 'http.response.body',
199             body => $body,
200             more => 0,
201             });
202             }
203              
204             # Class method to reset rate limits (useful for testing)
205             sub reset_all {
206 4     4 0 221597 _clear_buckets();
207             }
208              
209             1;
210              
211             __END__