Skip to content

Commit

Permalink
[EasyWebhook] Implement synchronous retries (#517)
Browse files Browse the repository at this point in the history
  • Loading branch information
natepage authored Mar 29, 2021
1 parent 2a91abd commit 0fb386a
Show file tree
Hide file tree
Showing 15 changed files with 337 additions and 12 deletions.
7 changes: 7 additions & 0 deletions packages/EasyWebhook/docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ The status can be set to one of:
This middleware stores the webhook and webhook result in the configured stores after a response has been received from
the webhook HTTP request. See [Stores](stores.md) for more information.

### `SyncRetryMiddleware`

If the webhook was sent synchronously, and it failed, this middleware retries sending the webhook.
It provides a simple out-of-the-box solution for handling retries. However, we strongly recommend sending webhooks
asynchronously, so your application is not blocked by retries.

## Middleware stack

The following table show the middleware stack in priority order, with summaries of their actions:
Expand All @@ -180,6 +186,7 @@ The following table show the middleware stack in priority order, with summaries
| `ResetStoreMiddleware` | Reset webhook and result stores | |
| `MethodMiddleware` | Set request method | |
| `AsyncMiddleware` | If asynchronous, store webhook and return up stack<br/>If synchronous, continue down stack | |
| `SyncRetryMiddleware` | If asynchronous, continue down stack<br/>If synchronous, retries webhook if not successful | |
| `StoreMiddleware` | | Store webhook and result |
| `EventsMiddleware` | | Dispatch event |
| `StatusAndAttemptMiddleware` | | Update status and attempt |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@
use EonX\EasyWebhook\Middleware\SignatureHeaderMiddleware;
use EonX\EasyWebhook\Middleware\StatusAndAttemptMiddleware;
use EonX\EasyWebhook\Middleware\StoreMiddleware;
use EonX\EasyWebhook\Middleware\SyncRetryMiddleware;
use EonX\EasyWebhook\RetryStrategies\MultiplierWebhookRetryStrategy;
use EonX\EasyWebhook\Signers\Rs256Signer;
use EonX\EasyWebhook\Stack;
use EonX\EasyWebhook\Stores\NullResultStore;
use EonX\EasyWebhook\Stores\NullStore;
use EonX\EasyWebhook\WebhookClient;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class EasyWebhookServiceProvider extends ServiceProvider
Expand Down Expand Up @@ -131,29 +133,38 @@ private function registerCoreMiddleware(): void
);
});

$this->app->singleton(SyncRetryMiddleware::class, function (): SyncRetryMiddleware {
return new SyncRetryMiddleware(
$this->app->make(WebhookRetryStrategyInterface::class),
\config('easy-webhook.send_async', true),
$this->app->make(LoggerInterface::class),
MiddlewareInterface::PRIORITY_CORE_AFTER + 30
);
});

$this->app->singleton(StoreMiddleware::class, function (): StoreMiddleware {
return new StoreMiddleware(
$this->app->make(StoreMiddleware::class),
$this->app->make(ResultStoreInterface::class),
MiddlewareInterface::PRIORITY_CORE_AFTER + 30
MiddlewareInterface::PRIORITY_CORE_AFTER + 40
);
});

$this->app->singleton(EventsMiddleware::class, function (): EventsMiddleware {
return new EventsMiddleware(
$this->app->make(EventDispatcherInterface::class),
MiddlewareInterface::PRIORITY_CORE_AFTER + 40
MiddlewareInterface::PRIORITY_CORE_AFTER + 50
);
});

$this->app->singleton(StatusAndAttemptMiddleware::class, function (): StatusAndAttemptMiddleware {
return new StatusAndAttemptMiddleware(MiddlewareInterface::PRIORITY_CORE_AFTER + 50);
return new StatusAndAttemptMiddleware(MiddlewareInterface::PRIORITY_CORE_AFTER + 60);
});

$this->app->singleton(SendWebhookMiddleware::class, function (): SendWebhookMiddleware {
return new SendWebhookMiddleware(
$this->app->make(BridgeConstantsInterface::HTTP_CLIENT),
MiddlewareInterface::PRIORITY_CORE_AFTER + 60
MiddlewareInterface::PRIORITY_CORE_AFTER + 70
);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use EonX\EasyWebhook\Middleware\SendWebhookMiddleware;
use EonX\EasyWebhook\Middleware\StatusAndAttemptMiddleware;
use EonX\EasyWebhook\Middleware\StoreMiddleware;
use EonX\EasyWebhook\Middleware\SyncRetryMiddleware;
use Psr\Log\LoggerInterface;

return static function (ContainerConfigurator $container): void {
$services = $container->services();
Expand Down Expand Up @@ -52,20 +54,26 @@
->arg('$priority', MiddlewareInterface::PRIORITY_CORE_AFTER + 20);

$services
->set(StoreMiddleware::class)
->set(SyncRetryMiddleware::class)
->arg('$asyncEnabled', '%' . BridgeConstantsInterface::PARAM_ASYNC . '%')
->arg('$logger', ref(LoggerInterface::class))
->arg('$priority', MiddlewareInterface::PRIORITY_CORE_AFTER + 30);

$services
->set(EventsMiddleware::class)
->set(StoreMiddleware::class)
->arg('$priority', MiddlewareInterface::PRIORITY_CORE_AFTER + 40);

$services
->set(StatusAndAttemptMiddleware::class)
->set(EventsMiddleware::class)
->arg('$priority', MiddlewareInterface::PRIORITY_CORE_AFTER + 50);

$services
->set(StatusAndAttemptMiddleware::class)
->arg('$priority', MiddlewareInterface::PRIORITY_CORE_AFTER + 60);

// Make sure SendWebhookMiddleware is always last
$services
->set(SendWebhookMiddleware::class)
->arg('$httpClient', ref(BridgeConstantsInterface::HTTP_CLIENT))
->arg('$priority', MiddlewareInterface::PRIORITY_CORE_AFTER + 60);
->arg('$priority', MiddlewareInterface::PRIORITY_CORE_AFTER + 70);
};
10 changes: 10 additions & 0 deletions packages/EasyWebhook/src/Exceptions/InvalidStackIndexException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace EonX\EasyWebhook\Exceptions;

final class InvalidStackIndexException extends AbstractEasyWebhookException
{
// No body needed.
}
4 changes: 4 additions & 0 deletions packages/EasyWebhook/src/Interfaces/StackInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

interface StackInterface
{
public function getCurrentIndex(): int;

public function next(): MiddlewareInterface;

public function rewind(): void;

public function rewindTo(int $index): void;
}
10 changes: 10 additions & 0 deletions packages/EasyWebhook/src/Middleware/AbstractMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

use EonX\EasyUtils\Traits\HasPriorityTrait;
use EonX\EasyWebhook\Interfaces\MiddlewareInterface;
use EonX\EasyWebhook\Interfaces\StackInterface;
use EonX\EasyWebhook\Interfaces\WebhookInterface;
use EonX\EasyWebhook\Interfaces\WebhookResultInterface;

abstract class AbstractMiddleware implements MiddlewareInterface
{
Expand All @@ -17,4 +20,11 @@ public function __construct(?int $priority = null)
$this->priority = $priority;
}
}

protected function passOn(WebhookInterface $webhook, StackInterface $stack): WebhookResultInterface
{
return $stack
->next()
->process($webhook, $stack);
}
}
75 changes: 75 additions & 0 deletions packages/EasyWebhook/src/Middleware/SyncRetryMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace EonX\EasyWebhook\Middleware;

use EonX\EasyWebhook\Interfaces\StackInterface;
use EonX\EasyWebhook\Interfaces\WebhookInterface;
use EonX\EasyWebhook\Interfaces\WebhookResultInterface;
use EonX\EasyWebhook\Interfaces\WebhookRetryStrategyInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

final class SyncRetryMiddleware extends AbstractMiddleware
{
/**
* @var bool
*/
private $asyncEnabled;

/**
* @var \Psr\Log\LoggerInterface
*/
private $logger;

/**
* @var \EonX\EasyWebhook\Interfaces\WebhookRetryStrategyInterface
*/
private $retryStrategy;

public function __construct(
WebhookRetryStrategyInterface $retryStrategy,
?bool $asyncEnabled = null,
?LoggerInterface $logger = null,
?int $priority = null
) {
$this->retryStrategy = $retryStrategy;
$this->asyncEnabled = $asyncEnabled ?? true;
$this->logger = $logger ?? new NullLogger();

parent::__construct($priority);
}

public function process(WebhookInterface $webhook, StackInterface $stack): WebhookResultInterface
{
if ($this->asyncEnabled || $webhook->getMaxAttempt() <= 1) {
return $this->passOn($webhook, $stack);
}

$this->logger->debug(
'Using the synchronous retry is a nice and simple solution.
However, we strongly recommend to setup async feature and use a proper retry strategy within the queue.'
);

$rewindTo = $stack->getCurrentIndex();
$safety = 0;

do {
$stack->rewindTo($rewindTo);

if ($webhook->getCurrentAttempt() > 0) {
\usleep($this->retryStrategy->getWaitingTime($webhook) * 1000);
}

$result = $this->passOn($webhook, $stack);
$safety++;

$shouldLoop = $result->isSuccessful() === false
&& $this->retryStrategy->isRetryable($webhook)
&& $safety < $webhook->getMaxAttempt();
} while ($shouldLoop);

return $result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ public function __construct(
$this->maxDelayMilliseconds = $maxDelayMilliseconds;
}

/**
* @return int The time to delay/wait in milliseconds
*/
public function getWaitingTime(WebhookInterface $webhook): int
{
$delay = (int)($this->delayMilliseconds * \pow($this->multiplier, $webhook->getCurrentAttempt()));
Expand Down
15 changes: 15 additions & 0 deletions packages/EasyWebhook/src/Stack.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace EonX\EasyWebhook;

use EonX\EasyUtils\CollectorHelper;
use EonX\EasyWebhook\Exceptions\InvalidStackIndexException;
use EonX\EasyWebhook\Exceptions\NoNextMiddlewareException;
use EonX\EasyWebhook\Interfaces\MiddlewareInterface;
use EonX\EasyWebhook\Interfaces\StackInterface;
Expand All @@ -31,6 +32,11 @@ public function __construct(iterable $middleware)
);
}

public function getCurrentIndex(): int
{
return $this->index;
}

/**
* @return \EonX\EasyWebhook\Interfaces\MiddlewareInterface[]
*/
Expand All @@ -57,4 +63,13 @@ public function rewind(): void
{
$this->index = 0;
}

public function rewindTo(int $index): void
{
if ($index < 0) {
throw new InvalidStackIndexException(\sprintf('Stack index must be positive, %s given', $index));
}

$this->index = $index;
}
}
6 changes: 4 additions & 2 deletions packages/EasyWebhook/tests/AbstractMiddlewareTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace EonX\EasyWebhook\Tests;

use EonX\EasyWebhook\Interfaces\MiddlewareInterface;
use EonX\EasyWebhook\Interfaces\StackInterface;
use EonX\EasyWebhook\Interfaces\WebhookInterface;
use EonX\EasyWebhook\Interfaces\WebhookResultInterface;
use EonX\EasyWebhook\Stack;
Expand All @@ -15,8 +16,9 @@ abstract class AbstractMiddlewareTestCase extends AbstractTestCase
protected function process(
MiddlewareInterface $middleware,
WebhookInterface $webhook,
?WebhookResultInterface $webhookResult = null
?WebhookResultInterface $webhookResult = null,
?StackInterface $stack = null
): WebhookResultInterface {
return $middleware->process($webhook, new Stack([new MiddlewareStub($webhookResult)]));
return $middleware->process($webhook, $stack ?? new Stack([new MiddlewareStub($webhookResult)]));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use EonX\EasyWebhook\Bridge\Symfony\EasyWebhookSymfonyBundle;
use EonX\EasyWebhook\Tests\Stubs\EventDispatcherStub;
use EonX\EasyWebhook\Tests\Stubs\LockServiceStub;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
Expand Down Expand Up @@ -43,6 +45,7 @@ public function process(ContainerBuilder $container): void
$container->setDefinition(EventDispatcherInterface::class, new Definition(EventDispatcherStub::class));
$container->setDefinition(LockServiceInterface::class, new Definition(LockServiceStub::class));
$container->setDefinition(MessageBusInterface::class, new Definition(MessageBusStub::class));
$container->setDefinition(LoggerInterface::class, new Definition(NullLogger::class));

foreach ($container->getAliases() as $alias) {
$alias->setPublic(true);
Expand Down
Loading

0 comments on commit 0fb386a

Please sign in to comment.