File Coverage

blib/lib/Trickster/Middleware/RateLimiter.pm
Criterion Covered Total %
statement 38 40 95.0
branch 8 14 57.1
condition 3 4 75.0
subroutine 10 10 100.0
pod 2 2 100.0
total 61 70 87.1


line stmt bran cond sub pod time code
1             package Trickster::Middleware::RateLimiter;
2              
3 1     1   913 use strict;
  1         20  
  1         28  
4 1     1   4 use warnings;
  1         1  
  1         41  
5 1     1   8 use v5.14;
  1         3  
6              
7 1     1   4 use parent 'Plack::Middleware';
  1         1  
  1         4  
8 1         5 use Plack::Util::Accessor qw(
9             requests
10             window
11             storage
12             key_generator
13             error_handler
14 1     1   59 );
  1         1  
15              
16             sub prepare_app {
17 1     1 1 50 my ($self) = @_;
18            
19 1 50       9 $self->requests(60) unless $self->requests;
20 1 50       6 $self->window(60) unless $self->window;
21 1 50       4 $self->storage({}) unless $self->storage;
22            
23             $self->key_generator(sub {
24 4     4   15 my $env = shift;
25 4   50     10 return $env->{REMOTE_ADDR} || 'unknown';
26 1 50       8 }) unless $self->key_generator;
27            
28             $self->error_handler(sub {
29 1     1   4 my ($env, $remaining, $reset) = @_;
30             return [
31 1         2 429,
32             [
33             'Content-Type' => 'application/json',
34             'X-RateLimit-Limit' => $self->requests,
35             'X-RateLimit-Remaining' => 0,
36             'X-RateLimit-Reset' => $reset,
37             'Retry-After' => $reset - time,
38             ],
39             ['{"error":"Rate limit exceeded"}'],
40             ];
41 1 50       7 }) unless $self->error_handler;
42             }
43              
44             sub call {
45 4     4 1 2974 my ($self, $env) = @_;
46            
47 4         10 my $key = $self->key_generator->($env);
48 4         6 my $now = time;
49            
50             # Get or initialize bucket
51 4   100     5 my $bucket = $self->storage->{$key} ||= {
52             count => 0,
53             reset => $now + $self->window,
54             };
55            
56             # Reset bucket if window expired
57 4 50       22 if ($now >= $bucket->{reset}) {
58 0         0 $bucket->{count} = 0;
59 0         0 $bucket->{reset} = $now + $self->window;
60             }
61            
62             # Check rate limit
63 4 100       27 if ($bucket->{count} >= $self->requests) {
64 1         5 return $self->error_handler->($env, 0, $bucket->{reset});
65             }
66            
67             # Increment counter
68 3         12 $bucket->{count}++;
69            
70 3         4 my $remaining = $self->requests - $bucket->{count};
71            
72             # Call app
73 3         26 my $res = $self->app->($env);
74            
75             # Add rate limit headers
76             return $self->response_cb($res, sub {
77 3     3   36 my $res = shift;
78 3         38 push @{$res->[1]},
79             'X-RateLimit-Limit' => $self->requests,
80             'X-RateLimit-Remaining' => $remaining,
81 3         3 'X-RateLimit-Reset' => $bucket->{reset};
82 3         193 });
83             }
84              
85             1;
86              
87             __END__