Skip to content
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

Add retryable connection #76

Open
wants to merge 3 commits 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
20 changes: 0 additions & 20 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,6 @@ parameters:
count: 1
path: src/Connection.php

-
message: "#^Method ipl\\\\Sql\\\\Connection\\:\\:__construct\\(\\) has parameter \\$config with no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Connection.php

-
message: "#^Method ipl\\\\Sql\\\\Connection\\:\\:delete\\(\\) has parameter \\$table with no value type specified in iterable type array\\.$#"
count: 1
Expand Down Expand Up @@ -215,11 +210,6 @@ parameters:
count: 1
path: src/Connection.php

-
message: "#^Method ipl\\\\Sql\\\\Connection\\:\\:prepexec\\(\\) has parameter \\$values with no value type specified in iterable type array\\.$#"
count: 1
path: src/Connection.php

-
message: "#^Method ipl\\\\Sql\\\\Connection\\:\\:update\\(\\) has parameter \\$data with no value type specified in iterable type iterable\\.$#"
count: 1
Expand Down Expand Up @@ -270,16 +260,6 @@ parameters:
count: 1
path: src/Connection.php

-
message: "#^Parameter \\#2 \\$values of method ipl\\\\Sql\\\\Connection\\:\\:prepexec\\(\\) expects array\\|string\\|null, mixed given\\.$#"
count: 1
path: src/Connection.php

-
message: "#^Parameter \\#2 \\$values of method ipl\\\\Sql\\\\Connection\\:\\:yieldPairs\\(\\) expects array\\|null, mixed given\\.$#"
count: 1
path: src/Connection.php

-
message: "#^Property ipl\\\\Sql\\\\Connection\\:\\:\\$adapter \\(ipl\\\\Sql\\\\Contract\\\\Adapter\\) does not accept object\\.$#"
count: 1
Expand Down
5 changes: 3 additions & 2 deletions src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class Connection implements Quoter
*
* {@link init()} is called after construction.
*
* @param Config|iterable $config
* @param Config|iterable<string, mixed> $config
*
* @throws InvalidArgumentException If there's no adapter for the given database available
*/
Expand Down Expand Up @@ -302,6 +302,7 @@ public function yieldAll($stmt, ...$args)

if (! empty($args)) {
if (is_array($args[0])) {
/** @var array<int, mixed> $values */
$values = array_shift($args);
}
}
Expand Down Expand Up @@ -382,7 +383,7 @@ public function yieldPairs($stmt, array $values = null)
* Prepare and execute the given statement
*
* @param Delete|Insert|Select|Update|string $stmt The SQL statement to prepare and execute
* @param string|array $values Values to bind to the statement, if any
* @param null|string|array<int, mixed> $values Values to bind to the statement, if any
*
* @return PDOStatement
*/
Expand Down
102 changes: 102 additions & 0 deletions src/RetryConnection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

namespace ipl\Sql;

use Exception;
use ipl\Stdlib\ExponentialBackoff;
use PDOStatement;

class RetryConnection extends Connection
{
/** @var ExponentialBackoff */
protected $backoff;

/** @var string[] A list of PDO retryable errors */
protected static $retryableErrors = [
'server has gone away',
'no connection to the server',
'Lost connection',
'Connection was killed',
'Connection refused',
'Error while sending',
'is dead or not enabled',
'decryption failed or bad record mac',
'server closed the connection unexpectedly',
'SSL connection has been closed unexpectedly',
'Error writing data to the connection',
'Resource deadlock avoided',
'Transaction() on null',
'child connection forced to terminate due to client_idle_limit',
'query_wait_timeout',
'reset by peer',
'Physical connection is not usable',
'TCP Provider: Error code 0x68',
'ORA-03114',
'Packets out of order. Expected',
'Adaptive Server connection failed',
'Communication link failure',
'No such file or directory',
];

public function __construct($config, int $numberRetries = 1)
{
parent::__construct($config);

$this->backoff = new ExponentialBackoff($numberRetries);
}

/**
* Get whether the given (PDO) exception can be fixed by reconnecting to the database.
*
* @param Exception $err
*
* @return bool
*/
public static function isRetryable(Exception $err): bool
{
$message = $err->getMessage();
foreach (static::$retryableErrors as $error) {
if (strpos($message, $error) !== false) {
return true;
}
}

return false;
}

public function prepexec($stmt, $values = null)
{
/** @var PDOStatement $result */
$result = $this->backoff->retry(function (Exception $err = null) use ($stmt, $values) {
if ($err && ! static::isRetryable($err)) {
throw $err;
}

if ($err) {
$this->disconnect();
}

return parent::prepexec($stmt, $values);
});

return $result;
}

public function beginTransaction(): bool
{
/** @var bool $result */
$result = $this->backoff->retry(function (Exception $err = null): bool {
if ($err && ! static::isRetryable($err)) {
throw $err;
}

if ($err) {
$this->disconnect();
}

return parent::beginTransaction();
});

return $result;
}
}
39 changes: 39 additions & 0 deletions tests/RetryConnectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace ipl\Tests\Sql;

use Exception;
use ipl\Sql\RetryConnection;

class RetryConnectionTest extends \PHPUnit\Framework\TestCase
{
public function testIsRetryable()
{
$db = $this->getConnection();

$this->assertTrue($db::isRetryable(new Exception('SQLState: Connection refused by the server')));
$this->assertTrue($db::isRetryable(new Exception('SQLState: Error writing data to the connection')));
$this->assertTrue($db::isRetryable(new Exception('SQLState: No such file or directory found')));

$this->assertFalse($db::isRetryable(new Exception('SQLState: Cannot start transaction')));
$this->assertFalse($db::isRetryable(new Exception('Cannot establish the connection to SQL server')));
$this->assertFalse($db::isRetryable(new Exception('Fatal error encountered during command execution')));
}

public function testExecutionRetriesGivesUpAfterMaxRetries()
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('SQLSTATE[HY000] [2002] No such file or directory');

$this->getConnection(2)->transaction(function () {
});
}

protected function getConnection(int $retries = 1): RetryConnection
{
return new RetryConnection([
'db' => 'mysql',
'dbname' => 'foo',
], $retries);
}
}