This is a pivot of the original syntax proposal thanks to feedback from twitter.
The retry keyword adds to the try\catch\finally
block to optionally execute an arbitrary statement before jumping to the top of the try
block n
times.
TL;DR The retry
keyword offers a cleaner, more readable & more efficient solution to a common problem.
class RecoverableException extends Exception {}
$id = 42;
try {
throw new RecoverableException("FAILED getting ID #{$id}");
} retry 3 => $attempt (RecoverableException $e) {
echo "Failed getting ID #{$id} on try #{$attempt}. Retrying...\n";
sleep(1);
} catch (RecoverableException $e) {
echo $e->getMessage().PHP_EOL;
}
Output:
Failed getting ID #42 on try #1. Retrying...
Failed getting ID #42 on try #2. Retrying...
Failed getting ID #42 on try #3. Retrying...
FAILED getting ID #42
The full feature set of the new retry
syntax includes the following:
- Retry the
try
blockn
times (or forever withINF
or by omitting the int literal) - Execute arbitrary code before each retry (to sleep, log, check exception code, etc)
- Access to the instance of the thrown exception before the
try
block is retried - Access to the number of times the
try
block has been executed (with$attempt
) - Specify the exceptions to be retried
- Retry multiple recoverable exceptions with multi-catch syntax (added in PHP 7.1)
- Break out of retry attempts for reasons other than the exception not being thrown or running out of retry attempts (using
break
)
In order to achieve all the features described above with a user-land implementation, the following implementation of a retry()
function would suffice.
function retry(int $retryCount, callable $tryThis, callable $beforeRetry = null, array $targetExceptions = ['Exception'])
{
$attempts = 0;
tryCode:
try {
return $tryThis();
} catch (\Throwable $e) {
$isTargetException = false;
foreach ($targetExceptions as $targetException) {
if ($e instanceof $targetException) {
$isTargetException = true;
break;
}
}
if (!$retryCount || !$isTargetException) {
throw $e;
}
$retryCount--;
$shouldRetry = true;
if ($beforeRetry) {
$shouldRetry = $beforeRetry($e, ++$attempts);
}
if ($shouldRetry) {
goto tryCode;
}
throw $e;
}
}
Full usage of all the features of the retry()
function might look like this.
class RecoverableException extends Exception {}
class AnotherRecoverableException extends Exception {}
class NonRecoverableException extends Exception {}
$id = 42;
try {
$result = retry(3, function () use ($id) {
throw new AnotherRecoverableException("FAILED getting ID #{$id}");
}, function ($e, $attempt) use ($id) {
if (42 === $e->getCode()) {
return false;
}
echo "Failed getting ID #{$id} on try #{$attempt}. Retrying...\n";
sleep(1);
return true;
}, [RecoverableException::class, AnotherRecoverableException::class]);
} catch (RecoverableException | AnotherRecoverableException $e) {
echo $e->getMessage().PHP_EOL;
} catch (NonRecoverableException $e) {
echo "I could not retry this exception\n";
echo $e->getMessage().PHP_EOL;
}
- Not easily readable
- Specifying exceptions to catch for retry requires invoking a lot of opcodes
- Requires two levels of try/catches
- ???Adds a new scope/execution context to the stack for each callback
- Due to closure, have to use
use ($foo)
if you want to include vars outside of callback scope (harder to read & easy to forget)
With the new retry
keyword, the above example could be changed to this.
$id = 42;
try {
throw new RecoverableException("FAILED getting ID #{$id}");
} retry 3 => $attempt (RecoverableException | AnotherRecoverableException $e) {
if (42 === $e->getCode()) {
break;
}
echo "Failed getting ID #{$id} on try #{$attempt}. Retrying...\n";
sleep(1);
} catch (RecoverableException | AnotherRecoverableException $e) {
echo $e->getMessage().PHP_EOL;
} catch (NonRecoverableException $e) {
echo "I could not retry this exception\n";
echo $e->getMessage().PHP_EOL;
}
- Way more readable
- More efficient at the op-code level
- n% less boilerplate code
- Offers a clean out-of-the-box solution to a common problem
Like this:
for ($i = 0; $i < $n; $i++) {
try {
/* Stuffs here */
break;
} catch(Exception $e) {
/* Handle $e */
}
}
The problem (other than readability) is if you want to execute arbitrary code before the retry, but not on the final fail. A common use-case is the need to execute sleep(1)
before each retry and log_error(/* */)
(or whatever) on the final fail. In the above example, sleep(1)
would be added to the catch
block and will be called even after all the retries are used up, thus causing an unnecessary extra second added to the execution time. In addition, we might want to halt script execution with exit
on the final fail only.
This of course can be handled pretty easily with an if/else
to detect the last try, but this just adds even more boilerplate & impairs readability.
The user-land implementation of the retry()
function invokes the following 63 opcodes.
function name: retry
number of ops: 63
compiled vars: !0 = $retryCount, !1 = $tryThis, !2 = $beforeRetry, !3 = $targetExceptions, !4 = $attempts, !5 = $e, !6 = $isTargetException, !7 = $targetException, !8 = $shouldRetry
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
7 0 E > EXT_NOP
1 RECV !0
2 RECV !1
3 RECV_INIT !2 null
4 RECV_INIT !3 <array>
9 5 EXT_STMT
6 ASSIGN !4, 0
11 7 > NOP
12 8 EXT_STMT
9 INIT_DYNAMIC_CALL !1
10 EXT_FCALL_BEGIN
11 DO_FCALL 0 $10
12 EXT_FCALL_END
13 > RETURN $10
14* JMP ->61
13 15 E > > CATCH 'Throwable', !5
14 16 > EXT_STMT
17 ASSIGN !6, <false>
15 18 EXT_STMT
19 > FE_RESET_R $12 !3, ->30
20 > > FE_FETCH_R $12, !7, ->30
16 21 > EXT_STMT
22 FETCH_CLASS 640 :13 !7
23 INSTANCEOF ~14 !5, $13
24 > JMPZ ~14, ->29
17 25 > EXT_STMT
26 ASSIGN !6, <true>
18 27 EXT_STMT
28 > JMP ->30
29 > > JMP ->20
30 > FE_FREE $12
21 31 EXT_STMT
32 BOOL_NOT ~16 !0
33 > JMPNZ_EX ~16 ~16, ->36
34 > BOOL_NOT ~17 !6
35 BOOL ~16 ~17
36 > > JMPZ ~16, ->39
22 37 > EXT_STMT
38 > THROW 0 !5
24 39 > EXT_STMT
40 POST_DEC ~18 !0
41 FREE ~18
25 42 EXT_STMT
43 ASSIGN !8, <true>
26 44 EXT_STMT
45 > JMPZ !2, ->55
27 46 > EXT_STMT
47 INIT_DYNAMIC_CALL !2
48 EXT_FCALL_BEGIN
49 SEND_VAR_EX !5
50 PRE_INC $20 !4
51 BRK
52 DO_FCALL 0 $21
53 EXT_FCALL_END
54 ASSIGN !8, $21
29 55 > EXT_STMT
56 > JMPZ !8, ->59
30 57 > EXT_STMT
58 > JMP ->7
32 59 > EXT_STMT
60 > THROW 0 !5
34 61* EXT_STMT
62* > RETURN null
The $tryThis
closure invokes the following 13 opcodes.
function name: {closure}
number of ops: 13
compiled vars: !0 = $id
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
39 0 E > EXT_NOP
1 BIND_STATIC !0, 'id'
40 2 EXT_STMT
3 NEW $1 :-4
4 EXT_FCALL_BEGIN
5 NOP
6 FAST_CONCAT ~2 'FAILED+getting+ID+%23', !0
7 SEND_VAL_EX ~2
8 DO_FCALL 0
9 EXT_FCALL_END
10 > THROW 0 $1
41 11* EXT_STMT
12* > RETURN null
Finally, the $beforeRetry
closure invokes the following 30 opcodes.
function name: {closure}
number of ops: 30
compiled vars: !0 = $e, !1 = $attempt, !2 = $id
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
0 E > EXT_NOP
1 RECV !0
2 RECV !1
3 BIND_STATIC !2, 'id'
42 4 EXT_STMT
5 INIT_METHOD_CALL !0, 'getCode'
6 EXT_FCALL_BEGIN
7 DO_FCALL 0 $3
8 EXT_FCALL_END
9 IS_IDENTICAL ~4 42, $3
10 > JMPZ ~4, ->13
43 11 > EXT_STMT
12 > RETURN <false>
45 13 > EXT_STMT
14 ROPE_INIT 5 ~6 'Failed+getting+ID+%23'
15 ROPE_ADD 1 ~6 ~6, !2
16 ROPE_ADD 2 ~6 ~6, '+on+try+%23'
17 ROPE_ADD 3 ~6 ~6, !1
18 ROPE_END 4 ~5 ~6, '.+Retrying...%0A'
19 ECHO ~5
46 20 EXT_STMT
21 INIT_FCALL 'sleep'
22 EXT_FCALL_BEGIN
23 SEND_VAL 1
24 DO_FCALL 0
25 EXT_FCALL_END
47 26 EXT_STMT
27 > RETURN <true>
48 28* EXT_STMT
29* > RETURN null
@TODO Opcode Analysis of retry
keyword
LOVE it. PLEASE!!!11