Skip to content

Commit 79a216c

Browse files
committed
Simplifies retry configuration for deadlocks and timeouts
Replaces complex, multi-faceted retryable exceptions configuration with boolean flags for deadlock and lock wait timeout retries. This change simplifies configuration and improves readability. The configuration now uses `retry_on_deadlock` and `retry_on_lock_wait_timeout` flags.
1 parent 00f32e8 commit 79a216c

File tree

4 files changed

+110
-112
lines changed

4 files changed

+110
-112
lines changed

README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,23 +110,21 @@ php artisan vendor:publish --tag=database-transaction-retry-config
110110
- `lock_wait_timeout_seconds` lets you override `innodb_lock_wait_timeout` per attempt; set the matching environment variable (`DB_TRANSACTION_RETRY_LOCK_WAIT_TIMEOUT`) to control the session value or leave null to use the database default.
111111
- `logging.channel` points at any existing Laravel log channel so you can reuse stacks or third-party drivers.
112112
- `logging.levels.success` / `logging.levels.failure` let you tune the severity emitted for successful retries and exhausted attempts (defaults: `warning` and `error`).
113-
- `retryable_exceptions.sql_states` lists SQLSTATE codes that should trigger a retry (defaults to `40001`).
114-
- `retryable_exceptions.driver_error_codes` lists driver-specific error codes (defaults to `1213` deadlocks and `1205` lock wait timeouts). Including `1205` not only enables retries but also activates the optional session lock wait timeout override when configured.
115-
- `retryable_exceptions.classes` lets you specify fully-qualified exception class names that should always be retried.
113+
- `retry_on_deadlock` toggles the built-in handling for MySQL deadlocks (`1213`). Set `DB_TRANSACTION_RETRY_ON_DEADLOCK=false` to disable it.
114+
- `retry_on_lock_wait_timeout` toggles retries for MySQL lock wait timeouts (`1205`) **and** activates the optional session timeout override. Set `DB_TRANSACTION_RETRY_ON_LOCK_WAIT_TIMEOUT=true` to enable it.
116115

117116
## Retry Conditions
118117

119118
Retries are attempted when the caught exception matches one of the configured conditions:
120119

121-
- `Illuminate\Database\QueryException` with a SQLSTATE listed in `retryable_exceptions.sql_states`.
122-
- `Illuminate\Database\QueryException` with a driver error code listed in `retryable_exceptions.driver_error_codes` (defaults include `1213` deadlocks and `1205` lock wait timeouts).
123-
- Any exception instance whose class appears in `retryable_exceptions.classes`.
120+
- `Illuminate\Database\QueryException` for MySQL deadlocks (`1213`) when `retry_on_deadlock` is enabled (default).
121+
- `Illuminate\Database\QueryException` for MySQL lock wait timeouts (`1205`) when `retry_on_lock_wait_timeout` is enabled.
124122

125123
Everything else (e.g., constraint violations, syntax errors, application exceptions) is surfaced immediately without logging or sleeping. If no attempt succeeds and all retries are exhausted, the last exception is re-thrown. In the rare case nothing is thrown but the loop exits, a `RuntimeException` is raised to signal exhaustion.
126124

127125
## Lock Wait Timeout
128126

129-
When `lock_wait_timeout_seconds` is configured, the retrier issues `SET SESSION innodb_lock_wait_timeout = {seconds}` on the active connection before each attempt, but only when the retry rules include the lock-wait timeout driver code (`1205`). This keeps the timeout predictable even after reconnects or pool reuse, and on drivers that do not support the statement the helper safely ignores the failure.
127+
When `lock_wait_timeout_seconds` is configured, the retrier issues `SET SESSION innodb_lock_wait_timeout = {seconds}` on the active connection before each attempt, but only when `retry_on_lock_wait_timeout` is enabled. This keeps the timeout predictable even after reconnects or pool reuse, and on drivers that do not support the statement the helper safely ignores the failure.
130128

131129
## Logging Behaviour
132130

config/database-transaction-retry.php

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,24 +57,13 @@
5757
| Retryable Exceptions
5858
|--------------------------------------------------------------------------
5959
|
60-
| Configure the database errors that should trigger a retry. SQLSTATE codes
61-
| and driver error codes are checked for `QueryException` instances. You may
62-
| also list additional exception classes to retry on by name.
60+
| Configure the database errors that should trigger a retry.
6361
|
6462
*/
6563

66-
'retryable_exceptions' => [
67-
'sql_states' => [
68-
'40001', // Serialization failure
69-
],
70-
71-
'driver_error_codes' => [
72-
1213, // MySQL deadlock
73-
// 1205, // MySQL lock wait timeout
74-
],
64+
'retry_on_deadlock' => env('DB_TRANSACTION_RETRY_ON_DEADLOCK', true),
7565

76-
'classes' => [],
77-
],
66+
'retry_on_lock_wait_timeout' => env('DB_TRANSACTION_RETRY_ON_LOCK_WAIT_TIMEOUT', false),
7867

7968
/*
8069
|--------------------------------------------------------------------------

src/Services/TransactionRetrier.php

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public static function runWithRetry(
6767
return DB::transaction($callback);
6868
} catch (Throwable $exception) {
6969
$exceptionCaught = true;
70-
$shouldRetryError = static::shouldRetry($exception);
70+
$shouldRetryError = static::shouldRetry($exception, $config);
7171

7272
if ($shouldRetryError) {
7373
$attempt++;
@@ -100,50 +100,36 @@ public static function runWithRetry(
100100
throw new RuntimeException('Transaction with retry exhausted after ' . $maxRetries . ' attempts.');
101101
}
102102

103-
protected static function shouldRetry(Throwable $throwable): bool
103+
protected static function shouldRetry(Throwable $throwable, array $config): bool
104104
{
105-
$config = function_exists('config') ? config('database-transaction-retry.retryable_exceptions', []) : [];
106-
107-
if (! is_array($config)) {
108-
$config = [];
109-
}
110-
111-
$retryableClasses = array_filter(
112-
array_map('trim', is_array($config['classes'] ?? null) ? $config['classes'] : []),
113-
static fn ($class) => $class !== ''
114-
);
115-
116-
foreach ($retryableClasses as $class) {
117-
if (class_exists($class) && $throwable instanceof $class) {
118-
return true;
119-
}
105+
if (! $throwable instanceof QueryException) {
106+
return false;
120107
}
121108

122-
if ($throwable instanceof QueryException) {
123-
return static::isRetryableQueryException($throwable, $config);
124-
}
125-
126-
return false;
109+
return static::isRetryableQueryException($throwable, $config);
127110
}
128111

129112
protected static function isRetryableQueryException(QueryException $exception, array $config): bool
130113
{
131-
$sqlStates = is_array($config['sql_states'] ?? null) ? $config['sql_states'] : [];
132-
$sqlStates = array_map(static fn ($state) => strtoupper((string) $state), $sqlStates);
133-
134-
$driverCodes = is_array($config['driver_error_codes'] ?? null) ? $config['driver_error_codes'] : [];
135-
$driverCodes = array_map(static fn ($code) => (int) $code, $driverCodes);
136-
137114
$sqlState = strtoupper((string) $exception->getCode());
138115
$driverErr = is_array($exception->errorInfo ?? null) && isset($exception->errorInfo[1])
139116
? (int) $exception->errorInfo[1]
140117
: null;
141118

142-
if (in_array($sqlState, $sqlStates, true)) {
143-
return true;
119+
$retryDeadlock = static::normalizeBoolean($config['retry_on_deadlock'] ?? true, true);
120+
$retryLockWait = static::normalizeBoolean($config['retry_on_lock_wait_timeout'] ?? false, false);
121+
122+
if ($retryDeadlock) {
123+
if ($sqlState === '40001') {
124+
return true;
125+
}
126+
127+
if (! is_null($driverErr) && $driverErr === 1213) {
128+
return true;
129+
}
144130
}
145131

146-
if (! is_null($driverErr) && in_array($driverErr, $driverCodes, true)) {
132+
if ($retryLockWait && ! is_null($driverErr) && $driverErr === 1205) {
147133
return true;
148134
}
149135

@@ -339,15 +325,7 @@ protected static function applyLockWaitTimeout(array $config): void
339325

340326
protected static function isLockWaitRetryEnabled(array $config): bool
341327
{
342-
$retryable = is_array($config['retryable_exceptions'] ?? null)
343-
? $config['retryable_exceptions']
344-
: [];
345-
346-
$driverCodes = is_array($retryable['driver_error_codes'] ?? null)
347-
? array_map(static fn ($code) => (int) $code, $retryable['driver_error_codes'])
348-
: [];
349-
350-
return in_array(1205, $driverCodes, true);
328+
return static::normalizeBoolean($config['retry_on_lock_wait_timeout'] ?? false, false);
351329
}
352330

353331
protected static function exposeTransactionLabel(string $trxLabel): void
@@ -362,4 +340,33 @@ protected static function exposeTransactionLabel(string $trxLabel): void
362340

363341
app()->instance('tx.label', $trxLabel);
364342
}
343+
344+
protected static function normalizeBoolean(mixed $value, bool $fallback): bool
345+
{
346+
if (is_bool($value)) {
347+
return $value;
348+
}
349+
350+
if (is_string($value)) {
351+
$value = strtolower(trim($value));
352+
353+
if ($value === '') {
354+
return $fallback;
355+
}
356+
357+
if (in_array($value, ['false', '0', 'off', 'no'], true)) {
358+
return false;
359+
}
360+
361+
if (in_array($value, ['true', '1', 'on', 'yes'], true)) {
362+
return true;
363+
}
364+
}
365+
366+
if (is_numeric($value)) {
367+
return (int) $value !== 0;
368+
}
369+
370+
return $fallback;
371+
}
365372
}

tests/Unit/DBTransactionRetryHelperTest.php

Lines changed: 55 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -242,15 +242,41 @@ function sleep(int $seconds): void
242242
expect(SleepSpy::$delays)->toBe([]);
243243
});
244244

245+
test('does not retry when deadlock retry disabled', function (): void {
246+
Container::getInstance()->make('config')->set(
247+
'database-transaction-retry.retry_on_deadlock',
248+
false
249+
);
250+
251+
$attempts = 0;
252+
253+
try {
254+
TransactionRetrier::runWithRetry(function () use (&$attempts): void {
255+
$attempts++;
256+
257+
throw makeQueryException(1213);
258+
}, maxRetries: 3, retryDelay: 1, trxLabel: 'deadlock-disabled');
259+
260+
$this->fail('Expected QueryException was not thrown.');
261+
} catch (QueryException $th) {
262+
expect($th->errorInfo[1])->toBe(1213);
263+
}
264+
265+
expect($attempts)->toBe(1);
266+
expect($this->database->transactionCalls)->toBe(1);
267+
expect(SleepSpy::$delays)->toBe([]);
268+
expect($this->logManager->records)->toBe([]);
269+
});
270+
245271
test('retries on lock wait timeout and applies configured session timeout', function (): void {
246272
Container::getInstance()->make('config')->set(
247273
'database-transaction-retry.lock_wait_timeout_seconds',
248274
7
249275
);
250276

251277
Container::getInstance()->make('config')->set(
252-
'database-transaction-retry.retryable_exceptions.driver_error_codes',
253-
[1205]
278+
'database-transaction-retry.retry_on_lock_wait_timeout',
279+
true
254280
);
255281

256282
$attempts = 0;
@@ -288,13 +314,8 @@ function sleep(int $seconds): void
288314
);
289315

290316
Container::getInstance()->make('config')->set(
291-
'database-transaction-retry.retryable_exceptions.driver_error_codes',
292-
[1213]
293-
);
294-
295-
Container::getInstance()->make('config')->set(
296-
'database-transaction-retry.retryable_exceptions.sql_states',
297-
[]
317+
'database-transaction-retry.retry_on_lock_wait_timeout',
318+
false
298319
);
299320

300321
try {
@@ -310,61 +331,26 @@ function sleep(int $seconds): void
310331
expect($this->database->statementCalls)->toBe([]);
311332
});
312333

313-
test('retries when driver code is configured', function (): void {
314-
Container::getInstance()->make('config')->set(
315-
'database-transaction-retry.retryable_exceptions.driver_error_codes',
316-
[1213, 999]
317-
);
318-
334+
test('retries when SQLSTATE indicates deadlock even without driver code', function (): void {
319335
$attempts = 0;
320336

321337
$result = TransactionRetrier::runWithRetry(function () use (&$attempts) {
322338
$attempts++;
323339

324340
if ($attempts === 1) {
325-
throw makeQueryException(999, 0);
326-
}
327-
328-
return 'recovered';
329-
}, maxRetries: 3, retryDelay: 1, trxLabel: 'invoices');
330-
331-
expect($result)->toBe('recovered');
332-
expect($this->database->transactionCalls)->toBe(2);
333-
expect($this->logManager->records)->toHaveCount(1);
334-
$record = $this->logManager->records[0];
335-
336-
expect($record['message'])->toBe('[invoices] [DATABASE TRANSACTION RETRY - SUCCESS] Illuminate\Database\QueryException (SQLSTATE 00000, Driver 999) After (Attempts: 1/3) - Warning');
337-
expect($record['context']['driverCode'])->toBe(999);
338-
expect($record['context']['sqlState'])->toBe('00000');
339-
});
340-
341-
test('retries when exception class is configured', function (): void {
342-
Container::getInstance()->make('config')->set(
343-
'database-transaction-retry.retryable_exceptions.classes',
344-
[CustomRetryException::class]
345-
);
346-
347-
$attempts = 0;
348-
349-
$result = TransactionRetrier::runWithRetry(function () use (&$attempts) {
350-
$attempts++;
351-
352-
if ($attempts === 1) {
353-
throw new CustomRetryException('try again');
341+
throw makeSqlStateOnlyQueryException('40001');
354342
}
355343

356344
return 'ok';
357-
}, maxRetries: 3, retryDelay: 1, trxLabel: 'custom');
345+
}, maxRetries: 3, retryDelay: 1, trxLabel: 'sqlstate-deadlock');
358346

359347
expect($result)->toBe('ok');
360348
expect($this->database->transactionCalls)->toBe(2);
361-
349+
expect($this->logManager->records)->toHaveCount(1);
362350
$record = $this->logManager->records[0];
363351

364-
expect($record['message'])->toBe('[custom] [DATABASE TRANSACTION RETRY - SUCCESS] Tests\\CustomRetryException After (Attempts: 1/3) - Warning');
365-
expect($record['context']['exceptionClass'])->toBe(CustomRetryException::class);
366-
expect(array_key_exists('driverCode', $record['context']))->toBeFalse();
367-
expect(array_key_exists('sqlState', $record['context']))->toBeFalse();
352+
expect($record['context']['sqlState'])->toBe('40001');
353+
expect($record['context']['driverCode'])->toBeNull();
368354
});
369355

370356
test('binds transaction label into container during execution', function (): void {
@@ -506,8 +492,26 @@ function makeQueryException(int $driverCode, string|int $sqlState = 40001): Quer
506492
);
507493
}
508494

509-
final class CustomRetryException extends \RuntimeException
495+
function makeSqlStateOnlyQueryException(string|int $sqlState = 40001): QueryException
510496
{
497+
$sqlStateString = strtoupper((string) $sqlState);
498+
499+
if (strlen($sqlStateString) < 5) {
500+
$sqlStateString = str_pad($sqlStateString, 5, '0', STR_PAD_LEFT);
501+
}
502+
503+
$pdo = new \PDOException(
504+
'SQLSTATE[' . $sqlStateString . ']: Driver error',
505+
is_numeric($sqlState) ? (int) $sqlState : 0
506+
);
507+
$pdo->errorInfo = [$sqlStateString, null, 'Driver error'];
508+
509+
return new QueryException(
510+
'mysql',
511+
'insert into foo (bar) values (?)',
512+
['baz'],
513+
$pdo
514+
);
511515
}
512516

513517
final class FakeDatabaseManager

0 commit comments

Comments
 (0)