A retry mechanism for Symfony commands
Why? 💡
Developers know that unexpected errors can occur in a program, and it’s a bit annoying when it appears during a command moreover when this command is launched by a system at a time when everybody is sleeping 😴😴
Most of the time the systems used to run commands have a retry mechanism that restarts the command when it fails, but in this article, we’ll see how we can implement a retry mechanism from the code so anywhere you deploy your code the command will be retried in case it fails 🪄
Let’s Go 🚀🚀
For the demo, I’ll make all of my commands retryable by design, but you can adapt the code and make retryable a specific command.
The goal is to have the same retry mechanism as we have in the Symfony HttpClient since version 5.2. We define a max retry
a delay
and a multiplier
, when the command fails we check if the max retry is reached, wait for the defined delay, and re-run the command.
🧠 To make the magic happen I will use the console events with a logic to configure the retry strategy before the command is executed and the logic to retry the command when it fails.
To do this I’ll create 3 PHP classes:
- The command that contains your logic with the script to execute
- A
ConfigureRetryConsoleListener
to configure a global retry strategy (for all commands) but you can override this or configure it in the command itself - A
RetryOnConsoleErrorListener
The Command:
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputInterface, InputOption};
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:test-retry',
description: 'This is command to test the retry',
)]
class MyCommand extends Command
{
public function __construct()
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->text("👋🏽👋🏽 Bonssouar from the command");
throw new \Exception("A weird 🐛🐛 has broke the command!");
return Command::SUCCESS;
}
}
The ConfigureRetryConsoleListener
<?php
namespace App\Command;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(ConsoleEvents::COMMAND, 'configure')]
class ConfigureRetryConsoleListener
{
public function configure(ConsoleCommandEvent $event)
{
// The max retries
$event->getCommand()->addOption(name:'retry',
mode: InputOption::VALUE_OPTIONAL,
default: 3)
;
// The delay between retries in milliseconds
$event->getCommand()->addOption(name:'delay',
mode: InputOption::VALUE_OPTIONAL,
default: 1000)
;
// The multiplier to increase the delay between tries
$event->getCommand()->addOption(name:'multiplier',
mode: InputOption::VALUE_OPTIONAL,
default: 3)
;
}
}
We listen to the ConsoleEvents::COMMAND
event and we define options with default values that can be overridden in each command class, remember that this class is not mandatory and you can define the option directly in the command, I created it to make all of my commands retryable by design.
The Retryable mechanism
<?php
namespace App\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleErrorEvent;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(ConsoleEvents::ERROR, 'retry')]
class RetryOnConsoleErrorListener
{
public function retry(ConsoleErrorEvent $event)
{
$command = $event->getCommand();
$input = $event->getInput();
$output = $event->getOutput();
// Get the retry configuration.
$retries = $input->getOption('retry');
$delay = $input->getOption('delay');
$multiplier = $input->getOption('multiplier');
$io = new SymfonyStyle($input, $output);
$i = 0;
while($i !== $retries) {
try {
$io->warning(
sprintf('Command failed %s, let\'s retry 🚀 after waiting %s milliseconds',
str_repeat("😭",$i+1), $delay));
// Wait the given milliseconds delay
usleep($delay * 1000);
// Debug purpose
dump(time());
$io->warning(sprintf('Retry %s', str_repeat("🔥",$i+1)));
// Re-run the command
$exitCode = $command->run($input, $output);
// Exit in case of success
if ($exitCode === Command::SUCCESS) {
break;
}
} catch (\Throwable $e) {
// Exit in case of max retries reached
if ($i === $retries) {
throw $e;
}
} finally {
// Increase retries and delay
++$i;
$delay *= $multiplier;
}
}
}
}
You can see this snippet on gist and I don’t explain this as I added a bunch of comments 😆
Let’s try 🔥
php bin/console app:test-retry -vvv
🎉🎉 As you can see we retried 3 times and we waited for the given delay before each attempt (see the output of the time function).
The configuration (delay, retry, multiplier) was defined by the default value but you can override them in the command execution
bin/console app:test-retry --delay=2000 --retry=5 --multiplier=4
or inside your Command class in the configure
method with your default value.
#[AsCommand(
name: 'app:foo',
description: 'Add a short description for foo command',
)]
class FooCommand extends Command
{
protected function configure()
{
$this->addOption(name:'retry', mode: InputOption::VALUE_OPTIONAL, default: 5);
$this->addOption(name:'multiplier', mode: InputOption::VALUE_OPTIONAL, default: 4);
$this->addOption(name:'delay', mode: InputOption::VALUE_OPTIONAL, default: 2000);
}
// ...
}
⚠️ Be aware that your program should be idempotent or transactional so you can repeat the actions without being afraid that 90% of the program was executed and it fails at the last 10% because the retry will re-execute the command and the whole logic inside it not only the failed part!
That’s all folks! Thanks for reading don’t forget to clap a max and share it 😚😚