expectException(\RuntimeException::class); $this->expectExceptionMessage('attempt 3'); Retry::run(function () use (&$calls): never { $calls++; throw new \RuntimeException('attempt ' . $calls); }, maxAttempts: 3); } public function testMaxAttemptsIsRespected(): void { $calls = 0; try { Retry::run(function () use (&$calls): never { $calls++; throw new \RuntimeException(); }, maxAttempts: 4); } catch (\RuntimeException) { } self::assertSame(4, $calls); } public function testOnlyRetiesOnMatchingExceptionType(): void { $calls = 0; $result = Retry::run(function () use (&$calls): string { $calls++; if ($calls === 1) { throw new \InvalidArgumentException('retry me'); } return 'ok'; }, maxAttempts: 3, only: [\InvalidArgumentException::class]); self::assertSame('ok', $result); self::assertSame(2, $calls); } public function testOnlyThrowsImmediatelyForNonMatchingType(): void { $this->expectException(\RuntimeException::class); Retry::run(function (): never { throw new \RuntimeException('not in only list'); }, maxAttempts: 3, only: [\InvalidArgumentException::class]); } public function testExceptThrowsImmediatelyForMatchingType(): void { $calls = 0; $this->expectException(\InvalidArgumentException::class); Retry::run(function () use (&$calls): never { $calls++; throw new \InvalidArgumentException('stop immediately'); }, maxAttempts: 3, except: [\InvalidArgumentException::class]); } public function testExceptAllowsRetryForNonMatchingType(): void { $calls = 0; $result = Retry::run(function () use (&$calls): string { $calls++; if ($calls === 1) { throw new \RuntimeException('retry ok'); } return 'ok'; }, maxAttempts: 3, except: [\InvalidArgumentException::class]); self::assertSame('ok', $result); self::assertSame(2, $calls); } public function testBackoffWithZeroDoesNotSleep(): void { // Just verify it runs correctly without any sleep delay $calls = 0; $result = Retry::run(function () use (&$calls): string { $calls++; if ($calls < 3) { throw new \RuntimeException(); } return 'done'; }, maxAttempts: 3, backoffMs: 0); self::assertSame('done', $result); } public function testSubclassMatchesOnlyFilter(): void { $calls = 0; $result = Retry::run(function () use (&$calls): string { $calls++; if ($calls === 1) { // LogicException extends \Exception which extends \Throwable throw new \LogicException('subclass'); } return 'ok'; }, maxAttempts: 3, only: [\Exception::class]); self::assertSame('ok', $result); } }