File Coverage

blib/lib/Algorithm/Backoff/RetryTimeouts.pm
Criterion Covered Total %
statement 80 80 100.0
branch 16 20 80.0
condition 13 29 44.8
subroutine 16 16 100.0
pod 3 3 100.0
total 128 148 86.4


line stmt bran cond sub pod time code
1             package Algorithm::Backoff::RetryTimeouts;
2              
3 1     1   70843 use utf8;
  1         26  
  1         6  
4 1     1   32 use strict;
  1         2  
  1         21  
5 1     1   5 use warnings;
  1         2  
  1         26  
6              
7 1     1   492 use Algorithm::Backoff::Exponential;
  1         4978  
  1         35  
8 1     1   7 use base qw< Algorithm::Backoff::Exponential >;
  1         2  
  1         159  
9              
10 1     1   691 use Storable qw< dclone >;
  1         3159  
  1         67  
11 1     1   8 use Time::HiRes qw< time >;
  1         2  
  1         6  
12              
13 1     1   655 use namespace::clean;
  1         16162  
  1         6  
14              
15             # ABSTRACT: A backoff-style retry algorithm with adjustable timeout support
16 1     1   686 use version;
  1         1908  
  1         6  
17             our $VERSION = 'v1.0.0'; # VERSION
18              
19             #pod =head1 SYNOPSIS
20             #pod
21             #pod use Algorithm::Backoff::RetryTimeouts;
22             #pod
23             #pod my $retry_algo = Algorithm::Backoff::RetryTimeouts->new(
24             #pod # common adjustments (defaults shown)
25             #pod max_attempts => 8,
26             #pod max_actual_duration => 50,
27             #pod jitter_factor => 0.1,
28             #pod timeout_jitter_factor => 0.1,
29             #pod adjust_timeout_factor => 0.5,
30             #pod min_adjust_timeout => 5,
31             #pod
32             #pod # other defaults
33             #pod initial_delay => sqrt(2),
34             #pod exponent_base => sqrt(2),
35             #pod delay_on_success => 0,
36             #pod min_delay => 0,
37             #pod max_delay => undef,
38             #pod consider_actual_delay => 1,
39             #pod );
40             #pod
41             #pod my ($delay, $timeout);
42             #pod $timeout = $retry_algo->timeout;
43             #pod
44             #pod my $is_successful = 0;
45             #pod while (!$is_successful) {
46             #pod $actionee->timeout( $timeout );
47             #pod $is_successful = $actionee->do_the_thing;
48             #pod
49             #pod ($delay, $timeout) = $is_successful ? $retry_algo->success : $retry_algo->failure;
50             #pod die "Ran out of time" if $delay == -1;
51             #pod sleep $delay;
52             #pod }
53             #pod
54             #pod =head1 DESCRIPTION
55             #pod
56             #pod This module is a subclass of L that adds support for
57             #pod adjustable timeouts during each retry. This also comes with a sane set of defaults as a
58             #pod good baseline for most kinds of retry operations.
59             #pod
60             #pod A combination of features solves for most problems that would arise from retry operations:
61             #pod
62             #pod =over
63             #pod
64             #pod =item *
65             #pod
66             #pod B - Forces the algorithm to give up if repeated attempts don't yield
67             #pod success.
68             #pod
69             #pod =item *
70             #pod
71             #pod B - Forces the algorithm to give up if no successes happen within a
72             #pod certain time frame.
73             #pod
74             #pod =item *
75             #pod
76             #pod B - A C exponential delay keeps single retries from waiting
77             #pod too long, while spreading out repeated retries that may fail too quickly and run out of
78             #pod max attempts. This also decreases the congestion that happens with repeated attempts.
79             #pod
80             #pod =item *
81             #pod
82             #pod B - Adding random jitter to the retry delays solves for the Thundering Herd
83             #pod problem.
84             #pod
85             #pod =item *
86             #pod
87             #pod B - Providing an adjustable timeout after each request solves the
88             #pod opposite problem of exponential backoffs: slower, unresponsive errors that gobble up all
89             #pod of the max duration time in one go. Each new timeout is a certain percentage of the time
90             #pod left.
91             #pod
92             #pod =back
93             #pod
94             #pod =head2 Typical scenario
95             #pod
96             #pod Here's an example scenario of the algorithm with existing defaults:
97             #pod
98             #pod $retry_algo is created, and timer starts
99             #pod
100             #pod Initial timeout is 25s
101             #pod
102             #pod 1st attempt fails instantly
103             #pod
104             #pod $retry_algo says to wait 1.4s (±10% jitter), and use a timeout of 24.3s
105             #pod
106             #pod 2nd attempt fails instantly
107             #pod
108             #pod $retry_algo says to wait 2s (±10% jitter), and use a timeout of 23.3s
109             #pod
110             #pod 3rd attempt fails after the full 23.3s timeout
111             #pod
112             #pod $retry_algo says to not wait (since the attempt already used up the delay), and use
113             #pod a timeout of 11.7s
114             #pod
115             #pod 4th attempt succeeds
116             #pod
117             #pod =cut
118              
119             our %SPEC = %{ dclone \%Algorithm::Backoff::Exponential::SPEC };
120              
121             {
122             my $args = $SPEC{new}{args};
123              
124             # Our defaults
125             $args->{consider_actual_delay}{default} = 1;
126             $args->{max_attempts }{default} = 8;
127             $args->{max_actual_duration }{default} = 50;
128             $args->{jitter_factor }{default} = 0.1;
129             $args->{initial_delay }{default} = sqrt(2);
130             $args->{exponent_base }{default} = sqrt(2);
131              
132             # No need to require what already has a default
133             $args->{initial_delay}{req} = 0;
134              
135             # New arguments
136             $args->{adjust_timeout_factor} = {
137             summary => 'How much of the time left to use in the adjustable timeout',
138             schema => ['ufloat*', between=>[0, 1]],
139             default => 0.5,
140             };
141             $args->{min_adjust_timeout} = {
142             summary => 'Minimum adjustable timeout, in seconds',
143             schema => 'ufloat*',
144             default => 5,
145             };
146             $args->{timeout_jitter_factor} = {
147             summary => 'How much randomness to add to the adjustable timeout',
148             schema => ['float*', between=>[0, 0.5]],
149             default => 0.1,
150             };
151             }
152              
153             #pod =head1 CONSTRUCTOR
154             #pod
155             #pod The L<"new"|Algorithm::Backoff::Exponential/new> constructor takes all of the base options
156             #pod from L. Some of the defaults are changed (also shown in
157             #pod the L above), but otherwise function the same way.
158             #pod
159             #pod =over
160             #pod
161             #pod =item * L => I (default: 8)
162             #pod
163             #pod =item * L => I (default: 50)
164             #pod
165             #pod =item * L => I (default: 0.1)
166             #pod
167             #pod =item * L => I (default: C)
168             #pod
169             #pod =item * L => I (default: C)
170             #pod
171             #pod =item * L => I (default: 0)
172             #pod
173             #pod =item * L => I (default: 0)
174             #pod
175             #pod =item * L => I
176             #pod
177             #pod =item * L => I (default: 1)
178             #pod
179             #pod =back
180             #pod
181             #pod The following new options are added in this module:
182             #pod
183             #pod =over
184             #pod
185             #pod =item * adjust_timeout_factor => I (default: 0.5)
186             #pod
187             #pod How much of the remaining time to use for the next attempt's timeout, as a
188             #pod factor between 0 and 1.
189             #pod
190             #pod In order to prevent a single attempt from using up all of the remaining time, an
191             #pod adjustable timeout will force the attempt to only use a portion of the time. By default,
192             #pod only 50% of the remaining time will be set as the next timeout value.
193             #pod
194             #pod =item * min_adjust_timeout => I (default: 5)
195             #pod
196             #pod Minimum timeout value, in seconds.
197             #pod
198             #pod This value bypasses any C checks, so the total time spent on
199             #pod sleeping and attempts may end up exceeding that value by a small amount (up to
200             #pod C). In this case, future failures will return
201             #pod a delay of C<-1> as expected.
202             #pod
203             #pod =item * timeout_jitter_factor => I (default: 0.1)
204             #pod
205             #pod How much randomness to add to the adjustable timeout.
206             #pod
207             #pod Delay jitter may not be enough to desynchronize two processes that are consistently
208             #pod timing out on the same problem. In those cases, the delay will usually be zero and won't
209             #pod have any sort of jitter to solve the problem itself. A jitter factor against the timeout
210             #pod will ensure simultaneous attempts have slightly different timeout windows.
211             #pod
212             #pod =back
213             #pod
214             #pod =head1 METHODS
215             #pod
216             #pod =head2 success
217             #pod
218             #pod my ($delay, $timeout) = $retry_algo->success([ $timestamp ]);
219             #pod
220             #pod Log a successful attempt. If not specified, C<$timestamp> defaults to current time.
221             #pod Unlike the L, this method will return a list containing
222             #pod both the L and the L for the next
223             #pod attempt.
224             #pod
225             #pod =head2 failure
226             #pod
227             #pod my ($delay, $timeout) = $retry_algo->failure([ $timestamp ]);
228             #pod
229             #pod Log a failed attempt. If not specified, C<$timestamp> defaults to current time.
230             #pod Unlike the L, this method will return a list containing
231             #pod both the L and the L for the next
232             #pod attempt.
233             #pod
234             #pod =cut
235              
236             sub failure {
237 1023     1023 1 4020 my ($self, $timestamp) = @_;
238 1023   33     2198 $timestamp //= time;
239              
240 1023         2048 my ($delay, $timeout) = $self->SUPER::failure($timestamp);
241              
242             # Fix certain values if the check failed max duration/attempts checks
243 1023   66     2003 $timeout //= $self->timeout;
244 1023 100       1870 if ($delay == -1) {
245 3         7 $self->{_attempts}++;
246 3         6 $self->{_last_timestamp} = $timestamp;
247             }
248              
249 1023         1838 return ($delay, $timeout);
250             }
251              
252             #pod =head2 delay
253             #pod
254             #pod my $delay = $retry_algo->delay;
255             #pod
256             #pod Returns the last suggested delay, in seconds.
257             #pod
258             #pod The delay will return C<-1> to suggest that the process should give up and fail, if
259             #pod C or C have been reached.
260             #pod
261             #pod =cut
262              
263             sub delay {
264 1059     1059 1 35936 my $self = shift;
265 1059   100     2975 return $self->{_prev_delay} // 0;
266             }
267              
268             #pod =head2 timeout
269             #pod
270             #pod my $timeout = $retry_algo->delay;
271             #pod
272             #pod Returns the last suggested timeout, in seconds. If no attempts have been logged,
273             #pod it will suggest an initial timeout to start with.
274             #pod
275             #pod This will be a floating-point number, so you may need to convert it to an integer if your
276             #pod timeout system doesn't support decimals.
277             #pod
278             #pod A timeout of C<-1> will be returned if C was forcefully turned off.
279             #pod
280             #pod =cut
281              
282             sub timeout {
283 1047     1047 1 12268 my $self = shift;
284              
285 1047         1684 my $last_timeout = $self->{_last_timeout};
286 1047         1555 my $min_time = $self->{min_adjust_timeout};
287 1047         1577 my $max_time = $self->{max_actual_duration};
288 1047         1537 my $timeout_factor = $self->{adjust_timeout_factor};
289              
290 1047 100       1964 return $last_timeout if defined $last_timeout;
291 1004 50       1802 return -1 unless $max_time;
292              
293 1004         1519 my $timeout = $max_time * $timeout_factor;
294 1004 100       2130 $timeout = $self->_add_timeout_jitter($timeout) if $self->{timeout_jitter_factor};
295 1004 50       2114 $timeout = $min_time if $min_time > $timeout;
296 1004         2272 return $timeout;
297             }
298              
299             sub _set_last_timeout {
300 1020     1020   1772 my ($self, $delay, $timestamp) = @_;
301              
302 1020         1498 my $start_time = $self->{_start_timestamp};
303 1020         1599 my $min_time = $self->{min_adjust_timeout};
304 1020         1451 my $max_time = $self->{max_actual_duration};
305 1020         1604 my $timeout_factor = $self->{adjust_timeout_factor};
306 1020 50 0     2989 return ($delay // 0, -1) unless defined $start_time && $max_time;
      33        
307              
308 1020   0     1795 $timestamp //= $self->{_last_timestamp} // $self->{_start_timestamp};
      33        
309              
310             # Calculate initial timeout
311 1020         1621 my $actual_time_used = $timestamp - $start_time;
312 1020         1434 my $actual_time_left = $max_time - $actual_time_used;
313 1020         1639 my $timeout = $actual_time_left * $timeout_factor;
314              
315             # Ensure the delay+timeout time isn't going to go over the limit
316 1020   50     1703 $delay //= 0;
317 1020         1561 my $max_delay = $actual_time_left * (1 - $timeout_factor);
318 1020 100       1952 $delay = $max_delay if $delay > $max_delay;
319              
320             # Re-adjust the timeout based on the final delay and min timeout setting
321 1020         1496 $timeout = ($actual_time_left - $delay) * $timeout_factor;
322 1020 100       2234 $timeout = $self->_add_timeout_jitter($timeout) if $self->{timeout_jitter_factor};
323 1020 100       2093 $timeout = $min_time if $min_time > $timeout;
324              
325 1020         1584 $self->{_prev_delay} = $delay;
326 1020         1423 $self->{_last_timeout} = $timeout;
327              
328 1020         2590 return ($delay, $timeout);
329             }
330              
331             sub _add_timeout_jitter {
332 2000     2000   3346 my ($self, $timeout) = @_;
333 2000         2932 my $jitter = $self->{timeout_jitter_factor};
334 2000 50 33     5528 return $timeout unless $timeout && $jitter;
335              
336 2000         3240 my $min = $timeout * (1 - $jitter);
337 2000         3318 my $max = $timeout * (1 + $jitter);
338 2000         4049 return $min + ($max - $min) * rand();
339             }
340              
341             sub _consider_actual_delay {
342 20     20   340 my $self = shift;
343              
344             # See https://github.com/perlancar/perl-Algorithm-Backoff/issues/1
345 20   100     63 $self->{_last_delay} = $self->{_prev_delay} //= 0;
346              
347 20         55 return $self->SUPER::_consider_actual_delay(@_);
348             }
349              
350             sub _success_or_failure {
351 1020     1020   9826 my ($self, $is_success, $timestamp) = @_;
352              
353             # If this is the first time, the _last_timestamp should be set to the start, not
354             # $timestamp. This will prevent issues with the first attempt causing unnecessary
355             # delays (ie: waiting 1.4s after the first attempt took longer than that).
356 1020   66     1950 $self->{_last_timestamp} //= $self->{_start_timestamp};
357              
358 1020         1979 my $delay = $self->SUPER::_success_or_failure($is_success, $timestamp);
359 1020         27448 return $self->_set_last_timeout($delay, $timestamp);
360             }
361              
362             #pod =head1 SEE ALSO
363             #pod
364             #pod L - Base distro for this module
365             #pod
366             #pod =cut
367              
368             1;
369              
370             __END__