Skip to content

#[Retry] attribute to support retrying flaky test #6182

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/Framework/Attributes/Retry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Framework\Attributes;

use Attribute;

/**
* @immutable
*
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class Retry
{
private int $maxRetries;
private int $delay;
Copy link
Contributor

@mvorisek mvorisek Jun 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect this to be of type float to support delays like 0.5 (500 ms).

And usually this is very bad retry strategy. Exponential delay is much better. Maybe there should be no native delayed retry support as it can slowdown testing massively.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, but instead of passing a float, maybe it's a better idea to replace sleep with usleep. As for the performance issue, I think the solution is simply not to pass that parameter. Still, I believe the delay is a good idea since it's optional.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SI units definitely, ie. seconds.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe using milliseconds instead of seconds is a better and more flexible option, especially when it comes to controlling timeouts and delays more precisely. This approach is also more commonly adopted, for example, both Symfony's HTTP client and Guzzle use milliseconds for time-related settings.

Additionally, we could consider adding a multiplier option (similar to Symfony) to allow increasing the delay between each retry. Personally, I prefer Guzzle's approach of using a callable for this logic. However, I understand that passing closures directly in PHP attributes isn't natively supported,

Happy to hear your thoughts on this!

Copy link
Contributor

@mvorisek mvorisek Jun 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Milliseconds are not base SI units.

Still, I am thinking if a delay is wanted to be supported natively.

I think there can be an elegant option - the "try number" can be accessible from setUp method and the use can wait/sleep the way he needs by any code.


/**
* @var ?non-empty-string
*/
private ?string $retryOn;

/**
* @param ?non-empty-string $retryOn
*/
public function __construct(int $maxRetries, ?int $delay = 0, ?string $retryOn = null)
{
$this->maxRetries = $maxRetries;
$this->delay = $delay;
$this->retryOn = $retryOn;
}

public function maxRetries(): int
{
return $this->maxRetries;
}

public function delay(): int
{
return $this->delay;
}

/**
* @return ?non-empty-string
*/
public function retryOn(): ?string
{
return $this->retryOn;
}
}
32 changes: 31 additions & 1 deletion src/Framework/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
use function restore_exception_handler;
use function set_error_handler;
use function set_exception_handler;
use function sleep;
use function sprintf;
use function str_contains;
use function stream_get_contents;
Expand Down Expand Up @@ -79,6 +80,7 @@
use PHPUnit\Metadata\Api\HookMethods;
use PHPUnit\Metadata\Api\Requirements;
use PHPUnit\Metadata\Parser\Registry as MetadataRegistry;
use PHPUnit\Metadata\Retry;
use PHPUnit\Metadata\WithEnvironmentVariable;
use PHPUnit\Runner\BackedUpEnvironmentVariable;
use PHPUnit\Runner\DeprecationCollector\Facade as DeprecationCollector;
Expand Down Expand Up @@ -1248,7 +1250,7 @@ protected function onNotSuccessfulTest(Throwable $t): never
* @throws ExpectationFailedException
* @throws Throwable
*/
private function runTest(): mixed
private function runTest(int $attempt = 0): mixed
{
$testArguments = array_merge($this->data, array_values($this->dependencyInput));

Expand Down Expand Up @@ -1276,6 +1278,12 @@ private function runTest(): mixed
}

if (!$this->shouldExceptionExpectationsBeVerified($exception)) {
$metadata = $this->getRetryMetadata($exception, $attempt);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only had a very cursory look at the proposed changes yet, but this caught my eye: how are tests retried that use expectException(), for example?

Copy link
Author

@santysisi santysisi Apr 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing that out!

Just to clarify how this works: in PHPUnit, if an exception is thrown after calling expectException(), it's treated as a failure, not an error and failures are not retried. On the other hand, if an exception is thrown before expectException() is set, PHPUnit treats it as an error, which will trigger a retry.

For example:

#[Retry(2)]
public function testFoo(): void
{
    throw new Exception;
    $this->expectException(Exception::class);
}

This will be retried, since the exception occurs before the expected exception is declared.

Whereas:

#[Retry(2)]
public function testFoo(): void
{
    $this->expectException(Exception::class);
    throw new Exception;
}

This will not be retried, because the exception is expected and treated as a failure if something goes wrong, not an unexpected error.

So i believe (maybe I'm wrong) that the the retry behavior aligns with how PHPUnit distinguishes between errors and failures.

Thanks again for your comment
I really appreciate the feedback 😄 .

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, I'm not entirely sure if this approach fully aligns with your expectations or the overall goals of PHPUnit. If there's anything I'm not comfortable with or could be improved, I'd greatly appreciate your guidance. I'd be happy to review the logic and make adjustments to better fit PHPUnit's design and intent.


if (null !== $metadata) {
return $this->retryTest($metadata, $attempt);
}

throw $exception;
}

Expand Down Expand Up @@ -2255,6 +2263,28 @@ private function handleExceptionFromInvokedCountMockObjectRule(Throwable $t): vo
}
}

private function getRetryMetadata(Throwable $th, int $attempt): ?Retry
{
foreach (MetadataRegistry::parser()->forMethod($this::class, $this->name())->isRetry() as $metadata) {
assert($metadata instanceof Retry);

if ($metadata->maxRetries() > $attempt && (null === $metadata->retryOn() || $th instanceof ($metadata->retryOn()))) {
return $metadata;
}
}

return null;
}

private function retryTest(Retry $metadata, int $attempt): mixed
{
if ($metadata->delay() > 0) {
sleep($metadata->delay());
}

return $this->runTest(++$attempt);
}

/**
* Creates a test stub for the specified interface or class.
*
Expand Down
13 changes: 13 additions & 0 deletions src/Metadata/Metadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,11 @@ public static function withoutErrorHandler(): WithoutErrorHandler
return new WithoutErrorHandler(self::METHOD_LEVEL);
}

public static function retry(int $maxRetries, ?int $delay = 0, ?string $retryOn = null): Retry
{
return new Retry(self::METHOD_LEVEL, $maxRetries, $delay, $retryOn);
}

/**
* @param int<0, 1> $level
*/
Expand Down Expand Up @@ -969,4 +974,12 @@ public function isWithoutErrorHandler(): bool
{
return false;
}

/**
* @phpstan-assert-if-true Retry $this
*/
public function isRetry(): bool
{
return false;
}
}
10 changes: 10 additions & 0 deletions src/Metadata/MetadataCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -632,4 +632,14 @@ public function isWithoutErrorHandler(): self
),
);
}

public function isRetry(): self
{
return new self(
...array_filter(
$this->metadata,
static fn (Metadata $metadata): bool => $metadata->isRetry(),
),
);
}
}
8 changes: 8 additions & 0 deletions src/Metadata/Parser/AttributeParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
use PHPUnit\Framework\Attributes\RequiresPhpunit;
use PHPUnit\Framework\Attributes\RequiresPhpunitExtension;
use PHPUnit\Framework\Attributes\RequiresSetting;
use PHPUnit\Framework\Attributes\Retry;
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
Expand Down Expand Up @@ -845,6 +846,13 @@ public function forMethod(string $className, string $methodName): MetadataCollec

$result[] = Metadata::withoutErrorHandler();

break;

case Retry::class:
assert($attributeInstance instanceof Retry);

$result[] = Metadata::retry($attributeInstance->maxRetries(), $attributeInstance->delay(), $attributeInstance->retryOn());

break;
}
}
Expand Down
61 changes: 61 additions & 0 deletions src/Metadata/Retry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Metadata;

/**
* @immutable
*
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*/
final readonly class Retry extends Metadata
{
private int $maxRetries;
private int $delay;

/**
* @var ?non-empty-string
*/
private ?string $retryOn;

/**
* @param ?non-empty-string $retryOn
*/
public function __construct(int $level, int $maxRetries, int $delay, ?string $retryOn)
{
parent::__construct($level);

$this->maxRetries = $maxRetries;
$this->delay = $delay;
$this->retryOn = $retryOn;
}

public function isRetry(): bool
{
return true;
}

public function maxRetries(): int
{
return $this->maxRetries;
}

public function delay(): int
{
return $this->delay;
}

/**
* @return ?non-empty-string
*/
public function retryOn(): ?string
{
return $this->retryOn;
}
}
24 changes: 24 additions & 0 deletions tests/_files/Metadata/Attribute/tests/RetryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\TestFixture\Metadata\Attribute;

use LogicException;
use PHPUnit\Framework\Attributes\Retry;
use PHPUnit\Framework\TestCase;

final class RetryTest extends TestCase
{
#[Retry(1)]
#[Retry(2, 1)]
#[Retry(3, 0, LogicException::class)]
public function testOne(): void
{
}
}
108 changes: 108 additions & 0 deletions tests/unit/Framework/Attributes/RetryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Framework\Attributes;

use DateTime;
use Exception;
use LogicException;
use PHPUnit\Framework\TestCase;

final class RetryTest extends TestCase
Copy link
Contributor

@mvorisek mvorisek Jun 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, it seems pre/post test hooks are not called. But that can be an issue.

I would like to have this tested by E2E tests to show the behaviour and also combined with other features like process isolation.

{
private static int $retryNumber = 0;
private static ?DateTime $start = null;

protected function setUp(): void
{
self::$retryNumber = 0;
self::$start = new DateTime;
}

#[Retry(3)]
public function testRetriesUntilMaxAttemptsThenSucceeds(): void
{
if (self::$retryNumber < 3) {
self::$retryNumber++;

throw new Exception;
}

$this->assertSame(3, self::$retryNumber);
}

#[Retry(1)]
public function testSingleRetryThenThrowsExpectedException(): void
{
if (self::$retryNumber < 1) {
self::$retryNumber++;

throw new Exception;
}

$this->expectException(Exception::class);
$this->expectExceptionMessage('test exception two');
$this->assertSame(1, self::$retryNumber);

throw new Exception('test exception two');
}

#[Retry(2, 0, LogicException::class)]
public function testRetryWithUnmatchedExceptionTypeFailsImmediately(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('test exception');
$this->assertSame(0, self::$retryNumber);
self::$retryNumber++;

throw new Exception('test exception');
}

#[Retry(2, 0, LogicException::class)]
#[Retry(2)]
public function testMultipleRetryAttributesFallBackToDefaultRetry(): void
{
if (self::$retryNumber < 2) {
self::$retryNumber++;

throw new Exception;
}

$this->assertSame(2, self::$retryNumber);
}

#[Retry(5, 0, LogicException::class)]
public function testRetriesUntilLogicExceptionStopsThrowing(): void
{
if (self::$retryNumber < 5) {
self::$retryNumber++;

throw new LogicException;
}

$this->assertSame(5, self::$retryNumber);
}

#[Retry(1, 2)]
public function testRetryDelaysExecutionBySpecifiedSeconds(): void
{
$end = new DateTime;

if (self::$retryNumber < 1) {
self::$retryNumber++;

throw new Exception;
}

$diffInSeconds = $end->getTimestamp() - self::$start->getTimestamp();

$this->assertSame(1, self::$retryNumber);
$this->assertSame(2, $diffInSeconds);
}
}
Loading