├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .php_cs.dist ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── RetryAnnotationTrait.php └── RetryTrait.php └── tests ├── InheritedTest.php ├── RetryAnnotationTraitTest.php └── RetryTraitTest.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | php: [ 7.1, 7.2, 7.3, 7.4, '8.0', 8.1 ] 13 | name: "PHP ${{ matrix.php }} Unit Test" 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: codecov/codecov-action@v1 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | - name: Install composer dependencies 22 | uses: nick-invision/retry@v1 23 | with: 24 | timeout_minutes: 10 25 | max_attempts: 3 26 | command: composer install 27 | - name: Run script 28 | run: vendor/bin/phpunit 29 | 30 | style: 31 | runs-on: ubuntu-latest 32 | name: PHP Style Check 33 | steps: 34 | - uses: actions/checkout@v2 35 | - name: Setup PHP 36 | uses: shivammathur/setup-php@v2 37 | with: 38 | php-version: 7.4 39 | - name: Install Dependencies 40 | uses: nick-invision/retry@v1 41 | with: 42 | timeout_minutes: 10 43 | max_attempts: 3 44 | command: composer install 45 | - name: Run Script 46 | run: vendor/bin/php-cs-fixer fix --dry-run --diff . 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /.php_cs.cache 4 | /.phpunit.result.cache 5 | /.idea 6 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | setRules([ 5 | '@PSR2' => true, 6 | 'concat_space' => ['spacing' => 'one'], 7 | 'no_unused_imports' => true, 8 | 'method_argument_space' => false, 9 | ]) 10 | ->setFinder( 11 | PhpCsFixer\Finder::create() 12 | ->in(__DIR__) 13 | ->exclude(__DIR__.'/vendor') 14 | ) 15 | ; 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHPUnit Retry 2 | 3 | Traits for retrying test methods and classes in PHPUnit. 4 | 5 | ## Installation 6 | 7 | ``` 8 | composer require --dev bshaffer/phpunit-retry-annotations 9 | ``` 10 | 11 | ## Configuring retries 12 | 13 | ### Retry using a specified number of retries 14 | 15 | ```php 16 | /** 17 | * @retryAttempts 2 18 | */ 19 | class MyTest extends PHPUnit\Framework\TestCase 20 | { 21 | use PHPUnitRetry\RetryTrait; 22 | 23 | public function testSomethingFlakeyTwice() 24 | { 25 | // Retry a flakey test up to two times 26 | } 27 | 28 | /** 29 | * @retryAttempts 3 30 | */ 31 | public function testSomethingFlakeyThreeTimes() 32 | { 33 | // Retry a flakey test up to three times 34 | } 35 | } 36 | ``` 37 | 38 | **NOTE:** "Attempts" represents the number of times a test is retried. 39 | Providing "@retryAttempts" a value of 0 has no effect, and would not retry. 40 | 41 | ### Retry until a specific duration has passed 42 | 43 | ```php 44 | /** 45 | * @retryForSeconds 90 46 | */ 47 | class MyTest extends PHPUnit\Framework\TestCase 48 | { 49 | use PHPUnitRetry\RetryTrait; 50 | 51 | public function testSomethingFlakeyFor90Seconds() 52 | { 53 | // retries for 90 seconds 54 | } 55 | 56 | /** 57 | * @retryForSeconds 1800 58 | */ 59 | public function testSomethingFlakeyFor30Minutes() 60 | { 61 | // retries for 30 minutes 62 | } 63 | } 64 | ``` 65 | 66 | ## Configuring retry conditions 67 | 68 | 69 | ### Retry only for certain exceptions 70 | 71 | By default, retrying happens when any exception other than 72 | `PHPUnit\Framework\IncompleteTestError` and `PHPUnit\Framework\SkippedTestError` 73 | is thrown. 74 | 75 | Because you may not always want to retry, you can configure your test to only 76 | retry under certain conditions. For example, you can only retry if your tests 77 | throw a certain exception. 78 | 79 | ```php 80 | /** 81 | * @retryAttempts 3 82 | * @retryIfException MyApi\ResourceExhaustedException 83 | */ 84 | ``` 85 | 86 | You can retry for multiple exceptions. 87 | 88 | ```php 89 | /** 90 | * @retryAttempts 3 91 | * @retryIfException MyApi\RateLimitExceededException 92 | * @retryIfException ServiceUnavailableException 93 | */ 94 | ``` 95 | 96 | ### Retry based on a custom method 97 | 98 | For more complex logic surrounding whether you should retry, define a custom 99 | retry method: 100 | 101 | ```php 102 | /** 103 | * @retryAttempts 3 104 | * @retryIfMethod isRateLimitExceededException 105 | */ 106 | public function testWithCustomRetryMethod() 107 | { 108 | // retries only if the method `isRateLimitExceededException` returns true. 109 | } 110 | 111 | /** 112 | * @param Exception $e 113 | */ 114 | private function isRateLimitExceededException(Exception $e) 115 | { 116 | // Check if HTTP Status code is 429 "Too many requests" 117 | return ($e instanceof HttpException && $e->getStatusCode() == 429); 118 | } 119 | ``` 120 | 121 | Define arbitrary arguments for your retry method by passing them into the 122 | annotation: 123 | 124 | ```php 125 | /** 126 | * @retryAttempts 3 127 | * @retryIfMethod exceptionStatusCode 429 128 | */ 129 | public function testWithCustomRetryMethod() 130 | { 131 | // retries only if the method `exceptionStatusCode` returns true. 132 | } 133 | 134 | /** 135 | * @param Exception $e 136 | */ 137 | private function exceptionStatusCode(Exception $e, $statusCode) 138 | { 139 | // Check if HTTP status code is $statusCode 140 | return ($e instanceof HttpException && $e->getStatusCode() == $statusCode); 141 | } 142 | ``` 143 | ## Configuring delay 144 | 145 | ### Delay for a duration between each retry 146 | 147 | ```php 148 | /** 149 | * @retryAttempts 3 150 | * @retryDelaySeconds 10 151 | */ 152 | ``` 153 | 154 | ### Delay for an amount increasing exponentially based on the retry attempt 155 | 156 | ```php 157 | /** 158 | * @retryAttempts 3 159 | * @retryDelayMethod exponentialBackoff 160 | */ 161 | ``` 162 | 163 | The behavior of the `exponentialBackoff` method is to start at 1 164 | second and increase to a maximum of 60 seconds. The maximum delay can be 165 | customized by supplying a second argument to the annotation 166 | 167 | ```php 168 | /** 169 | * This test will delay with exponential backoff, with a maximum delay of 10 minutes. 170 | * 171 | * @retryAttempts 30 172 | * @retryDelayMethod exponentialBackoff 600 173 | */ 174 | ``` 175 | 176 | ### Define a custom delay method 177 | 178 | ```php 179 | /** 180 | * @retryAttempts 3 181 | * @retryDelayMethod myCustomDelay 182 | */ 183 | public function testWithCustomDelay() 184 | { 185 | // retries using the method `myCustomDelay`. 186 | } 187 | 188 | /** 189 | * @param int $attempt The current test attempt 190 | */ 191 | private function myCustomDelay($attempt) 192 | { 193 | // Doubles the sleep each attempt, but not longer than 10 seconds. 194 | sleep(min($attempt * 2, 10)); 195 | } 196 | ``` 197 | 198 | Define arbitrary arguments for your delay function by passing them into the 199 | annotation: 200 | 201 | ```php 202 | /** 203 | * @retryAttempts 3 204 | * @retryDelayMethod myCustomDelay 10 60 205 | */ 206 | public function testWithCustomDelay() 207 | { 208 | // retries using the method `myCustomDelay`. 209 | } 210 | 211 | /** 212 | * @param int $attempt The current test attempt. 213 | * @param int $multiplier Rate of exponential backoff delay. 214 | * @param int $maxDelay Maximum time to wait regardless of retry attempt. 215 | */ 216 | private function myCustomDelay($attempt, $multiplier, $maxDelay) 217 | { 218 | // Increases exponentially 219 | sleep(min($attempt * $multiplier, $max)); 220 | } 221 | ``` 222 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bshaffer/phpunit-retry-annotations", 3 | "description": "Traits for retrying test methods and classes in PHPUnit", 4 | "keywords": ["test", "retry", "phpunit"], 5 | "homepage": "https://github.com/bshaffer/phpunit-retry", 6 | "type": "library", 7 | "license": "Apache-2.0", 8 | "authors": [ 9 | { 10 | "name": "Brent Shaffer", 11 | "email": "bshafs@gmail.com", 12 | "homepage": "https://brentertainment.com" 13 | } 14 | ], 15 | "require": { 16 | "php": "^7.1|^8.0", 17 | "phpunit/phpunit": "^7.1|^8.0|^9.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "PHPUnitRetry\\": "src/" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "PHPUnitRetry\\Tests\\": "tests/" 27 | } 28 | }, 29 | "require-dev": { 30 | "friendsofphp/php-cs-fixer": "^2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | tests 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/RetryAnnotationTrait.php: -------------------------------------------------------------------------------- 1 | getTestAnnotations(); 22 | $retries = 0; 23 | 24 | if (isset($annotations['method']['retryAttempts'][0])) { 25 | $retries = $annotations['method']['retryAttempts'][0]; 26 | } elseif (isset($annotations['class']['retryAttempts'][0])) { 27 | $retries = $annotations['class']['retryAttempts'][0]; 28 | } 29 | 30 | return $this->parseRetryAttemptsAnnotation($retries); 31 | } 32 | 33 | private function parseRetryAttemptsAnnotation(string $retries): int 34 | { 35 | if ('' === $retries) { 36 | throw new InvalidArgumentException( 37 | 'The @retryAttempts annotation requires an integer as an argument' 38 | ); 39 | } 40 | if (false === is_numeric($retries)) { 41 | throw new InvalidArgumentException(sprintf( 42 | 'The @retryAttempts annotation must be an integer but got "%s"', 43 | var_export($retries, true) 44 | )); 45 | } 46 | if ((float) $retries !== (float) (int) $retries) { 47 | throw new InvalidArgumentException(sprintf( 48 | 'The @retryAttempts annotation must be an integer but got "%s"', 49 | (float) $retries 50 | )); 51 | } 52 | $retries = (int) $retries; 53 | if ($retries < 0) { 54 | throw new InvalidArgumentException(sprintf( 55 | 'The @retryAttempts annotation must be 0 or greater but got "%s".', 56 | $retries 57 | )); 58 | } 59 | return $retries; 60 | } 61 | 62 | private function getRetryDelaySecondsAnnotation(): int 63 | { 64 | $annotations = $this->getTestAnnotations(); 65 | $retryDelaySeconds = 0; 66 | 67 | if (isset($annotations['method']['retryDelaySeconds'][0])) { 68 | $retryDelaySeconds = $annotations['method']['retryDelaySeconds'][0]; 69 | } elseif (isset($annotations['class']['retryDelaySeconds'][0])) { 70 | $retryDelaySeconds = $annotations['class']['retryDelaySeconds'][0]; 71 | } 72 | 73 | return $this->parseRetryDelaySecondsAnnotation($retryDelaySeconds); 74 | } 75 | 76 | private function parseRetryDelaySecondsAnnotation(string $retryDelaySeconds): int 77 | { 78 | if ('' === $retryDelaySeconds) { 79 | throw new InvalidArgumentException( 80 | 'The @retryDelaySeconds annotation requires an integer as an argument' 81 | ); 82 | } 83 | if (false === is_numeric($retryDelaySeconds)) { 84 | throw new InvalidArgumentException(sprintf( 85 | 'The @retryDelaySeconds annotation must be an integer but got "%s"', 86 | var_export($retryDelaySeconds, true) 87 | )); 88 | } 89 | if ((float) $retryDelaySeconds !== (float) (int) $retryDelaySeconds) { 90 | throw new InvalidArgumentException(sprintf( 91 | 'The @retryDelaySeconds annotation must be an integer but got "%s"', 92 | floatval($retryDelaySeconds) 93 | )); 94 | } 95 | $retryDelaySeconds = (int) $retryDelaySeconds; 96 | if ($retryDelaySeconds < 0) { 97 | throw new InvalidArgumentException(sprintf( 98 | 'The @retryDelaySeconds annotation must be 0 or greater but got "%s".', 99 | $retryDelaySeconds 100 | )); 101 | } 102 | return $retryDelaySeconds; 103 | } 104 | 105 | private function getRetryDelayMethodAnnotation(): ?array 106 | { 107 | $annotations = $this->getTestAnnotations(); 108 | 109 | if (isset($annotations['method']['retryDelayMethod'][0])) { 110 | $delayAnnotation = $annotations['method']['retryDelayMethod']; 111 | } elseif (isset($annotations['class']['retryDelayMethod'][0])) { 112 | $delayAnnotation = $annotations['class']['retryDelayMethod']; 113 | } else { 114 | return null; 115 | } 116 | 117 | $delayAnnotations = explode(' ', $delayAnnotation[0]); 118 | $delayMethod = $delayAnnotations[0]; 119 | $delayMethodArgs = array_slice($delayAnnotations, 1); 120 | 121 | return [ 122 | $this->parseRetryDelayMethodAnnotation($delayMethod), 123 | $delayMethodArgs, 124 | ]; 125 | } 126 | 127 | private function parseRetryDelayMethodAnnotation(string $delayMethod): string 128 | { 129 | if ('' === $delayMethod) { 130 | throw new InvalidArgumentException( 131 | 'The @retryDelayMethod annotation requires a callable as an argument' 132 | ); 133 | } 134 | if (false === is_callable([$this, $delayMethod])) { 135 | throw new InvalidArgumentException(sprintf( 136 | 'The @retryDelayMethod annotation must be a method in your test class but got "%s"', 137 | $delayMethod 138 | )); 139 | } 140 | return $delayMethod; 141 | } 142 | 143 | private function getRetryForSecondsAnnotation(): ?int 144 | { 145 | $annotations = $this->getTestAnnotations(); 146 | 147 | if (isset($annotations['method']['retryForSeconds'][0])) { 148 | $retryForSeconds = $annotations['method']['retryForSeconds'][0]; 149 | } elseif (isset($annotations['class']['retryForSeconds'][0])) { 150 | $retryForSeconds = $annotations['class']['retryForSeconds'][0]; 151 | } else { 152 | return null; 153 | } 154 | 155 | return $this->parseRetryForSecondsAnnotation($retryForSeconds); 156 | } 157 | 158 | private function parseRetryForSecondsAnnotation(string $retryForSeconds): int 159 | { 160 | if ('' === $retryForSeconds) { 161 | throw new InvalidArgumentException( 162 | 'The @retryForSeconds annotation requires an integer as an argument' 163 | ); 164 | } 165 | if (false === is_numeric($retryForSeconds)) { 166 | throw new InvalidArgumentException(sprintf( 167 | 'The @retryForSeconds annotation must be an integer but got "%s"', 168 | var_export($retryForSeconds, true) 169 | )); 170 | } 171 | if ((float) $retryForSeconds !== (float)(int) $retryForSeconds) { 172 | throw new InvalidArgumentException(sprintf( 173 | 'The @retryForSeconds annotation must be an integer but got "%s"', 174 | (float) $retryForSeconds 175 | )); 176 | } 177 | $retryForSeconds = (int) $retryForSeconds; 178 | if ($retryForSeconds < 0) { 179 | throw new InvalidArgumentException(sprintf( 180 | 'The @retryForSeconds annotation must be 0 or greater but got "%s".', 181 | $retryForSeconds 182 | )); 183 | } 184 | return $retryForSeconds; 185 | } 186 | 187 | private function getRetryIfExceptionAnnotations(): ?array 188 | { 189 | $annotations = $this->getTestAnnotations(); 190 | 191 | if (isset($annotations['method']['retryIfException'][0])) { 192 | $retryIfExceptions = []; 193 | foreach ($annotations['method']['retryIfException'] as $retryIfException) { 194 | $this->validateRetryIfExceptionAnnotation($retryIfException); 195 | $retryIfExceptions[] = $retryIfException; 196 | } 197 | return $retryIfExceptions; 198 | } 199 | 200 | return null; 201 | } 202 | 203 | private function validateRetryIfExceptionAnnotation(string $retryIfException): void 204 | { 205 | if ('' === $retryIfException) { 206 | throw new InvalidArgumentException( 207 | 'The @retryIfException annotation requires a class name as an argument' 208 | ); 209 | } 210 | 211 | if (!class_exists($retryIfException)) { 212 | throw new InvalidArgumentException(sprintf( 213 | 'The @retryIfException annotation must be an instance of Exception but got "%s"', 214 | $retryIfException 215 | )); 216 | } 217 | } 218 | 219 | private function getRetryIfMethodAnnotation(): ?array 220 | { 221 | $annotations = $this->getTestAnnotations(); 222 | 223 | if (!isset($annotations['method']['retryIfMethod'][0])) { 224 | return null; 225 | } 226 | 227 | $retryIfMethodAnnotation = explode(' ', $annotations['method']['retryIfMethod'][0]); 228 | $retryIfMethod = $retryIfMethodAnnotation[0]; 229 | $retryIfMethodArgs = array_slice($retryIfMethodAnnotation, 1); 230 | 231 | $this->validateRetryIfMethodAnnotation($retryIfMethod); 232 | 233 | return [ 234 | $retryIfMethod, 235 | $retryIfMethodArgs, 236 | ]; 237 | } 238 | 239 | private function validateRetryIfMethodAnnotation(string $retryIfMethod): void 240 | { 241 | if ('' === $retryIfMethod) { 242 | throw new InvalidArgumentException( 243 | 'The @retryIfMethod annotation requires a callable as an argument' 244 | ); 245 | } 246 | if (false === is_callable([$this, $retryIfMethod])) { 247 | throw new InvalidArgumentException(sprintf( 248 | 'The @retryIfMethod annotation must be a method in your test class but got "%s"', 249 | $retryIfMethod 250 | )); 251 | } 252 | } 253 | 254 | private function getTestAnnotations(): array 255 | { 256 | $inheritedAnnotations = array_reduce(class_parents($this), function ($memo, $class) { 257 | return array_replace_recursive($memo, TestUtil::parseTestMethodAnnotations($class)); 258 | }, []); 259 | 260 | $annotations = TestUtil::parseTestMethodAnnotations(static::class, $this->getName(false)); 261 | 262 | return array_replace_recursive($inheritedAnnotations, $annotations); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/RetryTrait.php: -------------------------------------------------------------------------------- 1 | checkShouldRetryForException($e)) { 42 | throw $e; 43 | } 44 | $retryAttempt++; 45 | } while ($this->checkShouldRetryAgain($retryAttempt)); 46 | 47 | throw $e; 48 | } 49 | 50 | private function checkShouldRetryAgain(int $retryAttempt): bool 51 | { 52 | if ($retryAttempts = $this->getRetryAttemptsAnnotation()) { 53 | // Maximum retry attempts exceeded 54 | if ($retryAttempt > $retryAttempts) { 55 | return false; 56 | } 57 | 58 | // Log retry 59 | printf( 60 | '[RETRY] Retrying %s of %s' . PHP_EOL, 61 | $retryAttempt, 62 | $retryAttempts 63 | ); 64 | } elseif ($retryFor = $this->getRetryForSecondsAnnotation()) { 65 | if (self::$timeOfFirstRetry === null) { 66 | self::$timeOfFirstRetry = time(); 67 | } 68 | 69 | // Maximum retry duration exceeded 70 | $secondsRemaining = self::$timeOfFirstRetry + $retryFor - time(); 71 | if ($secondsRemaining < 0) { 72 | return false; 73 | } 74 | 75 | // Log retry 76 | printf( 77 | '[RETRY] Retrying %s (%s %s remaining)' . PHP_EOL, 78 | $retryAttempt, 79 | $secondsRemaining, 80 | $secondsRemaining === 1 ? 'second' : 'seconds' 81 | ); 82 | } else { 83 | return false; 84 | } 85 | 86 | // Execute delay function 87 | $this->executeRetryDelayFunction($retryAttempt); 88 | 89 | return true; 90 | } 91 | 92 | private function checkShouldRetryForException(Exception $e): bool 93 | { 94 | if ($retryIfExceptions = $this->getRetryIfExceptionAnnotations()) { 95 | foreach ($retryIfExceptions as $retryIfException) { 96 | if ($e instanceof $retryIfException) { 97 | return true; 98 | } 99 | } 100 | return false; 101 | } 102 | 103 | if ($retryIfMethodAnnotation = $this->getRetryIfMethodAnnotation()) { 104 | [$retryIfMethod, $retryIfMethodArgs] = $retryIfMethodAnnotation; 105 | 106 | array_unshift($retryIfMethodArgs, $e); 107 | return call_user_func_array([$this, $retryIfMethod], $retryIfMethodArgs); 108 | } 109 | 110 | // Retry all exceptions by default 111 | return true; 112 | } 113 | 114 | private function executeRetryDelayFunction(int $retryAttempt): ?int 115 | { 116 | if ($delaySeconds = $this->getRetryDelaySecondsAnnotation()) { 117 | sleep($delaySeconds); 118 | } elseif ($delayMethodAnnotation = $this->getRetryDelayMethodAnnotation()) { 119 | [$delayMethod, $delayMethodArgs] = $delayMethodAnnotation; 120 | array_unshift($delayMethodArgs, $retryAttempt); 121 | call_user_func_array([$this, $delayMethod], $delayMethodArgs); 122 | } 123 | 124 | return null; 125 | } 126 | 127 | /** 128 | * A delay function implementing an exponential backoff. Use it in your 129 | * tests like this: 130 | * 131 | * /** 132 | * * This test will delay with exponential backoff 133 | * * 134 | * * @retryAttempts 3 135 | * * @retryDelayMethod exponentialBackoff 136 | * * ... 137 | * 138 | * It is also possible to pass an argument to extend the maximum delay 139 | * seconds, which defaults to 60 seconds: 140 | * 141 | * /** 142 | * * This test will delay with exponential backoff, with a maximum delay of 1 hr. 143 | * * 144 | * * @retryAttempts 30 145 | * * @retryDelayMethod exponentialBackoff 3600 146 | * * ... 147 | */ 148 | private function exponentialBackoff($retryAttempt, $maxDelaySeconds = 60): void 149 | { 150 | $sleep = min( 151 | mt_rand(0, 1000000) + (pow(2, $retryAttempt) * 1000000), 152 | $maxDelaySeconds * 1000000 153 | ); 154 | usleep($sleep); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/InheritedTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(2, $this->getRetryAttemptsAnnotation()); 23 | } 24 | 25 | /** 26 | * @retryAttempts 3 27 | */ 28 | public function testMethodRetries(): void 29 | { 30 | $this->assertEquals(3, $this->getRetryAttemptsAnnotation()); 31 | } 32 | 33 | /** 34 | * @retryAttempts 35 | */ 36 | public function testNoArgumentToRetryAttemptsAnnotation(): void 37 | { 38 | $this->expectException('InvalidArgumentException'); 39 | $this->expectExceptionMessage('The @retryAttempts annotation requires an integer as an argument'); 40 | $this->getRetryAttemptsAnnotation(); 41 | } 42 | 43 | public function testEmptyStringToRetryAttemptsAnnotation(): void 44 | { 45 | $this->expectException('InvalidArgumentException'); 46 | $this->expectExceptionMessage('The @retryAttempts annotation requires an integer as an argument'); 47 | $this->parseRetryAttemptsAnnotation(''); 48 | } 49 | 50 | public function testInvalidStringArgumentTypeToRetryAttemptsAnnotation(): void 51 | { 52 | $this->expectException('InvalidArgumentException'); 53 | $this->expectExceptionMessage('The @retryAttempts annotation must be an integer but got "\'foo\'"'); 54 | $this->parseRetryAttemptsAnnotation('foo'); 55 | } 56 | 57 | public function testInvalidFloatArgumentTypeToRetryAttemptsAnnotation(): void 58 | { 59 | $this->expectException('InvalidArgumentException'); 60 | $this->expectExceptionMessage('The @retryAttempts annotation must be an integer but got "1.2"'); 61 | $this->parseRetryAttemptsAnnotation('1.2'); 62 | } 63 | 64 | public function testNonPositiveIntegerToRetryAttemptsAnnotation(): void 65 | { 66 | $this->expectException('InvalidArgumentException'); 67 | $this->expectExceptionMessage('The @retryAttempts annotation must be 0 or greater but got "-1"'); 68 | $this->parseRetryAttemptsAnnotation(-1); 69 | } 70 | 71 | public function testValidRetryAttemptsAnnotations(): void 72 | { 73 | $this->assertEquals(0, $this->parseRetryAttemptsAnnotation('0')); 74 | $this->assertEquals(1, $this->parseRetryAttemptsAnnotation('1')); 75 | $this->assertEquals(1, $this->parseRetryAttemptsAnnotation('1.0')); 76 | } 77 | 78 | public function testClassRetryDelaySeconds(): void 79 | { 80 | $this->assertEquals(1, $this->getRetryDelaySecondsAnnotation()); 81 | } 82 | 83 | /** 84 | * @retryDelaySeconds 2 85 | */ 86 | public function testMethodRetryDelaySeconds(): void 87 | { 88 | $this->assertEquals(2, $this->getRetryDelaySecondsAnnotation()); 89 | } 90 | 91 | /** 92 | * @retryDelaySeconds 93 | */ 94 | public function testNoArgumentToRetryDelaySecondsAnnotation(): void 95 | { 96 | $this->expectException('InvalidArgumentException'); 97 | $this->expectExceptionMessage('The @retryDelaySeconds annotation requires an integer as an argument'); 98 | $this->getRetryDelaySecondsAnnotation(); 99 | } 100 | 101 | public function testEmptyStringToRetryDelaySecondsAnnotation(): void 102 | { 103 | $this->expectException('InvalidArgumentException'); 104 | $this->expectExceptionMessage('The @retryDelaySeconds annotation requires an integer as an argument'); 105 | $this->parseRetryDelaySecondsAnnotation(''); 106 | } 107 | 108 | public function testInvalidStringArgumentTypeToRetryDelaySecondsAnnotation(): void 109 | { 110 | $this->expectException('InvalidArgumentException'); 111 | $this->expectExceptionMessage('The @retryDelaySeconds annotation must be an integer but got "\'foo\'"'); 112 | $this->parseRetryDelaySecondsAnnotation('foo'); 113 | } 114 | 115 | public function testInvalidFloatArgumentTypeToRetryDelaySecondsAnnotation(): void 116 | { 117 | $this->expectException('InvalidArgumentException'); 118 | $this->expectExceptionMessage('The @retryDelaySeconds annotation must be an integer but got "1.2"'); 119 | $this->parseRetryDelaySecondsAnnotation('1.2'); 120 | } 121 | 122 | public function testNonPositiveIntegerToRetryDelaySecondsAnnotation(): void 123 | { 124 | $this->expectException('InvalidArgumentException'); 125 | $this->expectExceptionMessage('The @retryDelaySeconds annotation must be 0 or greater but got "-1"'); 126 | $this->parseRetryDelaySecondsAnnotation(-1); 127 | } 128 | 129 | public function testValidRetryDelaySecondsAnnotations(): void 130 | { 131 | $this->assertEquals(0, $this->parseRetryDelaySecondsAnnotation('0')); 132 | $this->assertEquals(1, $this->parseRetryDelaySecondsAnnotation('1')); 133 | $this->assertEquals(1, $this->parseRetryDelaySecondsAnnotation('1.0')); 134 | } 135 | 136 | public function testClassRetryDelayMethod(): void 137 | { 138 | $this->assertEquals( 139 | ['fakeDelayMethod1', []], 140 | $this->getRetryDelayMethodAnnotation() 141 | ); 142 | } 143 | 144 | /** 145 | * @retryDelayMethod fakeDelayMethod2 146 | */ 147 | public function testMethodRetryDelayMethod(): void 148 | { 149 | $this->assertEquals( 150 | ['fakeDelayMethod2', []], 151 | $this->getRetryDelayMethodAnnotation() 152 | ); 153 | } 154 | 155 | /** 156 | * @retryDelayMethod fakeDelayMethod2 foo1 foo2 157 | */ 158 | public function testMethodRetryDelayMethodWithArguments(): void 159 | { 160 | $this->assertEquals( 161 | ['fakeDelayMethod2', ['foo1', 'foo2']], 162 | $this->getRetryDelayMethodAnnotation() 163 | ); 164 | } 165 | 166 | /** 167 | * @retryDelayMethod 168 | */ 169 | public function testNoArgumentToRetryDelayMethodAnnotation(): void 170 | { 171 | $this->expectException('InvalidArgumentException'); 172 | $this->expectExceptionMessage('The @retryDelayMethod annotation requires a callable as an argument'); 173 | $this->getRetryDelayMethodAnnotation(); 174 | } 175 | 176 | public function testEmptyStringToRetryDelayMethodAnnotation(): void 177 | { 178 | $this->expectException('InvalidArgumentException'); 179 | $this->expectExceptionMessage('The @retryDelayMethod annotation requires a callable as an argument'); 180 | $this->parseRetryDelayMethodAnnotation(''); 181 | } 182 | 183 | public function testInvalidCallableArgumentTypeToRetryDelayMethodAnnotation(): void 184 | { 185 | $this->expectException('InvalidArgumentException'); 186 | $this->expectExceptionMessage('The @retryDelayMethod annotation must be a method in your test class but got "nonexistantDelayMethod"'); 187 | $this->parseRetryDelayMethodAnnotation('nonexistantDelayMethod'); 188 | } 189 | 190 | public function testClassRetryForSeconds(): void 191 | { 192 | $this->assertEquals(1, $this->getRetryForSecondsAnnotation()); 193 | } 194 | 195 | /** 196 | * @retryForSeconds 2 197 | */ 198 | public function testMethodRetryForSeconds(): void 199 | { 200 | $this->assertEquals(2, $this->getRetryForSecondsAnnotation()); 201 | } 202 | 203 | /** 204 | * @retryForSeconds 205 | */ 206 | public function testNoArgumentToRetryForSecondsAnnotation(): void 207 | { 208 | $this->expectException('InvalidArgumentException'); 209 | $this->expectExceptionMessage('The @retryForSeconds annotation requires an integer as an argument'); 210 | $this->getRetryForSecondsAnnotation(); 211 | } 212 | 213 | public function testEmptyStringToRetryForSecondsAnnotation(): void 214 | { 215 | $this->expectException('InvalidArgumentException'); 216 | $this->expectExceptionMessage('The @retryForSeconds annotation requires an integer as an argument'); 217 | $this->parseRetryForSecondsAnnotation(''); 218 | } 219 | 220 | public function testInvalidStringArgumentTypeToRetryForSecondsAnnotation(): void 221 | { 222 | $this->expectException('InvalidArgumentException'); 223 | $this->expectExceptionMessage('The @retryForSeconds annotation must be an integer but got "\'foo\'"'); 224 | $this->parseRetryForSecondsAnnotation('foo'); 225 | } 226 | 227 | public function testInvalidFloatArgumentTypeToRetryForSecondsAnnotation(): void 228 | { 229 | $this->expectException('InvalidArgumentException'); 230 | $this->expectExceptionMessage('The @retryForSeconds annotation must be an integer but got "1.2"'); 231 | $this->parseRetryForSecondsAnnotation('1.2'); 232 | } 233 | 234 | public function testNonPositiveIntegerToRetryForSecondsAnnotation(): void 235 | { 236 | $this->expectException('InvalidArgumentException'); 237 | $this->expectExceptionMessage('The @retryForSeconds annotation must be 0 or greater but got "-1"'); 238 | $this->parseRetryForSecondsAnnotation(-1); 239 | } 240 | 241 | public function testValidRetryForSecondsAnnotations(): void 242 | { 243 | $this->assertEquals(0, $this->parseRetryForSecondsAnnotation(0)); 244 | $this->assertEquals(0, $this->parseRetryForSecondsAnnotation('0')); 245 | $this->assertEquals(1, $this->parseRetryForSecondsAnnotation(1)); 246 | $this->assertEquals(1, $this->parseRetryForSecondsAnnotation('1')); 247 | $this->assertEquals(1, $this->parseRetryForSecondsAnnotation(1.0)); 248 | $this->assertEquals(1, $this->parseRetryForSecondsAnnotation('1.0')); 249 | } 250 | 251 | /** 252 | * @retryIfMethod fakeIfMethod2 253 | */ 254 | public function testMethodRetryIfMethod(): void 255 | { 256 | $this->assertEquals( 257 | ['fakeIfMethod2', []], 258 | $this->getRetryIfMethodAnnotation() 259 | ); 260 | } 261 | 262 | /** 263 | * @retryIfMethod fakeIfMethod2 foo1 foo2 264 | */ 265 | public function testMethodRetryIfMethodWithArguments(): void 266 | { 267 | $this->assertEquals( 268 | ['fakeIfMethod2', ['foo1', 'foo2']], 269 | $this->getRetryIfMethodAnnotation() 270 | ); 271 | } 272 | 273 | /** 274 | * @retryIfMethod 275 | */ 276 | public function testNoArgumentToRetryIfMethodAnnotation(): void 277 | { 278 | $this->expectException('InvalidArgumentException'); 279 | $this->expectExceptionMessage('The @retryIfMethod annotation requires a callable as an argument'); 280 | $this->getRetryIfMethodAnnotation(); 281 | } 282 | 283 | public function testEmptyStringToRetryIfMethodAnnotation(): void 284 | { 285 | $this->expectException('InvalidArgumentException'); 286 | $this->expectExceptionMessage('The @retryIfMethod annotation requires a callable as an argument'); 287 | $this->validateRetryIfMethodAnnotation(''); 288 | } 289 | 290 | public function testInvalidCallableArgumentTypeToRetryIfMethodAnnotation(): void 291 | { 292 | $this->expectException('InvalidArgumentException'); 293 | $this->expectExceptionMessage('The @retryIfMethod annotation must be a method in your test class but got "nonexistantIfMethod"'); 294 | $this->validateRetryIfMethodAnnotation('nonexistantIfMethod'); 295 | } 296 | 297 | /** 298 | * @retryIfException InvalidArgumentException 299 | */ 300 | public function testRetryIfException(): void 301 | { 302 | $this->assertEquals( 303 | ['InvalidArgumentException'], 304 | $this->getRetryIfExceptionAnnotations() 305 | ); 306 | } 307 | 308 | /** 309 | * @retryIfException LogicException 310 | * @retryIfException InvalidArgumentException 311 | */ 312 | public function testMultipleRetryIfException(): void 313 | { 314 | $this->assertEquals( 315 | ['LogicException', 'InvalidArgumentException'], 316 | $this->getRetryIfExceptionAnnotations() 317 | ); 318 | } 319 | 320 | /** 321 | * @retryIfException 322 | */ 323 | public function testNoArgumentToRetryIfExceptionAnnotation(): void 324 | { 325 | $this->expectException('InvalidArgumentException'); 326 | $this->expectExceptionMessage('The @retryIfException annotation requires a class name as an argument'); 327 | $this->getRetryIfExceptionAnnotations(); 328 | } 329 | 330 | /** 331 | * @retryIfException ThisClassDoesNotExist 332 | */ 333 | public function testRetryIfExceptionWithInvalidClassname(): void 334 | { 335 | $this->expectException('InvalidArgumentException'); 336 | $this->expectExceptionMessage('The @retryIfException annotation must be an instance of Exception but got "ThisClassDoesNotExist"'); 337 | $this->getRetryIfExceptionAnnotations(); 338 | } 339 | 340 | private function fakeDelayMethod1(): void 341 | { 342 | } 343 | 344 | private function fakeDelayMethod2(): void 345 | { 346 | } 347 | 348 | private function fakeIfMethod1(): void 349 | { 350 | } 351 | 352 | private function fakeIfMethod2(): void 353 | { 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /tests/RetryTraitTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($this->checkShouldRetryAgain(1)); 28 | } 29 | 30 | /** 31 | * @retryAttempts 3 32 | */ 33 | public function testRetryAttempts(): void 34 | { 35 | self::$timesCalled++; 36 | $retryAttempts = $this->getRetryAttemptsAnnotation(); 37 | if (self::$timesCalled <= $retryAttempts) { 38 | throw new Exception('Intentional Exception'); 39 | } 40 | $this->assertEquals($retryAttempts + 1, self::$timesCalled); 41 | self::$timesCalled = 0; 42 | } 43 | 44 | /** 45 | * @retryAttempts 2 46 | * @retryDelaySeconds 1 47 | * @depends testRetryAttempts 48 | */ 49 | public function testRetryDelaySeconds(): void 50 | { 51 | $currentTimestamp = time(); 52 | if (empty(self::$timestampCalled)) { 53 | self::$timestampCalled = $currentTimestamp; 54 | throw new Exception('Intentional Exception'); 55 | } 56 | 57 | $this->assertGreaterThan(self::$timestampCalled, $currentTimestamp); 58 | self::$timestampCalled = null; 59 | } 60 | 61 | public function testExponentialBackoff(): void 62 | { 63 | $retryAttempt = 0; 64 | $leeway = .01; 65 | $start1 = microtime(true); 66 | $this->exponentialBackoff($retryAttempt); 67 | $end1 = microtime(true); 68 | $this->assertGreaterThan($start1, $end1); 69 | $this->assertLessThan(2 + $leeway, $end1 - $start1); 70 | 71 | $retryAttempt++; 72 | $start2 = microtime(true); 73 | $this->exponentialBackoff($retryAttempt); 74 | $end2 = microtime(true); 75 | $this->assertLessThan(3 + $leeway, $end2 - $start2); 76 | 77 | // Assert higher retryAttempt resulted in a longer delay 78 | $this->assertGreaterThan($end1 - $start1, $end2 - $start2); 79 | 80 | // Assert $maxDelaySeconds applies regardless of $retryAttempt 81 | $retryAttempt = 100; 82 | $maxDelaySeconds = 1; 83 | $start3 = microtime(true); 84 | $this->exponentialBackoff($retryAttempt, $maxDelaySeconds); 85 | $end3 = microtime(true); 86 | $this->assertLessThan(1 + $leeway, $end3 - $start3); 87 | } 88 | 89 | /** 90 | * @retryAttempts 2 91 | * @retryDelayMethod customDelayMethod foo 92 | * @depends testRetryAttempts 93 | */ 94 | public function testCustomRetryDelayMethod(): void 95 | { 96 | self::$timesCalled++; 97 | if (self::$timesCalled === 1) { 98 | throw new Exception('Intentional Exception'); 99 | } 100 | 101 | $this->assertTrue(self::$customDelayMethodCalled); 102 | self::$customDelayMethodCalled = false; 103 | self::$timesCalled = 0; 104 | } 105 | 106 | /** 107 | * @retryForSeconds 2 108 | * @retryDelaySeconds 1 109 | * @depends testCustomRetryDelayMethod 110 | */ 111 | public function testRetryForSeconds(): void 112 | { 113 | $currentTimestamp = time(); 114 | if (empty(self::$timestampCalled)) { 115 | self::$timestampCalled = $currentTimestamp; 116 | } 117 | if ($currentTimestamp < self::$timestampCalled + 3) { 118 | throw new Exception('Intentional Exception'); 119 | } 120 | $this->assertGreaterThan(self::$timestampCalled, $currentTimestamp); 121 | self::$timestampCalled = null; 122 | } 123 | 124 | /** 125 | * @retryAttempts 1 126 | * @retryIfException InvalidArgumentException 127 | * @depends testCustomRetryDelayMethod 128 | */ 129 | public function testRetryIfException(): void 130 | { 131 | self::$timesCalled++; 132 | if (self::$timesCalled === 1) { 133 | throw new InvalidArgumentException('Intentional Exception'); 134 | } 135 | 136 | $this->assertEquals(2, self::$timesCalled); 137 | self::$timesCalled = 0; 138 | } 139 | 140 | /** 141 | * @retryAttempts 1 142 | * @retryIfException InvalidArgumentException 143 | * @retryIfException DomainException 144 | * @depends testRetryIfException 145 | */ 146 | public function testRetryIfExceptionMultiple(): void 147 | { 148 | self::$timesCalled++; 149 | if (self::$timesCalled === 1) { 150 | throw new DomainException('Intentional Exception'); 151 | } 152 | 153 | $this->assertEquals(2, self::$timesCalled); 154 | self::$timesCalled = 0; 155 | } 156 | 157 | /** 158 | * @retryAttempts 1 159 | * @retryIfMethod customRetryIfMethod foo 160 | * @depends testRetryIfExceptionMultiple 161 | */ 162 | public function testRetryIfMethod(): void 163 | { 164 | self::$timesCalled++; 165 | if (self::$timesCalled === 1) { 166 | throw new Exception('Intentional Exception'); 167 | } 168 | 169 | $this->assertTrue(self::$customRetryIfMethodCalled); 170 | self::$customRetryIfMethodCalled = false; 171 | self::$timesCalled = 0; 172 | } 173 | 174 | /** 175 | * @var int $attempt 176 | */ 177 | private function customDelayMethod($attempt): void 178 | { 179 | $this->assertIsInt($attempt); 180 | $this->assertEquals(1, $attempt); 181 | 182 | // Test the custom arg 183 | $this->assertCount(2, $args = func_get_args()); 184 | $this->assertEquals('foo', $args[1]); 185 | self::$customDelayMethodCalled = true; 186 | } 187 | 188 | /** 189 | * @var Exception $e 190 | */ 191 | private function customRetryIfMethod($e): bool 192 | { 193 | $this->assertInstanceOf('Exception', $e); 194 | 195 | // Test the custom arg 196 | $this->assertCount(2, $args = func_get_args()); 197 | $this->assertEquals('foo', $args[1]); 198 | self::$customRetryIfMethodCalled = true; 199 | 200 | return true; 201 | } 202 | } 203 | --------------------------------------------------------------------------------