Browse Source

Merge branch 'master' into 4.next

ADmad 5 years ago
parent
commit
ae26fdd321

+ 29 - 18
src/Core/Retry/CommandRetry.php

@@ -34,22 +34,25 @@ class CommandRetry
     protected $strategy;
 
     /**
-     * The number of retries to perform in case of failure.
-     *
      * @var int
      */
-    protected $retries;
+    protected $maxRetries;
+
+    /**
+     * @var int
+     */
+    protected $numRetries;
 
     /**
      * Creates the CommandRetry object with the given strategy and retry count
      *
      * @param \Cake\Core\Retry\RetryStrategyInterface $strategy The strategy to follow should the action fail
-     * @param int $retries The number of times the action has been already called
+     * @param int $maxRetries The maximum number of retry attempts allowed
      */
-    public function __construct(RetryStrategyInterface $strategy, int $retries = 1)
+    public function __construct(RetryStrategyInterface $strategy, int $maxRetries = 1)
     {
         $this->strategy = $strategy;
-        $this->retries = $retries;
+        $this->maxRetries = $maxRetries;
     }
 
     /**
@@ -61,23 +64,31 @@ class CommandRetry
      */
     public function run(callable $action)
     {
-        $retryCount = 0;
-        $lastException = null;
-
-        do {
+        $this->numRetries = 0;
+        while (true) {
             try {
                 return $action();
             } catch (Exception $e) {
-                $lastException = $e;
-                if (!$this->strategy->shouldRetry($e, $retryCount)) {
-                    throw $e;
+                if (
+                    $this->numRetries < $this->maxRetries &&
+                    $this->strategy->shouldRetry($e, $this->numRetries)
+                ) {
+                    $this->numRetries++;
+                    continue;
                 }
-            }
-        } while ($this->retries > $retryCount++);
 
-        /** @psalm-suppress RedundantCondition */
-        if ($lastException !== null) {
-            throw $lastException;
+                throw $e;
+            }
         }
     }
+
+    /**
+     * Returns the last number of retry attemps.
+     *
+     * @return int
+     */
+    public function getRetries(): int
+    {
+        return $this->numRetries;
+    }
 }

+ 1 - 1
src/Core/Retry/RetryStrategyInterface.php

@@ -28,7 +28,7 @@ interface RetryStrategyInterface
      * Returns true if the action can be retried, false otherwise.
      *
      * @param \Exception $exception The exception that caused the action to fail
-     * @param int $retryCount The number of times the action has been already called
+     * @param int $retryCount The number of times action has been retried
      * @return bool Whether or not it is OK to retry the action
      */
     public function shouldRetry(Exception $exception, int $retryCount): bool;

+ 34 - 4
src/Database/Driver.php

@@ -17,7 +17,9 @@ declare(strict_types=1);
 namespace Cake\Database;
 
 use Cake\Core\App;
+use Cake\Core\Retry\CommandRetry;
 use Cake\Database\Exception\MissingConnectionException;
+use Cake\Database\Retry\ErrorCodeWaitStrategy;
 use Cake\Database\Schema\SchemaDialect;
 use Cake\Database\Schema\TableSchema;
 use Cake\Database\Statement\PDOStatement;
@@ -38,6 +40,11 @@ abstract class Driver implements DriverInterface
     protected const MAX_ALIAS_LENGTH = null;
 
     /**
+     * @var int[] DB-specific error codes that allow connect retry
+     */
+    protected const RETRY_ERROR_CODES = [];
+
+    /**
      * Instance of PDO.
      *
      * @var \PDO
@@ -82,6 +89,13 @@ abstract class Driver implements DriverInterface
     protected $_version;
 
     /**
+     * The last number of connection retry attempts.
+     *
+     * @var int
+     */
+    protected $connectRetries = 0;
+
+    /**
      * Constructor
      *
      * @param array $config The configuration for the driver.
@@ -110,13 +124,18 @@ abstract class Driver implements DriverInterface
      */
     protected function _connect(string $dsn, array $config): bool
     {
-        try {
-            $connection = new PDO(
+        $action = function () use ($dsn, $config) {
+            $this->setConnection(new PDO(
                 $dsn,
                 $config['username'] ?: null,
                 $config['password'] ?: null,
                 $config['flags']
-            );
+            ));
+        };
+
+        $retry = new CommandRetry(new ErrorCodeWaitStrategy(static::RETRY_ERROR_CODES, 5), 4);
+        try {
+            $retry->run($action);
         } catch (PDOException $e) {
             throw new MissingConnectionException(
                 [
@@ -126,8 +145,9 @@ abstract class Driver implements DriverInterface
                 null,
                 $e
             );
+        } finally {
+            $this->connectRetries = $retry->getRetries();
         }
-        $this->setConnection($connection);
 
         return true;
     }
@@ -487,6 +507,16 @@ abstract class Driver implements DriverInterface
     }
 
     /**
+     * Returns the number of connection retry attempts made.
+     *
+     * @return int
+     */
+    public function getConnectRetries(): int
+    {
+        return $this->connectRetries;
+    }
+
+    /**
      * Destructor
      */
     public function __destruct()

+ 7 - 0
src/Database/Driver/Sqlserver.php

@@ -47,6 +47,13 @@ class Sqlserver extends Driver
     protected const MAX_ALIAS_LENGTH = 128;
 
     /**
+     * @inheritDoc
+     */
+    protected const RETRY_ERROR_CODES = [
+        40613, // Azure Sql Database paused
+    ];
+
+    /**
      * Base configuration settings for Sqlserver driver
      *
      * @var array

+ 1 - 0
src/Database/DriverInterface.php

@@ -24,6 +24,7 @@ use Closure;
  * Interface for database driver.
  *
  * @method int|null getMaxAliasLength() Returns the maximum alias length allowed.
+ * @method int getConnectRetries() Returns the number of connection retry attempts made.
  */
 interface DriverInterface
 {

+ 69 - 0
src/Database/Retry/ErrorCodeWaitStrategy.php

@@ -0,0 +1,69 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.2.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Database\Retry;
+
+use Cake\Core\Retry\RetryStrategyInterface;
+use Exception;
+use PDOException;
+
+/**
+ * Implements retry strategy based on db error codes and wait interval.
+ *
+ * @internal
+ */
+class ErrorCodeWaitStrategy implements RetryStrategyInterface
+{
+    /**
+     * @var int[]
+     */
+    protected $errorCodes;
+
+    /**
+     * @var int
+     */
+    protected $retryInterval;
+
+    /**
+     * @param int[] $errorCodes DB-specific error codes that allow retrying
+     * @param int $retryInterval Seconds to wait before allowing next retry, 0 for no wait.
+     */
+    public function __construct(array $errorCodes, int $retryInterval)
+    {
+        $this->errorCodes = $errorCodes;
+        $this->retryInterval = $retryInterval;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function shouldRetry(Exception $exception, int $retryCount): bool
+    {
+        if (
+            $exception instanceof PDOException &&
+            $exception->errorInfo &&
+            in_array($exception->errorInfo[1], $this->errorCodes)
+        ) {
+            if ($this->retryInterval > 0) {
+                sleep($this->retryInterval);
+            }
+
+            return true;
+        }
+
+        return false;
+    }
+}

+ 2 - 4
src/Database/Retry/ReconnectStrategy.php

@@ -72,12 +72,10 @@ class ReconnectStrategy implements RetryStrategyInterface
     }
 
     /**
+     * {@inheritDoc}
+     *
      * Checks whether or not the exception was caused by a lost connection,
      * and returns true if it was able to successfully reconnect.
-     *
-     * @param \Exception $exception The exception to check for its message
-     * @param int $retryCount The number of times the action has been already called
-     * @return bool Whether or not it is OK to retry the action
      */
     public function shouldRetry(Exception $exception, int $retryCount): bool
     {

+ 4 - 4
src/Database/Type/DateTimeType.php

@@ -449,9 +449,9 @@ class DateTimeType extends BaseType implements BatchCastingInterface
      * @param string $value The value to parse and convert to an object.
      * @return \Cake\I18n\I18nDateTimeInterface|null
      */
-    protected function _parseLocaleValue(string $value)
+    protected function _parseLocaleValue(string $value): ?I18nDateTimeInterface
     {
-        /** @var \Cake\I18n\I18nDateTimeInterface $class */
+        /** @psalm-var class-string<\Cake\I18n\I18nDateTimeInterface> $class */
         $class = $this->_className;
 
         return $class::parseDateTime($value, $this->_localeMarshalFormat);
@@ -464,9 +464,9 @@ class DateTimeType extends BaseType implements BatchCastingInterface
      * @param string $value The value to parse and convert to an object.
      * @return \DateTimeInterface|null
      */
-    protected function _parseValue(string $value)
+    protected function _parseValue(string $value): ?DateTimeInterface
     {
-        /** @var \DateTime|\DateTimeImmutable $class */
+        /** @psalm-var class-string<\DateTime>|class-string<\DateTimeImmutable> $class */
         $class = $this->_className;
 
         foreach ($this->_marshalFormats as $format) {

+ 6 - 3
src/Database/Type/DateType.php

@@ -18,6 +18,7 @@ namespace Cake\Database\Type;
 
 use Cake\I18n\Date;
 use Cake\I18n\FrozenDate;
+use Cake\I18n\I18nDateTimeInterface;
 use DateTime;
 use DateTimeImmutable;
 use DateTimeInterface;
@@ -80,7 +81,9 @@ class DateType extends DateTimeType
     public function marshal($value): ?DateTimeInterface
     {
         $date = parent::marshal($value);
-        if ($date instanceof DateTime) {
+        if ($date && !$date instanceof I18nDateTimeInterface) {
+            // Clear time manually when I18n types aren't available and raw DateTime used
+            /** @psalm-var \DateTime|\DateTimeImmutable $date */
             $date->setTime(0, 0, 0);
         }
 
@@ -90,9 +93,9 @@ class DateType extends DateTimeType
     /**
      * @inheritDoc
      */
-    protected function _parseLocaleValue(string $value)
+    protected function _parseLocaleValue(string $value): ?I18nDateTimeInterface
     {
-        /** @var \Cake\I18n\I18nDateTimeInterface $class */
+        /** @psalm-var class-string<\Cake\I18n\I18nDateTimeInterface> $class */
         $class = $this->_className;
 
         return $class::parseDate($value, $this->_localeMarshalFormat);

+ 4 - 2
src/Database/Type/TimeType.php

@@ -16,6 +16,8 @@ declare(strict_types=1);
  */
 namespace Cake\Database\Type;
 
+use Cake\I18n\I18nDateTimeInterface;
+
 /**
  * Time type converter.
  *
@@ -38,9 +40,9 @@ class TimeType extends DateTimeType
     /**
      * @inheritDoc
      */
-    protected function _parseLocaleValue(string $value)
+    protected function _parseLocaleValue(string $value): ?I18nDateTimeInterface
     {
-        /** @var \Cake\I18n\I18nDateTimeInterface $class */
+        /** @psalm-var class-string<\Cake\I18n\I18nDateTimeInterface> $class */
         $class = $this->_className;
 
         /** @psalm-suppress PossiblyInvalidArgument */

+ 25 - 2
src/View/View.php

@@ -34,6 +34,7 @@ use Cake\View\Exception\MissingTemplateException;
 use InvalidArgumentException;
 use LogicException;
 use RuntimeException;
+use Throwable;
 
 /**
  * View, the V in the MVC triad. View interacts with Helpers and view variables passed
@@ -687,8 +688,20 @@ class View implements EventDispatcherInterface
         if ($result) {
             return $result;
         }
+
+        $bufferLevel = ob_get_level();
         ob_start();
-        $block();
+
+        try {
+            $block();
+        } catch (Throwable $exception) {
+            while (ob_get_level() > $bufferLevel) {
+                ob_end_clean();
+            }
+
+            throw $exception;
+        }
+
         $result = ob_get_clean();
 
         Cache::write($options['key'], $result, $options['config']);
@@ -1155,9 +1168,19 @@ class View implements EventDispatcherInterface
     protected function _evaluate(string $templateFile, array $dataForView): string
     {
         extract($dataForView);
+
+        $bufferLevel = ob_get_level();
         ob_start();
 
-        include func_get_arg(0);
+        try {
+            include func_get_arg(0);
+        } catch (Throwable $exception) {
+            while (ob_get_level() > $bufferLevel) {
+                ob_end_clean();
+            }
+
+            throw $exception;
+        }
 
         return ob_get_clean();
     }

+ 21 - 42
tests/TestCase/Core/Retry/CommandRetryTest.php

@@ -16,7 +16,6 @@ declare(strict_types=1);
 namespace Cake\Test\TestCase\Core\Retry;
 
 use Cake\Core\Retry\CommandRetry;
-use Cake\Core\Retry\RetryStrategyInterface;
 use Cake\TestSuite\TestCase;
 use Exception;
 
@@ -33,30 +32,18 @@ class CommandRetryTest extends TestCase
     public function testRetry()
     {
         $count = 0;
-        $exception = new Exception('this is failing');
-        $action = function () use (&$count, $exception) {
-            $count++;
-
-            if ($count < 4) {
-                throw $exception;
+        $action = function () use (&$count) {
+            if ($count < 2) {
+                ++$count;
+                throw new Exception('this is failing');
             }
 
             return $count;
         };
 
-        $strategy = $this->getMockBuilder(RetryStrategyInterface::class)->getMock();
-        $strategy
-            ->expects($this->exactly(3))
-            ->method('shouldRetry')
-            ->will($this->returnCallback(function ($e, $c) use ($exception, &$count) {
-                $this->assertSame($e, $exception);
-                $this->assertEquals($c + 1, $count);
-
-                return true;
-            }));
-
-        $retry = new CommandRetry($strategy, 5);
-        $this->assertEquals(4, $retry->run($action));
+        $strategy = new \TestApp\Database\Retry\TestRetryStrategy(true);
+        $retry = new CommandRetry($strategy, 2);
+        $this->assertEquals(2, $retry->run($action));
     }
 
     /**
@@ -66,20 +53,18 @@ class CommandRetryTest extends TestCase
      */
     public function testExceedAttempts()
     {
-        $exception = new Exception('this is failing');
-        $action = function () use ($exception) {
-            throw $exception;
-        };
+        $count = 0;
+        $action = function () use (&$count) {
+            if ($count < 2) {
+                ++$count;
+                throw new Exception('this is failing');
+            }
 
-        $strategy = $this->getMockBuilder(RetryStrategyInterface::class)->getMock();
-        $strategy
-            ->expects($this->exactly(4))
-            ->method('shouldRetry')
-            ->will($this->returnCallback(function ($e) {
-                return true;
-            }));
+            return $count;
+        };
 
-        $retry = new CommandRetry($strategy, 3);
+        $strategy = new \TestApp\Database\Retry\TestRetryStrategy(true);
+        $retry = new CommandRetry($strategy, 1);
         $this->expectException(Exception::class);
         $this->expectExceptionMessage('this is failing');
         $retry->run($action);
@@ -92,19 +77,13 @@ class CommandRetryTest extends TestCase
      */
     public function testRespectStrategy()
     {
-        $action = function () {
+        $count = 0;
+        $action = function () use (&$count) {
             throw new Exception('this is failing');
         };
 
-        $strategy = $this->getMockBuilder(RetryStrategyInterface::class)->getMock();
-        $strategy
-            ->expects($this->once())
-            ->method('shouldRetry')
-            ->will($this->returnCallback(function () {
-                return false;
-            }));
-
-        $retry = new CommandRetry($strategy, 3);
+        $strategy = new \TestApp\Database\Retry\TestRetryStrategy(false);
+        $retry = new CommandRetry($strategy, 2);
         $this->expectException(Exception::class);
         $this->expectExceptionMessage('this is failing');
         $retry->run($action);

+ 15 - 0
tests/TestCase/Database/ConnectionTest.php

@@ -228,6 +228,21 @@ class ConnectionTest extends TestCase
         $this->assertInstanceOf('PDOException', $e->getPrevious());
     }
 
+    public function testConnectRetry()
+    {
+        $this->skipIf(!ConnectionManager::get('test')->getDriver() instanceof \Cake\Database\Driver\Sqlserver);
+
+        $connection = new Connection(['driver' => 'RetryDriver']);
+        $this->assertInstanceOf('TestApp\Database\Driver\RetryDriver', $connection->getDriver());
+
+        try {
+            $connection->connect();
+        } catch (MissingConnectionException $e) {
+        }
+
+        $this->assertSame(4, $connection->getDriver()->getConnectRetries());
+    }
+
     /**
      * Tests creation of prepared statements
      *

+ 25 - 8
tests/TestCase/Database/Type/DateTypeTest.php

@@ -50,6 +50,18 @@ class DateTypeTest extends TestCase
     }
 
     /**
+     * Teardown
+     *
+     * @return void
+     */
+    public function tearDown(): void
+    {
+        parent::tearDown();
+        $this->type->useImmutable();
+        $this->type->useLocaleParser(false)->setLocaleFormat(null);
+    }
+
+    /**
      * Test toPHP
      *
      * @return void
@@ -193,11 +205,11 @@ class DateTypeTest extends TestCase
     public function testMarshal($value, $expected)
     {
         $result = $this->type->marshal($value);
-        if (is_object($expected)) {
-            $this->assertEquals($expected, $result);
-        } else {
-            $this->assertSame($expected, $result);
-        }
+        $this->assertEquals($expected, $result);
+
+        $this->type->useMutable();
+        $result = $this->type->marshal($value);
+        $this->assertEquals($expected, $result);
     }
 
     /**
@@ -208,13 +220,15 @@ class DateTypeTest extends TestCase
     public function testMarshalWithLocaleParsing()
     {
         $this->type->useLocaleParser();
+        $this->assertNull($this->type->marshal('11/derp/2013'));
 
         $expected = new Date('13-10-2013');
         $result = $this->type->marshal('10/13/2013');
         $this->assertEquals($expected->format('Y-m-d'), $result->format('Y-m-d'));
-        $this->assertNull($this->type->marshal('11/derp/2013'));
 
-        $this->type->useLocaleParser(false);
+        $this->type->useMutable();
+        $result = $this->type->marshal('10/13/2013');
+        $this->assertEquals($expected->format('Y-m-d'), $result->format('Y-m-d'));
     }
 
     /**
@@ -225,12 +239,15 @@ class DateTypeTest extends TestCase
     public function testMarshalWithLocaleParsingWithFormat()
     {
         $this->type->useLocaleParser()->setLocaleFormat('dd MMM, y');
+        $this->assertNull($this->type->marshal('11/derp/2013'));
 
         $expected = new Date('13-10-2013');
         $result = $this->type->marshal('13 Oct, 2013');
         $this->assertEquals($expected->format('Y-m-d'), $result->format('Y-m-d'));
 
-        $this->type->useLocaleParser(false)->setLocaleFormat(null);
+        $this->type->useMutable();
+        $result = $this->type->marshal('13 Oct, 2013');
+        $this->assertEquals($expected->format('Y-m-d'), $result->format('Y-m-d'));
     }
 
     /**

+ 61 - 4
tests/TestCase/View/ViewTest.php

@@ -1635,7 +1635,6 @@ TEXT;
             $this->View->render('extend_self', false);
             $this->fail('No exception');
         } catch (\LogicException $e) {
-            ob_end_clean();
             $this->assertStringContainsString('cannot have templates extend themselves', $e->getMessage());
         }
     }
@@ -1651,7 +1650,6 @@ TEXT;
             $this->View->render('extend_loop', false);
             $this->fail('No exception');
         } catch (\LogicException $e) {
-            ob_end_clean();
             $this->assertStringContainsString('cannot have templates extend in a loop', $e->getMessage());
         }
     }
@@ -1704,8 +1702,6 @@ TEXT;
             $this->View->render('extend_missing_element', false);
             $this->fail('No exception');
         } catch (\LogicException $e) {
-            ob_end_clean();
-            ob_end_clean();
             $this->assertStringContainsString('element', $e->getMessage());
         }
     }
@@ -1745,6 +1741,67 @@ TEXT;
     }
 
     /**
+     * Tests that the buffers that are opened when evaluating a template
+     * are being closed in case an exception happens.
+     *
+     * @return void
+     */
+    public function testBuffersOpenedDuringTemplateEvaluationAreBeingClosed()
+    {
+        $bufferLevel = ob_get_level();
+
+        $e = null;
+        try {
+            $this->View->element('exception_with_open_buffers');
+        } catch (\Exception $e) {
+        }
+
+        $this->assertNotNull($e);
+        $this->assertEquals('Exception with open buffers', $e->getMessage());
+        $this->assertEquals($bufferLevel, ob_get_level());
+    }
+
+    /**
+     * Tests that the buffers that are opened during block caching are
+     * being closed in case an exception happens.
+     *
+     * @return void
+     */
+    public function testBuffersOpenedDuringBlockCachingAreBeingClosed()
+    {
+        Cache::drop('test_view');
+        Cache::setConfig('test_view', [
+            'engine' => 'File',
+            'duration' => '+1 day',
+            'path' => CACHE . 'views/',
+            'prefix' => '',
+        ]);
+        Cache::clear('test_view');
+
+        $bufferLevel = ob_get_level();
+
+        $e = null;
+        try {
+            $this->View->cache(function () {
+                ob_start();
+
+                throw new \Exception('Exception with open buffers');
+            }, [
+                'key' => __FUNCTION__,
+                'config' => 'test_view',
+            ]);
+        } catch (\Exception $e) {
+        }
+
+        Cache::clear('test_view');
+        Cache::drop('test_view');
+
+        $this->assertNotNull($e);
+        $this->assertEquals('Exception with open buffers', $e->getMessage());
+        $this->assertEquals($bufferLevel, ob_get_level());
+    }
+
+    /**
      * Test memory leaks that existed in _paths at one point.
      *
      * @return void

+ 46 - 0
tests/test_app/TestApp/Database/Driver/RetryDriver.php

@@ -0,0 +1,46 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.2.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace TestApp\Database\Driver;
+
+use Cake\Database\Driver\Sqlserver;
+use Cake\Datasource\ConnectionManager;
+
+class RetryDriver extends Sqlserver
+{
+    /**
+     * @inheritDoc
+     */
+    protected const RETRY_ERROR_CODES = [18456];
+
+    /**
+     * @inheritDoc
+     */
+    public function connect(): bool
+    {
+        $testConfig = ConnectionManager::get('test')->config() + $this->_baseConfig;
+        $dsn = "sqlsrv:Server={$testConfig['host']};Database={$testConfig['database']}";
+
+        $this->_connect($dsn, ['username' => 'invalid', 'password' => '', 'flags' => []]);
+
+        return true;
+    }
+
+    public function enabled(): bool
+    {
+        return true;
+    }
+}

+ 41 - 0
tests/test_app/TestApp/Database/Retry/TestRetryStrategy.php

@@ -0,0 +1,41 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.2.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace TestApp\Database\Retry;
+
+use Cake\Core\Retry\RetryStrategyInterface;
+use Exception;
+
+class TestRetryStrategy implements RetryStrategyInterface
+{
+    /**
+     * @var bool
+     */
+    protected $allowRetry;
+
+    public function __construct(bool $allowRetry)
+    {
+        $this->allowRetry = $allowRetry;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function shouldRetry(Exception $exception, int $retryCount): bool
+    {
+        return $this->allowRetry;
+    }
+}

+ 3 - 0
tests/test_app/templates/element/exception_with_open_buffers.php

@@ -0,0 +1,3 @@
+<?php
+$this->start('non closing block');
+throw new \Exception('Exception with open buffers');