Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
33 / 34
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
RetryTrait
97.06% covered (success)
97.06%
33 / 34
66.67% covered (warning)
66.67%
2 / 3
15
0.00% covered (danger)
0.00%
0 / 1
 callWithRetry
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
10
 shouldRetry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBackoffDuration
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3/**
4 * Trait that provides support for calling a method with configurable retries
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2023.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Service
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development
28 */
29
30namespace VuFind\Service\Feature;
31
32/**
33 * Trait that provides support for calling a method with configurable retries
34 *
35 * @category VuFind
36 * @package  Service
37 * @author   Ere Maijala <ere.maijala@helsinki.fi>
38 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
39 * @link     https://vufind.org/wiki/development
40 */
41trait RetryTrait
42{
43    /**
44     * Retry options
45     *
46     * @var array
47     */
48    protected $retryOptions = [
49        'retryCount' => 5,            // number of retries (set to 0 to disable)
50        'firstBackoff' => 0,          // backoff (delay) before first retry
51                                      // (milliseconds)
52        'subsequentBackoff' => 200,   // backoff (delay) before subsequent retries
53                                      // (milliseconds)
54        'exponentialBackoff' => true, // whether to use exponential backoff
55        'maximumBackoff' => 1000,      // maximum backoff (milliseconds)
56    ];
57
58    /**
59     * Call a method and retry the call if an exception is thrown
60     *
61     * @param callable  $callback       Method to call
62     * @param ?callable $statusCallback Status callback called before retry and after
63     * a successful retry. The callback gets the attempt number and either an
64     * exception if an error occurred or null if the request succeeded after retries.
65     * @param array     $options        Optional options to override defaults in
66     * $this->retryOptions. Options can also include a retryableExceptionCallback
67     * for a callback that gets the attempt number and exception as parameters and
68     * returns true if the call can be retried or false if not.
69     *
70     * @return mixed
71     */
72    protected function callWithRetry(
73        callable $callback,
74        ?callable $statusCallback = null,
75        array $options = []
76    ) {
77        $attempt = 0;
78        $firstException = null;
79        $lastException = null;
80        $options = array_merge($this->retryOptions, $options);
81        do {
82            ++$attempt;
83            if ($delay = $this->getBackoffDuration($attempt, $options)) {
84                usleep($delay * 1000);
85            }
86            if ($lastException && $statusCallback) {
87                $statusCallback($attempt, $lastException);
88            }
89            try {
90                $result = $callback();
91                if ($attempt > 1 && $statusCallback) {
92                    $statusCallback($attempt, null);
93                }
94                return $result;
95            } catch (\Exception $e) {
96                $lastException = $e;
97                if (null === $firstException) {
98                    $firstException = $e;
99                }
100                if ($checkCallback = $options['retryableExceptionCallback'] ?? '') {
101                    if (!$checkCallback($attempt, $e)) {
102                        break;
103                    }
104                }
105            }
106        } while ($this->shouldRetry($attempt, $options));
107        // No success, re-throw the first exception:
108        throw $firstException;
109    }
110
111    /**
112     * Check if the call needs to be retried
113     *
114     * @param int   $attempt Failed attempt number
115     * @param array $options Current options
116     *
117     * @return bool
118     */
119    protected function shouldRetry(int $attempt, array $options): bool
120    {
121        return $attempt <= $options['retryCount'];
122    }
123
124    /**
125     * Get the delay before a try
126     *
127     * @param int   $attempt Attempt number
128     * @param array $options Current options
129     *
130     * @return int milliseconds
131     */
132    protected function getBackoffDuration(int $attempt, array $options): int
133    {
134        if ($attempt < 2) {
135            return 0;
136        }
137        if (2 === $attempt) {
138            return $options['firstBackoff'];
139        }
140        $backoff = $options['subsequentBackoff'];
141        if ($options['exponentialBackoff']) {
142            $backoff = min(
143                2 ** ($attempt - 3) * $backoff,
144                $options['maximumBackoff']
145            );
146        }
147        return $backoff;
148    }
149}