Aller au contenu principal

Mettre une API externe en cache sans servir de données périmées

Photo d'Emmanuel BALLERY, fondateur de x10
Emmanuel BALLERY
CTO freelance & Architecte logiciel
calendar_today 26/06/2026
schedule 12 min lecture
Un cache distribué protège une application des lenteurs et des pannes d'une API externe

Beaucoup d'applications d'entreprise dépendent d'une API qu'elles ne maîtrisent pas : un annuaire interne, un référentiel métier, un système de ticketing maison. Chez le grand opérateur télécom de cette série d'articles, c'était une API d'annuaire et de tickets : chaque écran affichant un organigramme, une fiche agent ou la liste des services interrogeait ce service distant. Lent, parfois indisponible, et facturé en latence sur chaque page.

Le réflexe est connu : mettre un cache devant. Le piège l'est moins. Un cache posé sans discernement sert des tickets fermés comme ouverts, masque les créations récentes, et transforme un problème de lenteur en problème d'incohérence — bien plus difficile à diagnostiquer. Tout l'art tient dans une question qu'on se pose trop tard : qu'est-ce qui peut être caché, et qu'est-ce qui ne doit jamais l'être ?

D'abord une façade : traduire, pas exposer

Avant même de parler de cache, une règle : aucune partie de l'application ne doit connaître la forme des réponses de l'API externe. Le bridge est une façade — une couche anti-corruption — dont le seul rôle est de traduire les modèles JSON distants en objets internes propres.

Plutôt que de mapper les champs à la main, on délègue ce travail au Serializer de Symfony : le DenormalizerInterface transforme les tableaux JSON bruts en objets internes. Les divergences de nommage entre l'API et le domaine se déclarent une seule fois, en attribut sur le modèle, avec #[SerializedName] :

// le bridge ne mappe rien à la main : il denormalise
$users = $this->denormalizer->denormalize($data, User::class . '[]');
final class User
{
    public function __construct(
        public int $id,
        #[SerializedName('cuid')] // le nom distant, absorbé sur le modèle
        public string $uid,
        #[SerializedName('first_name')]
        public string $firstName,
        #[SerializedName('last_name')]
        public string $lastName,
        public ?string $email = null,
        public ?string $department = null,
    ) {
    }
}

L'intérêt dépasse l'économie de code. En s'appuyant sur le Serializer, le bridge partage le même schéma de (dé)sérialisation que le reste de l'application, et toute la traduction se concentre sur le modèle. Parce que le code métier manipule des User internes, jamais des tableaux JSON venus d'ailleurs, le jour où l'API renomme un champ on touche un attribut, pas du code. Et c'est cette frontière qui rend le cache possible sans le disperser : tout passe par quelques méthodes, donc le cache se pose à un seul endroit.

Quoi cacher, et surtout quoi ne jamais cacher

Voici la décision qui structure tout. Les données de l'API ne sont pas égales devant le cache :

Concrètement, cette frontière se lit dans le code : les lectures de référence passent par un helper qui cache, les lectures vivantes appellent l'API directement.

// annuaire : donnée de référence, on passe par le cache
public function findUsers(array $query = []): Users
{
    $data = $this->fromCache('/api/users', $query);

    return new Users($this->denormalizer->denormalize($data, User::class . '[]'));
}

// tickets : donnée vivante, jamais de cache — appel direct à chaque fois
public function findTasks(array $query = []): Tasks
{
    $data = $this->fetch('/api/tasks', $query);

    return new Tasks($this->denormalizer->denormalize($data, Task::class . '[]'));
}

Ce n'est pas une optimisation qu'on ajoute après coup, c'est une décision de conception qu'on prend en écrivant chaque méthode : cette donnée a-t-elle le droit d'être vieille d'une minute ? La réponse range la méthode d'un côté ou de l'autre de la frontière, définitivement.

Une clé de cache stable : normaliser avant de hacher

Reste un piège discret. La clé de cache doit identifier une requête, mais deux requêtes identiques peuvent s'écrire différemment : ['page' => 1, 'limit' => 30] et ['limit' => 30, 'page' => 1] désignent le même appel. Sans précaution, elles produisent deux clés différentes — donc deux entrées de cache, deux appels à l'API, et la moitié du bénéfice envolée. La parade est de normaliser avant de hacher :

private function fromCache(string $uri, array $query): array
{
    $key = $this->cacheKey($uri, $query);

    return $this->cache->get($key, fn(): array => $this->fetch($uri, $query));
}

private function cacheKey(string $uri, array $query): string
{
    ksort($query); // l'ordre des paramètres ne doit pas changer la clé

    return 'directory_' . md5($uri) . '_' . md5(JsonUtils::encode($query));
}

Le ksort() rend la clé indépendante de l'ordre des paramètres ; le double md5 garde une clé courte et sûre, sans caractères interdits par le stockage de cache. Détail qui compte : on passe par le contrat CacheInterface::get() plutôt que par un has()/set() manuel, car il apporte gratuitement la protection contre le cache stampede — quand une clé expire, un seul processus recalcule pendant que les autres servent l'ancienne valeur, au lieu de marteler l'API tous en même temps.

Quand l'API renâcle : 404, 401 ou panne

Une dépendance externe finit toujours par mal répondre, et tous les échecs ne se valent pas. Chaque appel s'authentifie avec un jeton OAuth2 porté en auth_bearer, mis en cache exactement comme dans l'article précédent (négocié une fois, expiré soixante secondes avant terme). La brique fetch() porte cette authentification ; au-dessus, la méthode publique enveloppe l'appel d'un filet qui distingue trois cas :

// fetch : le GET authentifié, brique commune aux appels cachés et directs
private function fetch(string $uri, array $query): array
{
    return $this->client
        ->request('GET', $uri, [
            'auth_bearer' => $this->tokenFactory->getToken(),
            'query' => $query,
        ])
        ->toArray();
}

// le filet enveloppe le cache : un appel en échec n'est jamais mis en cache
public function findUsers(array $query = []): Users
{
    try {
        $data = $this->fromCache('/api/users', $query);

        return new Users($this->denormalizer->denormalize($data, User::class . '[]'));
    } catch (Throwable $e) {
        if ($e instanceof ClientExceptionInterface) {
            $status = $e->getResponse()->getStatusCode();

            // 404 : la ressource n'existe pas — ce n'est pas une erreur
            if (404 === $status) {
                return new Users([]);
            }

            // 401/403 : le token ne vaut plus rien — on le purge
            if (401 === $status || 403 === $status) {
                $this->tokenFactory->reset();
            }
        }

        // réseau, 5xx : on trace la cause et on dégrade
        $this->logger->error('appel au référentiel en échec', [
            'uri' => '/api/users',
            'exception' => $e->getMessage(),
        ]);

        return new Users([]);
    }
}

Le 404 n'est pas un échec : une fiche absente est une réponse valable, pas une exception à propager. Le 401/403 trahit un token révoqué ou expiré côté serveur malgré la marge ; on purge le cache de jetons pour forcer une renégociation au prochain appel — le pendant, côté consommateur de cache, du retry détaillé dans l'article sur le transport Mailer. Tout le reste — coupure réseau, 5xx — est journalisé puis absorbé : on renvoie le vide. Un détail structurel rend l'ensemble sûr : le filet entoure le cache, pas l'inverse — une réponse en échec n'atteint jamais cache->get(), si bien que le cache ne mémorise que des succès.

Ce choix mérite d'être explicite, car il a un coût : un résultat vide qui remonte oblige chaque appelant à gérer l'absence de donnée. C'est précisément ce qu'on veut — un organigramme momentanément vide vaut mieux qu'une application qui tombe parce qu'un service tiers tousse. Le couple cache + dégradation forme d'ailleurs un filet à deux niveaux : tant que le cache est chaud, une panne de l'API reste même invisible.

Garder le cache frais : TTL ou invalidation

Reste la question que tout cache finit par poser : quand devient-il périmé ? Pour l'annuaire distant, la réponse est un TTL — une durée de vie. On ne possède pas la donnée, on ne sait pas quand elle change à la source, alors on accepte qu'elle soit vieille d'une heure tout au plus et on laisse l'entrée expirer d'elle-même. Cette durée se déclare dans la configuration du pool, et c'est l'occasion de montrer d'où vient le $this->cache injecté dans le bridge :

framework:
  cache:
    default_redis_provider: '%env(REDIS_DSN)%'
    pools:
      # le pool du bridge annuaire : mémoire du process (L1), puis Redis partagé (L2)
      cache.directory:
        default_lifetime: 3600
        adapters:
          - cache.adapter.array
          - cache.adapter.redis

when@test:
  framework:
    cache:
      pools:
        cache.directory:
          adapters:
            - cache.adapter.null # aucun cache en test : comportement déterministe

Deux choses valent d'être notées. Le pool empile deux adaptateurs : un cache mémoire local au process, devant un Redis partagé entre toutes les instances — une requête déjà vue par un autre worker est servie sans toucher l'API. Et en test, le même pool bascule sur l'adaptateur null : aucune entrée n'est conservée, chaque test repart d'un cache vide, sans surprise.

Mais le TTL est un pis-aller : il fait le pari qu'une donnée périmée pendant une heure est tolérable. Dès qu'on possède la source — non plus une API distante, mais sa propre base —, on peut faire bien mieux qu'attendre l'expiration. Sur une autre application, une plateforme SaaS PIM, le cache garde des calculs coûteux sur des données que la plateforme maîtrise ; laisser un TTL afficher un attribut périmé après une modification serait un bug visible. La parade est d'invalider exactement quand la donnée change, avec un listener Doctrine :

#[AsDoctrineListener(Events::preUpdate)]
#[AsDoctrineListener(Events::postFlush)]
final class CacheListener
{
    private array $dirty = [];

    public function preUpdate(PreUpdateEventArgs $args): void
    {
        // pendant le flush, on note seulement QUELS types ont changé
        if ($args->getObject() instanceof Attribute) {
            $this->dirty[] = CacheTypeEnum::ATTRIBUTE;
        }
    }

    public function postFlush(): void
    {
        // une fois le commit fait, on reconstruit chaque cache touché — une seule fois
        foreach (array_unique($this->dirty) as $type) {
            $this->doctrineCache->get($type)?->warmup();
        }

        $this->dirty = [];
    }
}

Le découpage en deux temps est le cœur du pattern. Les événements preUpdate (et leurs cousins prePersist, preRemove) ne font qu'accumuler les types touchés pendant la transaction ; c'est seulement en postFlush, une fois les données réellement écrites, que le cache est reconstruit. On évite ainsi deux pièges : reconstruire un cache à partir de données pas encore committées, et le reconstruire dix fois pour dix entités modifiées dans la même transaction. Un seul rebuild, après coup, sur les seuls types concernés.

Une nuance pour les contextes long-running — un worker Messenger, un serveur FrankenPHP — où le listener survit d'un message au suivant : son tableau $dirty est bien vidé en postFlush, mais si une transaction échoue avant de l'atteindre, le résidu fuiterait sur le message d'après. Faire implémenter ResetInterface au listener — une méthode reset() qui remet $dirty à vide — laisse Symfony nettoyer cet état à chaque cycle et referme la fuite. Inutile en PHP-FPM, où chaque requête repart d'un état neuf ; salutaire dès qu'un process est réutilisé.

Restent les cas où il faut reprendre la main à la main — un incident, un déploiement, un cache qu'on soupçonne corrompu. Symfony fournit les commandes, auxquelles s'ajoute un warmup métier pour reconstruire les caches applicatifs d'un coup :

# vider un pool précis, sans toucher au reste du cache applicatif
php bin/console cache:pool:clear cache.directory

# purger uniquement les entrées expirées (à passer en cron)
php bin/console cache:pool:prune

# reconstruire les caches métier après un incident ou un déploiement
php bin/console app:cache:warmup

Bonus : tester sans jamais appeler l'API

Toute cette mécanique s'appuie sur une dépendance réseau — la bête noire des tests. La parade est la même qui a servi à poser le cache proprement : l'application ne dépend pas de la classe concrète, mais d'une interface. En production, l'implémentation réelle parle à l'API ; en test, une fausse implémentation renvoie des données déterministes, câblée à sa place dans la configuration de l'environnement de test :

// câblé uniquement en env de test (config/services.php)
final class FakeDirectory implements DirectoryInterface
{
    public function findUser(string $uid): ?User
    {
        // données fixes, aucune requête réseau : les tests e2e sont déterministes
        return new User(1, $uid, 'Ada', 'Lovelace', 'ada@example.com', 'Recherche');
    }

    // … les autres méthodes renvoient de même des objets prévisibles
}

Le bénéfice dépasse la vitesse. Les tests de bout en bout deviennent reproductibles — pas de flakiness dû à une API distante lente ou capricieuse — et ils peuvent tourner hors-ligne, en CI, sans le moindre accès au réseau interne. La même interface qui isole le métier de l'API en production l'isole du réseau en test : une frontière, deux bénéfices.

Conclusion

Mettre une API externe en cache n'est pas un problème de cache, c'est un problème de classification. Le travail technique — un helper, une clé normalisée, un try/catch — est mineur ; la décision qui compte est de trier chaque donnée entre ce qui peut vieillir et ce qui doit rester vivant, puis de choisir comment la rafraîchir — un TTL quand on subit la source, une invalidation par événement quand on la maîtrise. Une façade qui centralise les appels rend ces décisions applicables à un seul endroit, et une interface rend le tout testable hors-ligne.

La règle se résume en une phrase : cacher la référence, jamais le vivant ; et toujours prévoir le jour où l'API ne répond pas. C'est moins spectaculaire qu'une grappe Redis savamment réglée, mais c'est ce qui distingue un cache qui accélère d'un cache qui ment.

Photo d'Emmanuel BALLERY, fondateur de x10

À propos de l'auteur

Emmanuel BALLERY est le fondateur de x10 solutions. Expert en architecture logicielle et passionné par la qualité du code (Software Craftsmanship), il aide les entreprises à transformer leur dette technique en actifs durables.

Voir plus arrow_forward

Questions fréquentes

Faut-il mettre toute une API externe en cache ? expand_more
Non, et c'est l'erreur classique. Seules les données de référence qui changent lentement (annuaire, organigramme, catalogue) doivent être cachées. Les données vivantes (tickets, statuts, événements) et les écritures ne doivent jamais l'être : les cacher revient à servir des informations fausses. La vraie question, méthode par méthode, est « cette donnée a-t-elle le droit d'être vieille d'une minute ? ».
Comment invalider un cache : TTL ou événement ? expand_more
Cela dépend de qui possède la donnée. Quand elle vient d'une API externe qu'on ne maîtrise pas, on accepte un TTL (durée de vie) déclaré sur le pool de cache et on laisse l'entrée expirer. Quand on possède la source — sa propre base —, on fait mieux : un listener Doctrine détecte les changements d'entités et reconstruit le cache concerné en postFlush, après le commit, une seule fois par transaction. En secours, les commandes cache:pool:clear et cache:pool:prune permettent de vider ou purger un pool à la main.
Comment construire une clé de cache fiable pour une requête paramétrée ? expand_more
En normalisant les paramètres avant de les hacher. Deux requêtes identiques aux paramètres dans un ordre différent doivent produire la même clé : on trie le tableau de paramètres (ksort) puis on le hache. Sans cette normalisation, on multiplie les entrées de cache pour une même requête et on perd une partie du bénéfice.
Que se passe-t-il quand l'API externe tombe ? expand_more
Avec une dégradation gracieuse, l'application plie sans rompre : chaque appel est enveloppé, une erreur est journalisée et la méthode renvoie une valeur neutre (null ou liste vide). Un 404 est traité comme une absence normale ; un 401 invalide le jeton OAuth mis en cache. L'appelant affiche alors un état dégradé — un bloc vide, une donnée en cache — plutôt qu'une page d'erreur. Couplé au cache, ce mécanisme rend même la panne invisible tant que le cache reste chaud.
Comment tester un code qui dépend d'une API externe ? expand_more
En dépendant d'une interface plutôt que de la classe concrète. En production, l'implémentation réelle appelle l'API ; en test, une fausse implémentation renvoie des données déterministes, câblée à sa place dans la configuration de l'environnement de test. Les tests de bout en bout deviennent reproductibles et tournent hors-ligne, sans accès réseau.