Le blog

Protégez vos API partenaires avec Circuit Breaker - partie 2

Publié le 25 octobre 2024

Dans votre projet Symfony, vous devez effectuer des appels HTTP vers une API ou, plus généralement, un web service. Comment vous assurer que votre application est bien résiliente ? Vous vérifiez que la réponse reçue est valide, que les en-têtes sont bons, les valeurs du payload sont correctes et n'ont pas changé. Et soudain, vous recevez des erreurs 5xx 😱 ! Il y a de fortes chances que l'API soit en difficulté, et imaginons que ce soit parce qu'elle se trouve déjà sur-sollicitée, ce n'est pas lui rendre service de l'inonder de requêtes jusqu'à obtenir une réponse valide. Cela devient un véritable goulot d'étranglement et une source de dysfonctionnement pour votre application ! C'est là qu'entre en jeu le Circuit Breaker. Si vous souhaitez découvrir le Circuit Breaker ou encore la bibliothèque que j'ai choisi d'utiliser, Ganesha, vous pouvez consulter la première partie de cet article.

Le but final, vous le connaissez, c'est de protéger nos appels HttpClient avec l'aide d'un attribut.

Attribut WithCircuitBreaker sur un argument constructeur" class="wp-image-9987"/><figcaption class="wp-element-caption

Note : Cet article met en avant un exemple d'implémentation, pas une librairie clé en main.

#

Ganesha ❤️ Symfony

Ganesha apprécie Symfony... et plus particulièrement HttpClientInterface. Aujourd'hui, il est vrai que la bibliothèque manque d'un bridge pour automatiser cette intégration. Cela dit, c'est déjà appréciable de voir que le support de cette interface est présent ! Cela permet, sans changer la configuration d'une instance de Ganesha, de continuer à utiliser son propre HttpClient comme d'habitude, tout en le transmettant à l'instance de Ganesha.

$ganeshaClient = new GaneshaHttpClient($client, $ganesha);

try {
    $ganeshaClient->request('GET', 'https://demo.api-platform.com/books.jsonld');
} catch (RejectedException $e) {
    // Si le circuit est ouvert, on va recevoir une RejectedException.
}
#

Marquer un argument à protéger

L'idée est de pouvoir spécifier, pour un usage particulier de mon client HTTP (qu'il soit scoped ou non), que les appels effectués dans ce service doivent être résilients. Pour ce faire, je marque l'argument avec un attribut PHP, comme on le ferait avec #[Autowire]ou encore #[MapDecorated]. Le grand avantage, c'est qu'il n'est pas nécessaire de modifier le code pour en tirer parti, et qu'il est possible de définir des règles différentes pour un même client selon le contexte.

Dès que sa rediffusion sera disponible, je vous recommande la conférence de Clément Talleu sur les Attributs

#
Commençons par définir l'attribut PHP :

Pour définir cet attribut, voici ce que j'ai imaginé.

Du typage strict, pour éviter les surprises.

Un namespace qui contiendra le code.

Je marque ma classe comme Attribut, et lui demande de cibler les paramètres de méthode.

La stratégie à passer à Ganesha sera déduite du type d'options que je passerai à mon attribut.

<?php

declare(strict_types=1);

namespace App\CircuitBreaker;

#[\Attribute(\Attribute::TARGET_PARAMETER)]
final readonly class WithCircuitBreaker
{
    public CircuitBreakerStrategy $strategy;

    public function __construct(
        public Rate|Count $options,
        public ?string $serviceNameExtractor = null,
    ) {
        $this->strategy = $options instanceof Rate ? CircuitBreakerStrategy::STRATEGY_RATE : CircuitBreakerStrategy::STRATEGY_COUNT;
    }
}

Le second argument du constructeur permet de personnaliser la façon dont Ganesha génère sa clé de stockage à partir du nom du service. Par défaut, il utilise le domaine extrait de l'URL des méthodes du client HTTP. Cependant, avec l'utilisation des Scoped Client, aucun domaine n'est fourni. On passera alors un FQCN de classe qui implémente l'interface ServiceNameExtractorInterface, chargé de retourner un nom de service.

#
Une option de configuration

Les options sont des classes en lecture seule qui, selon la stratégie que l'on souhaite appliquer, demandent des arguments spécifiques à passer à Ganesha. Je ne vais montrer que le Rate, pour ne pas alourdir cet article.



J'aide nos IDE et outils d'analyse statique avec un peu de PHPDoc en particulier sur le fait que j'utilise IteratorAggregate.

Cela me permettra de parcourir cette classe comme un tableau indexé par nom des propriétés. Cela va m'aider lors de la création de mon service un peu plus tard :)

<?php

declare(strict_types=1);

namespace App\CircuitBreaker;

use Ackintosh\Ganesha\Storage\StorageKeysInterface;

/**
 * @author Grégoire Hébert <contact@gheb.dev>
 *
 * @implements \IteratorAggregate<string,null|int|StorageKeysInterface>
 */
final readonly class Rate implements \IteratorAggregate
{
    public function __construct(
        public ?int $timeWindow = null,
        public ?int $failureRateThreshold = null,
        public ?int $minimumRequests = null,
        public ?int $intervalToHalfOpen = null,
        public ?StorageKeysInterface $storageKeys = null,
    ) {
    }

    public function getIterator(): \Traversable
    {
        return new \ArrayIterator($this);
    }
}

Pour la lisibilité de l'article, j'ai pris l'initiative d'enlever la PHPDoc. Vous remarquerez que dans le dépôt en lien, j'ai pris soin de documenter un maximum mon code afin de m'y retrouver lorsque j'y reviendrai plus tard.

#

Prendre en charge l'attribut

C'est la partie la plus technique de l'article, alors je vais avancer étape par étape. Pour manipuler les services du conteneur d'injection de dépendances, j'interviendrai au moment de sa construction à l'aide d'une passe de compilation. Les concepts que je vais aborder ne sont pas évidents si vous n'avez pas eu l'occasion de travailler avec, donc je vais tâcher d'être le plus clair possible. Chez Les-Tilleuls.coop, cela fait d'ailleurs souvent l'objet d'une à deux journées complètes de formation et d’ateliers. Si vous êtes intéressé(e) à y participer, c'est par ici.

Créons la passe de compilation :

<?php

declare(strict_types=1);

namespace App\CircuitBreaker\DependencyInjection\CompilerPass;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final readonly class CircuitBreakerCompilerPass implements CompilerPassInterface
{
    #[\Override]
    public function process(ContainerBuilder $container): void
    {
        // TODO tout brancher !
    }

Puis, enregistrons la auprès du kernel.php :

<?php

namespace App;

use App\CircuitBreaker\DependencyInjection\CompilerPass\CircuitBreakerCompilerPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    protected function build(ContainerBuilder $container): void
    {
        parent::build($container);

        $container->addCompilerPass(new CircuitBreakerCompilerPass());
    }
}

À partir de maintenant, tout va se passer dans la passe de compilation, alors je vais me contenter de montrer les méthodes sans remettre la classe entière. C'est la méthode process qui est appelée par le kernel.

Puisque Ganesha utilise un builder pour construire son instance, je commence par définir un service pour chacune des stratégies possibles avec une factory. Ici, la stratégie Rate.

Ganesha a besoin d'un adapter pour chaque moyen de stockage et chaque moyen de suivi de l'état des services. Ici, Redis pour la stratégie Rate.

Ensuite, je parcours toutes les définitions de service et, pour chacune, je vérifie s'il est possible d'agir avec l'injection de dépendance et l'autowiring.

public function process(ContainerBuilder $container): void
    {
        $container->register(id: 'ganesha.builder.rate', class: GaneshaRateStrategyBuilder::class)
            ->setFactory(factory: [GaneshaStrategyBuilder::class, 'withRateStrategy']);

        // faire pareil pour la stratégie count

        $container->register(id: 'ganesha.adapter.redis', class: GaneshaRedisAdapter::class)
            ->setArgument('$redis', new Reference(CachePoolPass::getServiceProvider($container, $container->getParameter('env(CIRCUIT_BREAKER_REDIS_DSN)'))));

        // faire pareil pour les autres adapters

        foreach ($container->getDefinitions() as $definition) {
            if ($this->accept($definition) && $reflectionClass = $container->getReflectionClass($definition->getClass(), false)) {
                $this->processClass($definition, $reflectionClass);
            }
        }
    }

Accepter un service, c'est s'assurer qu'il ne contienne pas le tag demandant d'ignorer les attributs, et qu'il est bien marqué autowired.

 private function accept(Definition $definition): bool
 {
     return !$definition->hasTag('container.ignore_attributes') && $definition->isAutowired();
 }   

La classe est acceptée.

Sans constructeur, je ne peux rien faire.



Retrouvons les arguments du constructeur et leur position, pour lesquels il est demandé d'injecter un service issu de HttpClientInterface.

Pour chacun des arguments, si celui-ci possède l'attribut WithCircuitBreaker, alors on l'instancie, puis on s'attelle à modifier l'argument en question.

Si on reçoit plus d'un attribut, c'est qu'il y a un souci. Ça ne doit pas arriver mais ici on en sait rien alors on se protège.

private function processClass(Definition $classDefinition, \ReflectionClass $reflectionClass): void
{
    if (null === $constructor = $reflectionClass->getConstructor()) {
        return;
    }

    foreach ($this->findHttpClientDefinitionArguments($constructor) as ['position' => $position, 'HttpClientReference' => $argumentReference]) {
        $argument = $constructor->getParameters()[$position];






        foreach ($argument->getAttributes(WithCircuitBreaker::class, \ReflectionAttribute::IS_INSTANCEOF) as $k => $attribute) {
            $attribute = $attribute->newInstance();
            $this->processArgument($classDefinition, $argumentReference, $argument->getName(), $attribute);

            if ($k > 0) {
                throw new AutowiringFailedException('WithCirtcuitBreaker attribute cannot set more than once on a autowired argument.');
            }
        }
    }
}

Regardons comment trouver les arguments souhaités.

Pour chaque argument, nous allons regarder le type. Si ce n'est pas un type nommé et que ce type n'est pas HttpClientInterface, on poursuit notre chemin.

Si c'est bon, on donne sa position et sa référence.

private function findHttpClientDefinitionArguments(\ReflectionMethod $constructor): iterable
{
    foreach ($constructor->getParameters() as $pos => $reflectionParameter) {
        $type = $reflectionParameter->getType();
        if (
            !$type instanceof \ReflectionNamedType
            || !is_a($type->getName(), HttpClientInterface::class, true)
        ) {
            continue;
        }

        yield ['position' => $pos, 'HttpClientReference' => new Reference(HttpClientInterface::class.' $'.$reflectionParameter->getName())];
    }
}

Techniquement, il est possible que plusieurs types soient définis sur l'argument. Ce qui n'est pas géré ici.

Maintenant que nous avons entre les mains un argument sur lequel agir, créons le builder selon la stratégie définie par l'attribut, et passons-lui l'adapter.

Ganesha possède une méthode pour chaque option. Grâce à l'IteratorAggregate, parcourons les propriétés : si j'ai une valeur, alors on ajoute un appel de méthode à la définition de service.

Créons le client HTTP de Ganesha à qui nous passons le client HTTP original, et Ganesha lui-même que nous venons de préparer à l'aide du builder.

Si je dois préciser une classe pour la création des clés de suivi, c'est ici que je la déclare comme service, et le lui passe.

Enfin, j'écrase l'argument d'origine pour lui passer le client HTTP de Ganesha à la place.

private function processArgument(Definition $classDefinition, Reference $argumentReference, string $argumentName, WithCircuitBreaker $circuitBreaker): void
{
    $builder = (new ChildDefinition(parent: \sprintf('ganesha.builder.%s', $circuitBreaker->strategy->value)))
        ->addMethodCall(method: 'adapter', arguments: [new Reference('ganesha.adapter.redis')]);

    foreach ($circuitBreaker->options as $option => $value) {
        if (null === $value) {
            continue;
        }
        $builder->addMethodCall(method: $option, arguments: [$value]);
    }



    $ganeshaHttpClientDefinition = new Definition(class: Ganesha\GaneshaHttpClient::class);
    $ganeshaHttpClientDefinition->setArguments([
        $argumentReference,
        (new Definition(class: Ganesha::class))->setFactory(factory: [$builder, 'build']),
    ]);



    if (null !== $serviceExtractor = $circuitBreaker->serviceNameExtractor) {
        $ganeshaHttpClientDefinition->addArgument((new Definition(class: $serviceExtractor))->setArguments([$argumentName]));
    }


    $classDefinition->setArgument('$'.$argumentName, $ganeshaHttpClientDefinition);
}

En résumé, nous avons donc une passe de compilation qui repère toutes les déclarations de service ayant l'attribut WithCircuitBreaker associé à un argument HttpClientInterface, puis le décore par celui de Ganesha.

#

Usage de l'attribut

Maintenant que nos branchements sont faits, regardons un exemple d'usage. Commençons par créer une classe métier qui sera responsable de contacter une API.

<?php

declare(strict_types=1);

namespace App\Services;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class MyService
{
    public function __construct(
        private readonly HttpClientInterface $client
    )
    {
    }

    public function doSomething()
    {
        $result = $this->client->request('GET', 'https://demo.api-platform.com/books.jsonld');

        // TODO let him cook !
    }
}

Admettons que ce soit un appel à une API qui ne doit pas tomber en panne. Pour maitriser et contrôler nos requêtes, il faut ajouter l'attribut.

Je vais prendre le temps d'expliquer les options de la stratégie rate tout de même.

timeWindow c'est l'intervalle de temps (en secondes) écoulé durant lequel on va compter le nombre d'échecs.

failureRateThreshold c'est le seuil du taux d'échec en pourcentage qui change l'état de Circuit Breaker en OPEN (ouvert)

minimumRequests même si failureRateThreshold dépasse le seuil, Circuit Breaker reste en CLOSED si minimumRequests est inférieur à ce seuil. Histoire de ne pas déclencher la sécurité trop vite non plus.

intervalToHalfOpen C'est l'intervalle (en secondes) avant lequel on va laisser passer une requête, pour changer l'état de Circuit Breaker de OPEN à HALF_OPEN.

<?php

declare(strict_types=1);

namespace App\Services;

use App\CircuitBreaker\Rate;
use App\CircuitBreaker\WithCircuitBreaker;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class MyService
{
    public function __construct(
        #[WithCircuitBreaker(
            new Rate(
                timeWindow: 30,
                failureRateThreshold: 1, 
                minimumRequests: 1, 
                intervalToHalfOpen:  10
            )
        )]
        private readonly HttpClientInterface $client
    )
    {
    }

    // ...
}

Désormais, il faut pouvoir protéger son application si le circuit est ouvert.

<?php

declare(strict_types=1);

namespace App\Services;

use Ackintosh\Ganesha\Exception\RejectedException;
use App\CircuitBreaker\Rate;
use App\CircuitBreaker\WithCircuitBreaker;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class MyService
{
    public function __construct(
        #[WithCircuitBreaker(
            new Rate(timeWindow: 30,
                failureRateThreshold: 1,
                minimumRequests: 1,
                intervalToHalfOpen:  10
            )
        )]
        private readonly HttpClientInterface $client,
        private readonly LoggerInterface $logger,
    )
    {
    }

    public function doSomething()
    {
        try {
            return $this->client->request('GET', 'https://demo.api-platform.com/books.jsonld');

        } catch (RejectedException $e) {
            $this->logger->error('[CIRCUIT-BREAKER] api is not available', [
                'reason' => $e->getMessage(),
            ]);

            throw new YourCustomBusinessException(message: 'Nous éprouvons des difficultés à ..., merci de patienter quelques minutes avant d\'essayer à nouveau.', previous: $e);
        } catch (\Exception $e) {
            $this->logger->critical('Impossible to get ... from api', [
                'reason' => $e->getMessage(),
            ]);

            throw new YourCustomBusinessException(message: 'Nous éprouvons des difficultés à ... Si le problème persiste, merci de contacter l\'équipe technique.', previous: $e);
        }
    }
}

Ici, utiliser une exception de mon métier me permet de m'assurer un contrôle dans les couches supérieures et gérer le retour adéquat, si mon API est exploitée dans un contexte web ou un contexte ligne de commande. Et, bien entendu, un logger parce qu'il faut bien se tenir au courant de ce qui se passe sur notre application.

Lors de chaque appel, les informations du statut du service, des échecs et succès seront stockés.

Stockage de l'état de l'API dans redis" class="wp-image-10173"/><figcaption class="wp-element-caption#
Comment faire, lorsque l'on utilise un scoped client ?

Pour rappel, un scoped client est défini dans la configuration du framework.

framework:
    http_client:
        scoped_clients:
            demo.client:
                base_uri: https://demo.api-platform.com

Ce qui permet de changer et simplifier légèrement notre façon de l'utiliser dans notre code.

<?php

declare(strict_types=1);

namespace App\Services;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class MyService
{
    public function __construct(
        private readonly HttpClientInterface $demoClient // le nom du scope se reflète dans le nom de variable
    )
    {
    }

    public function doSomething()    {        
        $result = $this->client->request('GET', '/test'); // il n'est plus nécessaire de mettre l'origine
    }
}

Cependant, comme Ganesha utilise l'URL passée en second argument, il ne peut plus vraiment différencier les services. C’est pourquoi nous allons l’assister en fournissant une classe qui implémente l’interface ServiceNameExtractorInterface.

<?php

declare(strict_types=1);

namespace app\CircuitBreaker;

use Ackintosh\Ganesha\HttpClient\ServiceNameExtractorInterface;

final readonly class ServiceNameDefinition implements ServiceNameExtractorInterface
{
    public function __construct(private string $serviceName)
    {
    }

    #[\Override]
    public function extract(string $method, string $url, array $requestOptions = []): string
    {
        // Je considère la combinaison du nom du service, de l'url et de la méthode HTTP comme service.
        return sprintf('%s.%s_%s', $this->serviceName, $url, $method);
    }
}

Ici, je considère la combinaison du nom du service, de l'URL et de la méthode HTTP comme un service à surveiller à part entière. Cela me permet de différencier les divers appels au sein d'un même service. Par exemple, il est possible que les requêtes de lecture restent accessibles tandis que les requêtes d’écriture sont en échec.

Il ne reste plus qu'à le transmettre à Ganesha via mon attribut.

<?php

declare(strict_types=1);

namespace App\Services;

use App\CircuitBreaker\Rate;
use App\CircuitBreaker\WithCircuitBreaker;
use App\CircuitBreaker\ServiceNameDefinition;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class MyService
{
    public function __construct(
        #[WithCircuitBreaker(
            new Rate(30, 1, 1, 10),
            ServiceNameDefinition::class
        )]
        private readonly HttpClientInterface $client
    )
    {
    }

    // ...
}
#

Le mot de la fin

Il vous reste encore à gérer les événements de Ganesha, qui pourraient permettre de prévenir vos utilisateurs avant même qu'ils déclenchent une action. Ou encore offrir des pages de suivi.
À vous de jouer :)

Une autre approche aurait consisté à proposer une nouvelle clé de configuration directement au niveau de la déclaration des clients HttpClient. Cependant, cela aurait entraîné l'application des règles à l'ensemble des appels, ce qui n'est pas toujours souhaitable.

Un point négatif que je remarque est que l'approche choisie est incomplète du point de vue de l'autowiring de Symfony, qui est extrêmement puissant. Il permet d'injecter un service directement dans une propriété publique, à condition qu'un typehint précise sans ambiguïté l'existence d'un service associé. Mon implémentation ne prend pas en charge cette approche. De plus, un appel de méthode lors de la création du service pour injecter un HttpClient aurait également été possible, mais mon implémentation ne gère pas cette fonctionnalité non plus. Tout est envisageable, mais ce n'était pas mon besoin.

Enfin, faut-il une suite ? Ce n'est pas prévu. Un bundle pourrait être créé à partir de cela, offrant un branchement avec Ganesha, CircuitBreaker voire même en intégrant et complétant Resiliency. Vous voulez voir une de ces alternatives sortir ? Discutons-en !

Merci de m'avoir lu !

#

Bonus

Retrouvez le code sur ce dépot.

Note de l'auteur : En faisant relire cet article, on me fait remarquer qu'il n'aborde pas les raisons qui poussent à choisir ce patron de conception, ni les critères qui déterminent quand l'appliquer à un appel à une API, ou encore ceux qui indiquent qu'il ne faut pas utiliser de Circuit Breaker. Si un article plutôt orienté architecture vous intéresse, faites-le nous savoir sur les réseaux sociaux !

Grégoire Hébert

Grégoire Hébert

Principal developer

Mots-clésPHP, Symfony

Le blog

Pour aller plus loin