From 7e92a37b7d1d70042d70409e9bc302b163f5dd86 Mon Sep 17 00:00:00 2001 From: Aaron Francis Date: Wed, 26 Nov 2025 21:27:10 -0600 Subject: [PATCH 1/5] Modernize codebase with PHP 8.1+ features and expand test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add property types, return types, and union types to all source files - Use readonly properties in Result class - Fix composer.json version constraint typos and update dev dependencies - Add PHP 8.4 to test matrix, remove unused MySQL service from CI - Add 12 new tests covering Result, global disable, combined thresholds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/style.yml | 2 +- .github/workflows/tests.yml | 15 +-- composer.json | 12 +- src/Arbiter.php | 48 +++---- src/Flaky.php | 82 ++++++------ src/FlakyCommand.php | 29 ++--- src/Providers/FlakyServiceProvider.php | 10 +- src/Result.php | 22 ++-- tests/Support/Commands/FlakyNoVaryCommand.php | 23 ++++ tests/Support/FlakyTestServiceProvider.php | 2 + tests/Unit/CombinedThresholdsTest.php | 117 ++++++++++++++++++ tests/Unit/CommandTest.php | 17 ++- tests/Unit/GlobalDisableTest.php | 66 ++++++++++ tests/Unit/ResultTest.php | 59 +++++++++ tests/Unit/RetryTest.php | 32 ++++- 15 files changed, 422 insertions(+), 114 deletions(-) create mode 100644 tests/Support/Commands/FlakyNoVaryCommand.php create mode 100644 tests/Unit/CombinedThresholdsTest.php create mode 100644 tests/Unit/GlobalDisableTest.php create mode 100644 tests/Unit/ResultTest.php diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 02422c0..c479ce8 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -18,7 +18,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.3" + php-version: "8.4" extensions: json, dom, curl, libxml, mbstring coverage: none diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fb465a3..9d757ca 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - php: [ 8.1, 8.2, 8.3 ] + php: [ 8.1, 8.2, 8.3, 8.4 ] laravel: [ '10.*', '11.*', '12.*' ] dependency-version: [ prefer-lowest, prefer-stable ] @@ -29,19 +29,6 @@ jobs: name: P${{ matrix.php }} / L${{ matrix.laravel }} / ${{ matrix.dependency-version }} - services: - mysql: - image: mysql:8.0 - env: - MYSQL_DATABASE: fast_paginate - MYSQL_HOST: 127.0.0.1 - MYSQL_USER: test - MYSQL_PASSWORD: root - MYSQL_ROOT_PASSWORD: root - ports: - - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/composer.json b/composer.json index bb4f8be..df1c0de 100644 --- a/composer.json +++ b/composer.json @@ -10,14 +10,14 @@ } ], "require": { - "php": "^8.0", - "illuminate/support": "^10|^11.0|^12.0", - "illuminate/cache": "^10|^11.0|^12.0", - "illuminate/console": "^10|^11.|^12.00" + "php": "^8.1", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/cache": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0" }, "require-dev": { - "mockery/mockery": "^1.3.3", - "phpunit/phpunit": ">=8.5.23|^9|^10", + "mockery/mockery": "^1.6", + "phpunit/phpunit": "^10.5|^11.0", "orchestra/testbench": "^8.0|^9.0|^10.0" }, "autoload": { diff --git a/src/Arbiter.php b/src/Arbiter.php index 9f02c76..0e189dc 100644 --- a/src/Arbiter.php +++ b/src/Arbiter.php @@ -6,102 +6,106 @@ namespace AaronFrancis\Flaky; +use Illuminate\Contracts\Cache\Repository; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Traits\Macroable; +use Throwable; class Arbiter { use Macroable; - public $failuresAllowedForSeconds = 60 * 60 * 24 * 365 * 10; + public int $failuresAllowedForSeconds = 60 * 60 * 24 * 365 * 10; - public $consecutiveFailuresAllowed = INF; + public int|float $consecutiveFailuresAllowed = INF; - public $totalFailuresAllowed = INF; + public int|float $totalFailuresAllowed = INF; + /** @var callable(Throwable): void */ public $handleFailuresWith; - protected $key; + protected string $key; - protected $totalFailures; + protected int $totalFailures; - protected $consecutiveFailures; + protected int $consecutiveFailures; - protected $deadline; + protected ?int $deadline; - protected $cache; + protected Repository $cache; - public function __construct($id) + public function __construct(string $id) { $this->key = "flaky::$id"; $this->cache = Cache::store(); + /** @var array{total?: int, consecutive?: int, deadline?: int|null} $stats */ $stats = $this->cache->get($this->key, []); $this->totalFailures = Arr::get($stats, 'total', 0); $this->consecutiveFailures = Arr::get($stats, 'consecutive', 0); $this->deadline = Arr::get($stats, 'deadline'); - $this->handleFailuresWith = function ($e) { + $this->handleFailuresWith = function (Throwable $e): never { throw $e; }; } - public function handle($exception) + public function handle(?Throwable $exception): void { $this->deadline = $this->deadline ?? $this->freshDeadline(); - if ($exception) { + if ($exception !== null) { $this->totalFailures++; $this->consecutiveFailures++; } $this->updateCachedStats($exception); - if (!is_null($exception) && $this->outOfBounds()) { + if ($exception !== null && $this->outOfBounds()) { $this->callHandler($exception); } } - public function handleFailures($callback) + public function handleFailures(callable $callback): void { $this->handleFailuresWith = $callback; } - public function outOfBounds() + public function outOfBounds(): bool { return $this->tooManyConsecutiveFailures() || $this->tooManyTotalFailures() || $this->beyondDeadline(); } - public function tooManyConsecutiveFailures() + public function tooManyConsecutiveFailures(): bool { return $this->consecutiveFailures > $this->consecutiveFailuresAllowed; } - public function tooManyTotalFailures() + public function tooManyTotalFailures(): bool { return $this->totalFailures > $this->totalFailuresAllowed; } - public function beyondDeadline() + public function beyondDeadline(): bool { return now()->timestamp > $this->deadline; } - protected function callHandler($exception) + protected function callHandler(Throwable $exception): void { call_user_func($this->handleFailuresWith, $exception); } - protected function freshDeadline() + protected function freshDeadline(): int { return now()->addSeconds($this->failuresAllowedForSeconds)->timestamp; } - protected function updateCachedStats($exception) + protected function updateCachedStats(?Throwable $exception): void { - $failed = !is_null($exception); + $failed = $exception !== null; $stats = $failed ? [ // Reset if we passed, otherwise just store the incremented value. diff --git a/src/Flaky.php b/src/Flaky.php index ec4ba47..ca00e6f 100644 --- a/src/Flaky.php +++ b/src/Flaky.php @@ -6,7 +6,6 @@ namespace AaronFrancis\Flaky; -use Exception; use Illuminate\Support\Traits\Macroable; use Throwable; @@ -14,38 +13,40 @@ class Flaky { use Macroable; - protected static $disabledGlobally = false; + protected static bool $disabledGlobally = false; - protected $arbiter; + protected Arbiter $arbiter; - protected $retry = []; + /** @var array{times: int, sleep: int, when: callable|null} */ + protected array $retry = []; - protected $flakyProtectionDisabled = false; + protected bool $flakyProtectionDisabled = false; - protected $flakyExceptions; + /** @var array>|null */ + protected ?array $flakyExceptions = null; - public static function make($id) + public static function make(string $id): static { return new static($id); } - public static function globallyDisable() + public static function globallyDisable(): void { static::$disabledGlobally = true; } - public static function globallyEnable() + public static function globallyEnable(): void { static::$disabledGlobally = false; } - public function __construct($id) + public function __construct(string $id) { $this->retry(); $this->arbiter = new Arbiter($id); } - public function run(callable $callable) + public function run(callable $callable): Result { $exception = null; $value = null; @@ -65,16 +66,16 @@ public function run(callable $callable) return new Result($value, $exception); } - public function handle(?Throwable $exception = null) + public function handle(?Throwable $exception = null): Result { return $this->run(function () use ($exception) { - if (!is_null($exception)) { + if ($exception !== null) { throw $exception; } }); } - public function disableLocally() + public function disableLocally(): static { if (app()->environment('local')) { $this->disableFlakyProtection(); @@ -83,14 +84,17 @@ public function disableLocally() return $this; } - public function disableFlakyProtection($disabled = true) + public function disableFlakyProtection(bool $disabled = true): static { $this->flakyProtectionDisabled = $disabled; return $this; } - public function retry($times = 0, $sleepMilliseconds = 0, $when = null) + /** + * @param string|array>|callable|null $when + */ + public function retry(int $times = 0, int $sleepMilliseconds = 0, string|array|callable|null $when = null): static { // We just store these for now and then use them in the `run` method. $this->retry = [ @@ -102,98 +106,104 @@ public function retry($times = 0, $sleepMilliseconds = 0, $when = null) return $this; } - public function handleFailures($callback) + public function handleFailures(callable $callback): static { $this->arbiter->handleFailures($callback); return $this; } - public function reportFailures() + public function reportFailures(): static { return $this->handleFailures(function ($e) { report($e); }); } - public function throwFailures() + public function throwFailures(): static { return $this->handleFailures(function ($e) { throw $e; }); } - public function allowFailuresFor($seconds = 0, $minutes = 0, $hours = 0, $days = 0) + public function allowFailuresFor(int $seconds = 0, int $minutes = 0, int $hours = 0, int $days = 0): static { return $this->allowFailuresForSeconds( $seconds + (60 * $minutes) + (60 * 60 * $hours) + (60 * 60 * 24 * $days) ); } - public function allowFailuresForSeconds($seconds) + public function allowFailuresForSeconds(int $seconds): static { $this->arbiter->failuresAllowedForSeconds = $seconds; return $this; } - public function allowFailuresForAMinute() + public function allowFailuresForAMinute(): static { return $this->allowFailuresForMinutes(1); } - public function allowFailuresForMinutes($minutes) + public function allowFailuresForMinutes(int $minutes): static { return $this->allowFailuresForSeconds(60 * $minutes); } - public function allowFailuresForAnHour() + public function allowFailuresForAnHour(): static { return $this->allowFailuresForHours(1); } - public function allowFailuresForHours($hours) + public function allowFailuresForHours(int $hours): static { return $this->allowFailuresForSeconds(60 * 60 * $hours); } - public function allowFailuresForADay() + public function allowFailuresForADay(): static { return $this->allowFailuresForDays(1); } - public function allowFailuresForDays($days) + public function allowFailuresForDays(int $days): static { return $this->allowFailuresForSeconds(60 * 60 * 24 * $days); } - public function allowConsecutiveFailures($failures) + public function allowConsecutiveFailures(int|float $failures): static { $this->arbiter->consecutiveFailuresAllowed = $failures; return $this; } - public function allowTotalFailures($failures) + public function allowTotalFailures(int|float $failures): static { $this->arbiter->totalFailuresAllowed = $failures; return $this; } - public function forExceptions(array $exceptions) + /** + * @param array> $exceptions + */ + public function forExceptions(array $exceptions): static { $this->flakyExceptions = $exceptions; return $this; } - protected function protectionsBypassed() + protected function protectionsBypassed(): bool { return static::$disabledGlobally || $this->flakyProtectionDisabled; } - protected function normalizeRetryWhen($when = null) + /** + * @param string|array>|callable|null $when + */ + protected function normalizeRetryWhen(string|array|callable|null $when = null): ?callable { // Support for a single exception if (is_string($when)) { @@ -216,17 +226,17 @@ protected function normalizeRetryWhen($when = null) return $when; } - protected function shouldThrowImmediately(?Throwable $exception = null) + protected function shouldThrowImmediately(?Throwable $exception = null): bool { - if (is_null($exception)) { + if ($exception === null) { return false; } return $this->protectionsBypassed() || !$this->exceptionIsFlaky($exception); } - protected function exceptionIsFlaky(?Throwable $exception = null) + protected function exceptionIsFlaky(?Throwable $exception = null): bool { - return is_null($this->flakyExceptions) || in_array(get_class($exception), $this->flakyExceptions, true); + return $this->flakyExceptions === null || in_array($exception::class, $this->flakyExceptions, true); } } diff --git a/src/FlakyCommand.php b/src/FlakyCommand.php index d30359d..edaf782 100644 --- a/src/FlakyCommand.php +++ b/src/FlakyCommand.php @@ -18,11 +18,12 @@ class FlakyCommand { use Macroable; - protected $command; + protected Command $command; - protected $varyOnInput = false; + /** @var array|false */ + protected array|false $varyOnInput = false; - public static function make(Command $command) + public static function make(Command $command): static { return new static($command); } @@ -32,14 +33,17 @@ public function __construct(Command $command) $this->command = $command; } - public function varyOnInput($keys = []) + /** + * @param array $keys + */ + public function varyOnInput(array $keys = []): static { $this->varyOnInput = $keys; return $this; } - public function instance() + public function instance(): Flaky { return Flaky::make($this->generateCommandId()) // Only enable protection if the command is running @@ -47,22 +51,22 @@ public function instance() ->disableFlakyProtection($disabled = !$this->isScheduledCommand()); } - protected function generateCommandId() + protected function generateCommandId(): string { return implode('-', array_filter([ 'command', $this->command->getName(), - $this->hashInput() + $this->hashInput(), ])); } - protected function isScheduledCommand() + protected function isScheduledCommand(): bool { // See the FlakyServiceProvider to see where this is coming from. return Env::get('IS_SCHEDULED') === '1'; } - protected function hashInput() + protected function hashInput(): string { if ($this->varyOnInput === false) { return ''; @@ -73,7 +77,7 @@ protected function hashInput() $this->command->options() ); - if (count($this->varyOnInput)) { + if (count($this->varyOnInput) > 0) { $input = Arr::only($input, $this->varyOnInput); } @@ -82,10 +86,7 @@ protected function hashInput() return md5(json_encode($input)); } - /** - * @return Flaky - */ - public function __call(string $name, array $arguments) + public function __call(string $name, array $arguments): mixed { return $this->instance()->{$name}(...$arguments); } diff --git a/src/Providers/FlakyServiceProvider.php b/src/Providers/FlakyServiceProvider.php index bd432ed..8e9e0cc 100644 --- a/src/Providers/FlakyServiceProvider.php +++ b/src/Providers/FlakyServiceProvider.php @@ -14,11 +14,9 @@ class FlakyServiceProvider extends ServiceProvider { - public function register() + public function register(): void { - // $this->mergeConfigFrom(__DIR__ . '/../../config/flaky.php', 'flaky'); - - Event::listen(function (CommandStarting $event) { + Event::listen(function (CommandStarting $event): void { // When the schedule is starting we add an ENV variable. That // variable will get propagated down to all spawned commands // via the Symfony Process `getDefaultEnv` method. @@ -28,12 +26,12 @@ public function register() }); } - public function boot() + public function boot(): void { // A workaround for testing due to a change in Laravel 10.4.1 // https://github.com/laravel/framework/pull/46508 if ($this->app->runningUnitTests()) { - $this->app->booted(function () { + $this->app->booted(function (): void { app(Kernel::class)->rerouteSymfonyCommandEvents(); }); } diff --git a/src/Result.php b/src/Result.php index 6d0f78e..760dc1c 100644 --- a/src/Result.php +++ b/src/Result.php @@ -7,33 +7,31 @@ namespace AaronFrancis\Flaky; use Illuminate\Support\Traits\Macroable; +use Throwable; class Result { use Macroable; - public $value; + public readonly mixed $value; - public $failed = false; + public readonly bool $failed; - public $exception; + public readonly ?Throwable $exception; - public $succeeded; + public readonly bool $succeeded; - public function __construct($value, $exception) + public function __construct(mixed $value, ?Throwable $exception) { $this->value = $value; - - $this->succeeded = is_null($exception); - - $this->failed = !$this->succeeded; - $this->exception = $exception; + $this->succeeded = $exception === null; + $this->failed = !$this->succeeded; } - public function throw() + public function throw(): static { - if ($this->exception) { + if ($this->exception !== null) { throw $this->exception; } diff --git a/tests/Support/Commands/FlakyNoVaryCommand.php b/tests/Support/Commands/FlakyNoVaryCommand.php new file mode 100644 index 0000000..d5cdcb7 --- /dev/null +++ b/tests/Support/Commands/FlakyNoVaryCommand.php @@ -0,0 +1,23 @@ + + */ + +namespace AaronFrancis\Flaky\Tests\Support\Commands; + +use AaronFrancis\Flaky\FlakyCommand; +use Illuminate\Console\Command; + +class FlakyNoVaryCommand extends Command +{ + protected $signature = 'flaky:novary {--arg=}'; + + public function handle() + { + $flaky = FlakyCommand::make($this)->instance(); + + // Output the key for testing + $this->line($flaky->getProtected('arbiter')->getProtected('key')); + } +} diff --git a/tests/Support/FlakyTestServiceProvider.php b/tests/Support/FlakyTestServiceProvider.php index bd8a82e..781d7d5 100644 --- a/tests/Support/FlakyTestServiceProvider.php +++ b/tests/Support/FlakyTestServiceProvider.php @@ -8,6 +8,7 @@ use AaronFrancis\Flaky\Arbiter; use AaronFrancis\Flaky\Flaky; +use AaronFrancis\Flaky\Tests\Support\Commands\FlakyNoVaryCommand; use AaronFrancis\Flaky\Tests\Support\Commands\FlakyTestCommand; use AaronFrancis\Flaky\Tests\Support\Commands\FlakyVaryOnInputCommand; use AaronFrancis\Flaky\Tests\Support\Commands\OnlyScheduledCommand; @@ -18,6 +19,7 @@ class FlakyTestServiceProvider extends ServiceProvider public function boot() { $this->commands([ + FlakyNoVaryCommand::class, FlakyTestCommand::class, FlakyVaryOnInputCommand::class, OnlyScheduledCommand::class, diff --git a/tests/Unit/CombinedThresholdsTest.php b/tests/Unit/CombinedThresholdsTest.php new file mode 100644 index 0000000..c5b99f1 --- /dev/null +++ b/tests/Unit/CombinedThresholdsTest.php @@ -0,0 +1,117 @@ + + */ + +namespace AaronFrancis\Flaky\Tests\Unit; + +use AaronFrancis\Flaky\Flaky; +use Carbon\Carbon; +use Exception; +use Illuminate\Support\Facades\Cache; +use PHPUnit\Framework\Attributes\Test; + +class CombinedThresholdsTest extends Base +{ + protected function setUp(): void + { + parent::setUp(); + + // Clear cache for each test + Cache::flush(); + } + + #[Test] + public function consecutive_resets_on_success_but_total_persists(): void + { + $id = __FUNCTION__; + + // Fail twice + for ($i = 0; $i < 2; $i++) { + $result = Flaky::make($id) + ->allowConsecutiveFailures(3) + ->allowTotalFailures(10) + ->run(fn() => throw new Exception); + $this->assertTrue($result->failed); + } + + // Success resets consecutive + $result = Flaky::make($id) + ->allowConsecutiveFailures(3) + ->allowTotalFailures(10) + ->run(fn() => 'success'); + $this->assertTrue($result->succeeded); + + // Can fail 3 more times (consecutive reset) + for ($i = 0; $i < 3; $i++) { + $result = Flaky::make($id) + ->allowConsecutiveFailures(3) + ->allowTotalFailures(10) + ->run(fn() => throw new Exception); + $this->assertTrue($result->failed); + } + + // 4th consecutive should throw + $this->expectException(Exception::class); + Flaky::make($id) + ->allowConsecutiveFailures(3) + ->allowTotalFailures(10) + ->run(fn() => throw new Exception); + } + + #[Test] + public function total_failures_accumulate_across_successes(): void + { + $id = __FUNCTION__; + + // Fail 2, succeed, fail 3 = 5 total + for ($i = 0; $i < 2; $i++) { + Flaky::make($id) + ->allowConsecutiveFailures(100) + ->allowTotalFailures(5) + ->run(fn() => throw new Exception); + } + Flaky::make($id) + ->allowConsecutiveFailures(100) + ->allowTotalFailures(5) + ->run(fn() => 'success'); + for ($i = 0; $i < 3; $i++) { + Flaky::make($id) + ->allowConsecutiveFailures(100) + ->allowTotalFailures(5) + ->run(fn() => throw new Exception); + } + + // 6th total failure should throw + $this->expectException(Exception::class); + Flaky::make($id) + ->allowConsecutiveFailures(100) + ->allowTotalFailures(5) + ->run(fn() => throw new Exception); + } + + #[Test] + public function deadline_and_consecutive_work_together(): void + { + Carbon::setTestNow(); + $id = __FUNCTION__; + + // 2 failures allowed + Flaky::make($id) + ->allowFailuresForSeconds(60) + ->allowConsecutiveFailures(2) + ->run(fn() => throw new Exception); + Flaky::make($id) + ->allowFailuresForSeconds(60) + ->allowConsecutiveFailures(2) + ->run(fn() => throw new Exception); + + // 3rd consecutive throws even within deadline + $this->expectException(Exception::class); + Flaky::make($id) + ->allowFailuresForSeconds(60) + ->allowConsecutiveFailures(2) + ->run(fn() => throw new Exception); + } +} diff --git a/tests/Unit/CommandTest.php b/tests/Unit/CommandTest.php index 3aee13f..d2b9717 100644 --- a/tests/Unit/CommandTest.php +++ b/tests/Unit/CommandTest.php @@ -8,13 +8,26 @@ use Illuminate\Support\Env; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Event; use PHPUnit\Framework\Attributes\Test; class CommandTest extends Base { #[Test] - public function varies_on_command() + public function command_id_without_vary_on_input(): void + { + Artisan::call('flaky:novary --arg=1'); + $one = trim(Artisan::output()); + + Artisan::call('flaky:novary --arg=2'); + $two = trim(Artisan::output()); + + // Without varyOnInput, keys should be the same regardless of args + $this->assertEquals($one, $two); + $this->assertEquals('flaky::command-flaky:novary', $one); + } + + #[Test] + public function varies_on_command(): void { Artisan::call('flaky:vary --arg=1 --flag'); $one = Artisan::output(); diff --git a/tests/Unit/GlobalDisableTest.php b/tests/Unit/GlobalDisableTest.php new file mode 100644 index 0000000..4a978ab --- /dev/null +++ b/tests/Unit/GlobalDisableTest.php @@ -0,0 +1,66 @@ + + */ + +namespace AaronFrancis\Flaky\Tests\Unit; + +use AaronFrancis\Flaky\Flaky; +use Exception; +use PHPUnit\Framework\Attributes\Test; + +class GlobalDisableTest extends Base +{ + protected function tearDown(): void + { + Flaky::globallyEnable(); + + parent::tearDown(); + } + + #[Test] + public function globally_disable_bypasses_all_protection(): void + { + Flaky::globallyDisable(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Should throw immediately'); + + Flaky::make(__FUNCTION__) + ->allowFailuresForADay() + ->run(function () { + throw new Exception('Should throw immediately'); + }); + } + + #[Test] + public function globally_enable_restores_protection(): void + { + Flaky::globallyDisable(); + Flaky::globallyEnable(); + + $result = Flaky::make(__FUNCTION__) + ->allowFailuresForADay() + ->run(function () { + throw new Exception('Should be caught'); + }); + + $this->assertTrue($result->failed); + $this->assertInstanceOf(Exception::class, $result->exception); + } + + #[Test] + public function throw_failures_handler_throws_exception(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Thrown by handler'); + + Flaky::make(__FUNCTION__) + ->allowConsecutiveFailures(0) + ->throwFailures() + ->run(function () { + throw new Exception('Thrown by handler'); + }); + } +} diff --git a/tests/Unit/ResultTest.php b/tests/Unit/ResultTest.php new file mode 100644 index 0000000..9d0012a --- /dev/null +++ b/tests/Unit/ResultTest.php @@ -0,0 +1,59 @@ + + */ + +namespace AaronFrancis\Flaky\Tests\Unit; + +use AaronFrancis\Flaky\Result; +use Exception; +use PHPUnit\Framework\Attributes\Test; + +class ResultTest extends Base +{ + #[Test] + public function throw_rethrows_exception_when_present(): void + { + $exception = new Exception('Test exception'); + $result = new Result(null, $exception); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Test exception'); + + $result->throw(); + } + + #[Test] + public function throw_returns_self_when_no_exception(): void + { + $result = new Result('value', null); + + $returned = $result->throw(); + + $this->assertSame($result, $returned); + } + + #[Test] + public function properties_are_set_correctly_on_success(): void + { + $result = new Result('test-value', null); + + $this->assertEquals('test-value', $result->value); + $this->assertTrue($result->succeeded); + $this->assertFalse($result->failed); + $this->assertNull($result->exception); + } + + #[Test] + public function properties_are_set_correctly_on_failure(): void + { + $exception = new Exception('Failed'); + $result = new Result(null, $exception); + + $this->assertNull($result->value); + $this->assertFalse($result->succeeded); + $this->assertTrue($result->failed); + $this->assertSame($exception, $result->exception); + } +} diff --git a/tests/Unit/RetryTest.php b/tests/Unit/RetryTest.php index 64c4163..0bf4c31 100644 --- a/tests/Unit/RetryTest.php +++ b/tests/Unit/RetryTest.php @@ -14,7 +14,7 @@ class RetryTest extends Base { #[Test] - public function it_will_retry() + public function it_will_retry(): void { $timesRun = 0; @@ -35,6 +35,36 @@ public function it_will_retry() $this->assertEquals(1, $result->value); } + #[Test] + public function it_retries_with_callable_when(): void + { + $timesRun = 0; + + $flaky = Flaky::make(__FUNCTION__) + ->retry(5, 0, function ($e) { + return $e instanceof TimeoutException; + }) + ->allowConsecutiveFailures(10); + + $flaky->run(function () use (&$timesRun) { + $timesRun++; + + throw new TimeoutException; + }); + + // Should retry based on callable returning true + $this->assertEquals(5, $timesRun); + + $flaky->run(function () use (&$timesRun) { + $timesRun++; + + throw new Exception; + }); + + // Callable returns false for base Exception, no retry + $this->assertEquals(6, $timesRun); + } + #[Test] public function it_retries_a_particular_exception_as_single() { From e174b260f10183b41f8f46fc308ef66d45beda73 Mon Sep 17 00:00:00 2001 From: Aaron Francis Date: Wed, 26 Nov 2025 21:31:16 -0600 Subject: [PATCH 2/5] Ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d72f6ba..7bef995 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules docs/*.blade.php docs/**/*.blade.php .phpunit.cache/test-results +.claude/settings.local.json From 211ea57e25fea3b2e816a798d47a706cd718225f Mon Sep 17 00:00:00 2001 From: Aaron Francis Date: Wed, 26 Nov 2025 21:36:18 -0600 Subject: [PATCH 3/5] Fix testbench minimum version for prefer-lowest compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump orchestra/testbench minimum versions to avoid buggy early releases that cause "undeclared static property" errors in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index df1c0de..1048cd5 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "require-dev": { "mockery/mockery": "^1.6", "phpunit/phpunit": "^10.5|^11.0", - "orchestra/testbench": "^8.0|^9.0|^10.0" + "orchestra/testbench": "^8.21|^9.1|^10.0" }, "autoload": { "psr-4": { From a4aaf55cdae35a7ce038cd46309322d173bc8ae2 Mon Sep 17 00:00:00 2001 From: Aaron Francis Date: Wed, 26 Nov 2025 21:36:18 -0600 Subject: [PATCH 4/5] Fix testbench minimum version for prefer-lowest compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump orchestra/testbench minimum versions to avoid buggy early releases that cause "undeclared static property" errors in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index df1c0de..91a162a 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "require-dev": { "mockery/mockery": "^1.6", "phpunit/phpunit": "^10.5|^11.0", - "orchestra/testbench": "^8.0|^9.0|^10.0" + "orchestra/testbench": "^8.21|^9.5|^10.0" }, "autoload": { "psr-4": { From 46f14635d81450174ec1f54029bb891ff1ae46ea Mon Sep 17 00:00:00 2001 From: Aaron Francis Date: Wed, 26 Nov 2025 21:47:04 -0600 Subject: [PATCH 5/5] Remove nullable type from exceptionIsFlaky method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method is only called after null is already checked by the caller, so the nullable type hint was misleading and could cause null dereference if called directly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/Flaky.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Flaky.php b/src/Flaky.php index ca00e6f..873d6c9 100644 --- a/src/Flaky.php +++ b/src/Flaky.php @@ -235,7 +235,7 @@ protected function shouldThrowImmediately(?Throwable $exception = null): bool return $this->protectionsBypassed() || !$this->exceptionIsFlaky($exception); } - protected function exceptionIsFlaky(?Throwable $exception = null): bool + protected function exceptionIsFlaky(Throwable $exception): bool { return $this->flakyExceptions === null || in_array($exception::class, $this->flakyExceptions, true); }