Skip to content

Lock & Rate Limiter — Concurrence et protection d'API

TL;DR — Deux composants Symfony distincts mais souvent utilisés ensemble. symfony/lock fournit des verrous distribués (Redis, Doctrine, Memcached, sémaphores fichier) avec acquire/release, TTL automatique pour éviter les locks orphelins, et auto-refresh pour les opérations longues. C'est la brique pour l'idempotency, la sérialisation d'accès à une ressource partagée, ou la coordination cluster. symfony/rate-limiter propose 3 stratégies (fixed_window, sliding_window, token_bucket) pour limiter la fréquence d'accès — login brute force, API quotas, scraping protection. Les deux se combinent : un rate limiter rejette le 100ᵉ appel par minute, un lock garantit que le 100ᵉ appel n'est pas servi en concurrence avec lui-même.

🧠 Mental model — ASCII + analogie

Lock = une clé d'hôtel. Un seul peut la prendre à la fois. Si je pars sans la rendre, la réception sait au bout d'un certain temps (TTL) que je suis parti et peut donner la chambre à quelqu'un d'autre.

Rate Limiter = un videur de boîte de nuit qui compte les entrées. "Tu es le 60ᵉ ce soir, viens demain". Trois styles selon le bar :

  • fixed_window : "60 entrées par heure pile" (à 23h00 le compteur repart à 0).
  • sliding_window : "60 entrées sur les 60 dernières minutes glissantes" (plus juste, plus coûteux).
  • token_bucket : "tu as 60 jetons, je t'en redonne 1 par minute, dépense-les comme tu veux" (autorise les bursts).
        REQUÊTE


  ┌─────────────────┐  rejected (429)   ┌──────────────┐
  │ RateLimiter     │ ────────────────► │ Retry-After  │
  │  60/minute      │                   └──────────────┘
  └─────────────────┘
           │ allowed

  ┌─────────────────┐  wait (timeout)
  │ Lock            │ ◄──── concurrent request
  │  resource: U-42 │
  └─────────────────┘
           │ acquired

     do critical work


        release()

🛠️ Code minimal (PHP 8.2+)

Configuration Lock

bash
composer require symfony/lock
yaml
# config/packages/lock.yaml
framework:
    lock:
        default: '%env(LOCK_DSN)%'
        # Plusieurs stores :
        # - redis://redis:6379
        # - doctrine://default
        # - memcached://memcached:11211
        # - flock (fichier, local uniquement)
        # - semaphore (POSIX, local uniquement)
        invoice_processing: '%env(LOCK_INVOICE_DSN)%'

Acquérir et libérer un lock

php
<?php
declare(strict_types=1);

namespace App\Service;

use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Exception\LockConflictedException;

final readonly class InvoiceFinalizer
{
    public function __construct(private LockFactory $lockFactory) {}

    public function finalize(int $invoiceId): void
    {
        $lock = $this->lockFactory->createLock(
            resource: "invoice-finalize-{$invoiceId}",
            ttl: 30.0,        // libération automatique après 30s si crash
            autoRelease: true, // libère à la destruction du Lock
        );

        if (!$lock->acquire(blocking: false)) {
            throw new LockConflictedException("Already processing invoice {$invoiceId}");
        }

        try {
            // section critique : 1 seul process à la fois
            $this->generatePdf($invoiceId);
            $this->markFinalized($invoiceId);
        } finally {
            $lock->release();
        }
    }
}

Lock bloquant — et la vérité sur le « timeout »

Subtilité que beaucoup ignorent : acquire(blocking: true) n'a pas de paramètre de timeout. Le comportement dépend du store :

  • Si le store implémente BlockingStoreInterface (ex. SemaphoreStore, certains PostgreSqlStore via pg_advisory_lock), il bloque nativement sans limite de temps jusqu'à obtention.
  • Sinon, Symfony enveloppe le store dans un RetryTillSaveStore qui réessaie en boucle (usleep) jusqu'à acquisition — là encore, indéfiniment par défaut.

Pour une attente bornée, il n'y a pas d'option magique : on implémente la boucle soi-même.

php
$lock = $this->lockFactory->createLock('global-reset', ttl: 30.0);

$deadline = microtime(true) + 5.0; // budget d'attente : 5s
while (!$lock->acquire(blocking: false)) {
    if (microtime(true) >= $deadline) {
        return; // abandon après 5s — on ne bloque pas le worker indéfiniment
    }
    usleep(100_000); // 100ms ; ajoutez un jitter pour éviter le thundering herd
}

try {
    $this->doGlobalReset();
} finally {
    $lock->release();
}

En contexte HTTP, préférez toujours blocking: false (ou cette boucle bornée) : un acquire(blocking: true) non borné peut clouer un worker FPM/PHP et épuiser le pool sous contention.

Auto-refresh pour opérations longues

php
$lock = $this->lockFactory->createLock('long-import', ttl: 60.0);
$lock->acquire();

try {
    foreach ($this->csvRows() as $i => $row) {
        $this->process($row);
        if ($i % 100 === 0) {
            $lock->refresh(); // prolonge le TTL de 60s
        }
    }
} finally {
    $lock->release();
}

Configuration Rate Limiter

bash
composer require symfony/rate-limiter
yaml
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        anonymous_api:
            policy: 'fixed_window'
            limit: 60
            interval: '1 minute'
        authenticated_api:
            policy: 'sliding_window'
            limit: 1000
            interval: '1 hour'
        login_attempts:
            policy: 'token_bucket'
            limit: 5           # bucket size
            rate: { interval: '15 minutes', amount: 5 }

Utilisation directe d'un limiter

php
<?php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;

final class ApiController extends AbstractController
{
    public function __construct(private RateLimiterFactory $anonymousApiLimiter) {}

    #[Route('/api/quote', methods: ['GET'])]
    public function quote(Request $request): Response
    {
        $limiter = $this->anonymousApiLimiter->create($request->getClientIp());
        $limit = $limiter->consume(1);

        if (!$limit->isAccepted()) {
            // `Retry-After` doit être en secondes et JAMAIS négatif (horloges/arrondis) :
            // on clampe à 0. La spec HTTP autorise aussi une date HTTP, mais les secondes
            // sont plus simples à consommer côté SDK.
            $retryAfter = max(0, $limit->getRetryAfter()->getTimestamp() - time());

            $response = new JsonResponse(['error' => 'Too many requests'], Response::HTTP_TOO_MANY_REQUESTS);
            $response->headers->add([
                'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
                'X-RateLimit-Reset' => $limit->getRetryAfter()->getTimestamp(),
                'Retry-After' => $retryAfter,
            ]);
            return $response;
        }

        return new JsonResponse(['quote' => $this->getRandomQuote()], headers: [
            'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
            'X-RateLimit-Limit' => $limit->getLimit(),
        ]);
    }
}

Intégration login brute force (Symfony 5.4+)

yaml
# config/packages/security.yaml
security:
    firewalls:
        main:
            login_throttling:
                max_attempts: 5
                interval: '15 minutes'
                limiter: app.login_rate_limiter  # optionnel, sinon limiter auto
yaml
framework:
    rate_limiter:
        app.login_rate_limiter:
            policy: 'token_bucket'
            limit: 5
            rate: { interval: '15 minutes', amount: 5 }

Le firewall mesure email + IP pour éviter qu'un attaquant épuise la quota d'un user légitime.

Rate limiter sur tout un firewall via listener

php
<?php
namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactory;

#[AsEventListener(event: RequestEvent::class, priority: 10)]
final readonly class ApiRateLimitListener
{
    public function __construct(private RateLimiterFactory $anonymousApiLimiter) {}

    public function __invoke(RequestEvent $event): void
    {
        $req = $event->getRequest();
        if (!str_starts_with($req->getPathInfo(), '/api/')) {
            return;
        }
        $limiter = $this->anonymousApiLimiter->create($req->getClientIp());
        $limit = $limiter->consume(1);

        if (!$limit->isAccepted()) {
            $event->setResponse(new Response('429 Too Many Requests', 429, [
                'Retry-After' => max(0, $limit->getRetryAfter()->getTimestamp() - time()),
            ]));
            // priorité haute + setResponse() ⇒ court-circuite le contrôleur (KernelEvents::REQUEST)
        }
    }
}

🎯 Patterns courants

1. Lock pour idempotency (webhook unique)

Quand Stripe envoie un webhook, il peut le rejouer. Pour ne pas marquer une facture payée deux fois :

php
public function handleStripeWebhook(StripeEvent $event): Response
{
    $lock = $this->lockFactory->createLock("stripe-event-{$event->id}", ttl: 60);
    if (!$lock->acquire()) {
        return new Response('Already processing', 200); // idempotent
    }
    try {
        if ($this->repo->alreadyProcessed($event->id)) {
            return new Response('Already processed', 200);
        }
        $this->process($event);
        $this->repo->markProcessed($event->id);
    } finally {
        $lock->release();
    }
    return new Response('OK', 200);
}

2. Coordination cluster (single leader)

Un worker élu pour faire un job tous les jours, même avec 10 pods :

php
$lock = $this->lockFactory->createLock('nightly-backup', ttl: 3600);
if (!$lock->acquire()) {
    return; // un autre pod est leader
}
$this->doBackup();
// pas de release : on garde le lock jusqu'au TTL (1h)

3. Lock par ressource métier

Empêcher 2 utilisateurs de finaliser le même devis simultanément :

php
$lock = $this->lockFactory->createLock("quote-edit-{$quoteId}", ttl: 300);

4. Rate limit dégradé (allow with delay)

Au lieu de renvoyer 429, on attend que le bucket se remplisse :

php
$limit = $limiter->reserve(1, maxTime: 10); // bloque jusqu'à 10s max
$limit->wait();
// puis on traite

Utile pour des consumers de file qui doivent respecter le quota d'une API tierce (Stripe = 100 req/s) sans perdre de messages.

5. Différenciation par utilisateur

yaml
framework:
    rate_limiter:
        api_per_user:
            policy: 'sliding_window'
            limit: 100
            interval: '1 minute'
php
$limiter = $this->apiPerUserLimiter->create($this->getUser()->getId());

Le create() prend une clé : chaque user a son propre compteur isolé.

6. Lock + Messenger middleware

Un middleware Messenger peut acquérir un lock par message basé sur une propriété (UserId), évitant que deux workers traitent simultanément des messages liés au même user. Pattern utile pour la sérialisation d'opérations par agrégat sans serialization globale.

7. Sliding window pour scraping protection

yaml
scraping_protection:
    policy: 'sliding_window'
    limit: 30
    interval: '1 minute'

Sliding window est plus juste qu'un fixed window contre des bots qui synchronisent leurs requêtes sur le tick de l'horloge.

8. Token bucket pour API tier (burst friendly)

Un utilisateur peut faire 20 req d'un coup (vidange du bucket), puis doit attendre. Plus naturel pour des SDK qui regroupent les requêtes.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

VersionLockRate Limiter
5.4 LTSStable. LockFactory, BlockingStoreInterface, stores Redis/Doctrine/Memcached/Flock.Stable. Trois policies. Intégration login_throttling.
6.0API stable. Ajout du SemaphoreStore.Ajout de reserve() pour attente bornée.
6.4 LTSLock::acquireRead() / acquireWrite() pour locks RW.Amélioration consume()->getLimit().
7.0Drop des stores dépréciés (legacy_redis).Suppression de la policy no_limit (utiliser null factory).
7.1Lock RW dans plus de stores.Réécriture de la sliding window pour précision sub-second.
7.2+SharedLockInterface plus formalisé.PeekableRequestRateLimiterInterface pour inspecter sans consommer.

Comparaison Redlock

Le composant symfony/lock avec un store Redis n'implémente pas Redlock (l'algorithme multi-nœud d'Antirez). Il utilise un lock simple sur un seul nœud Redis. Pour la majorité des cas, c'est suffisant : Redis en haute disponibilité (Sentinel ou Cluster) offre une fiabilité adéquate. Redlock vise des garanties plus fortes (correction face à un fail-over Redis sans perte). Si vous avez besoin de Redlock strict, utilisez la lib cheprasov/php-redis-client ou similaire — mais sachez que Martin Kleppmann a critiqué Redlock comme "ni rapide ni sûr" dans son article "How to do distributed locking". En pratique : pour de l'idempotency webhook et de la coordination soft, le store Redis Symfony suffit largement.

⚠️ Pitfalls — 6-10

  1. Lock sans TTL ⇒ si le process crash, le lock reste à vie. Toujours fournir un ttl raisonnable (= 2-3× la durée nominale du job).

  2. TTL trop court pour un job lent ⇒ le lock expire pendant le traitement, un second process s'en empare, deux process travaillent en parallèle. Soluble avec Lock::refresh() périodique ou TTL plus long.

  3. release() non appelé (exception non capturée) ⇒ try { ... } finally { $lock->release(); } est obligatoire. autoRelease: true aide mais ne couvre pas tous les cas (notamment process killé par OOM).

  4. Lock par utilisateur sur une opération longue dans un contrôleur HTTP ⇒ tu bloques l'utilisateur (timeout client). Préférer router vers Messenger et locker dans le handler async.

  5. Rate limiter avec store en mémoire (in-memory) dans une app multi-FPM ⇒ chaque worker FPM a son propre compteur. Tu autorises N × workers requêtes au lieu de N. Utiliser Redis en prod.

  6. IP comme clé sans considérer le proxy ⇒ derrière Cloudflare ou un ALB, $request->getClientIp() peut être l'IP du proxy si trusted_proxies n'est pas configuré ⇒ un seul compteur global. Toujours configurer framework.trusted_proxies correctement.

  7. fixed_window vulnérable au "burst d'angle" : à 11:59:59 j'ai droit à 60 req, à 12:00:00 je peux refaire 60 ⇒ 120 req en 2 secondes. Pour des limites strictes, préférer sliding_window.

  8. Pas de Retry-After dans la réponse 429 ⇒ les clients réessayent immédiatement, accentuant la charge. Toujours fournir Retry-After ET X-RateLimit-Reset pour aider les SDK clients.

  9. Lock store Doctrine en SQLite/MySQL sans index ⇒ contention sévère. Vérifiez que la table lock_keys a un PK sur la clé.

  10. Confondre rate limiter par IP et par user authentifié : sur les routes mixtes, créer 2 limiters et appliquer selon l'authentification. Sinon un user authentifié dans un café partage la quota anonyme avec ses voisins.

🧪 Testing

Lock avec InMemoryStore

php
use Symfony\Component\Lock\Store\InMemoryStore;
use Symfony\Component\Lock\LockFactory;

$factory = new LockFactory(new InMemoryStore());
$lock1 = $factory->createLock('test');
$lock2 = $factory->createLock('test');

$this->assertTrue($lock1->acquire());
$this->assertFalse($lock2->acquire()); // bloqué
$lock1->release();
$this->assertTrue($lock2->acquire()); // libéré

Rate limiter avec store mémoire

yaml
# config/packages/test/rate_limiter.yaml
framework:
    cache:
        pools:
            rate_limiter.cache:
                adapter: cache.adapter.array
php
$limiter = $this->factory->create('user-1');
$limiter->consume(60); // épuise
$result = $limiter->consume(1);
$this->assertFalse($result->isAccepted());
$this->assertSame(0, $result->getRemainingTokens());

Test du throttling de login

php
// tests/Functional/LoginThrottlingTest.php
public function testTooManyFailedAttempts(): void
{
    $client = static::createClient();

    for ($i = 0; $i < 5; $i++) {
        $client->request('POST', '/login', [
            '_username' => '[email protected]',
            '_password' => 'wrong',
        ]);
    }

    $client->request('POST', '/login', [
        '_username' => '[email protected]',
        '_password' => 'wrong',
    ]);
    $this->assertResponseStatusCodeSame(429);
}

Mock du ClockInterface pour window sliding

php
use Symfony\Component\Clock\MockClock;

$clock = new MockClock('2026-01-15 10:00:00');
$factory = new RateLimiterFactory(['policy' => 'sliding_window', ...], $storage, $clock);
$limiter = $factory->create('u-1');
$limiter->consume(60);
$clock->modify('+30 seconds');
$this->assertFalse($limiter->consume(1)->isAccepted());
$clock->modify('+31 seconds'); // au-delà de la window
$this->assertTrue($limiter->consume(1)->isAccepted());

🎬 Cas d'usage concrets

Lock virement bancaire idempotent

Une néobanque traite des dizaines de milliers de virements par jour. Chaque virement déclenche une chaîne d'opérations : débit du compte source, crédit du compte destinataire, écriture comptable, notification au client, audit log. Si la requête HTTP est rejouée (timeout client, retry navigateur, double-clic), il est impératif que le virement ne soit pas exécuté deux fois — sinon le client perdrait son argent en double, et la responsabilité bancaire serait engagée. La solution combine deux niveaux. Premier niveau : un lock applicatif sur transfer-{idempotencyKey} où la clé est fournie par le client dans le header Idempotency-Key. Le lock est acquis en mode non-bloquant avec TTL 60 secondes ; si déjà acquis, on renvoie la réponse cachée du premier appel (via un cache Redis qui mémorise la réponse pendant 24h). Deuxième niveau : un lock par compte source account-{sourceId} qui sérialise les opérations sur un même compte, empêchant qu'un virement parallèle ne crée un solde négatif via race condition. Le TTL du lock compte est de 10 secondes, suffisant pour une transaction normale. Si le lock compte ne peut être acquis dans les 5 secondes, la requête est rejetée avec un 503 et Retry-After: 2. Tout est tracé : Datadog mesure lock.acquisition.latency, lock.timeout, idempotency.replay. Depuis le déploiement, le taux de doubles paiements est passé de 0.03% à 0.0001% (uniquement des cas anormaux de bugs côté tiers).

Rate limit API e-commerce

Une marketplace e-commerce expose une API publique aux marchands partenaires pour qu'ils synchronisent leurs stocks, prix et commandes. Sans rate limiting, certains marchands envoyaient des dizaines de milliers de requêtes par minute, saturant la base et dégradant l'expérience des autres marchands et des acheteurs finaux. La nouvelle politique impose des quotas tiérisés selon le plan d'abonnement du marchand : tier gratuit 60 req/min, tier pro 600 req/min, tier enterprise 6000 req/min. Le rate limiter Symfony utilise sliding_window (le plus juste pour éviter le burst à la jonction de fenêtre) avec stockage Redis pour partager entre les pods. La clé est {tier}-{merchantId} afin que chaque marchand ait son propre compteur et que les tiers ne se gênent pas. Le header X-RateLimit-Remaining est renvoyé à chaque réponse pour permettre aux SDK clients de back-pressure intelligemment. En cas de dépassement, 429 + Retry-After calculé proprement. Pour les endpoints de webhook réception (où le marchand n'est pas authentifié dans la requête initiale), un rate limit séparé par IP est appliqué pour bloquer les scrapers, avec un quota beaucoup plus restrictif. La direction technique a observé une stabilité retrouvée immédiatement après déploiement, et les marchands pro ont massivement upgrade vers le tier enterprise (CA +18% sur 6 mois).

Lock signature contrat juridique

Un cabinet d'avocats utilise un workflow de signature électronique pour ses contrats clients. Le contrat est généré, envoyé pour signature à plusieurs parties (le client, le contradicteur, l'avocat), et chaque signature est tracée. Risque identifié : si deux signataires cliquent sur "signer" en même temps, ou si une signataire clique deux fois rapidement par impatience, le système peut générer deux entrées de signature pour la même partie ou corrompre l'état du document. Le lock contract-sign-{contractId} est acquis en mode bloquant avec timeout 5 secondes au début de la fonction signer. Si le lock est déjà détenu, l'utilisateur voit un message "Signature en cours, veuillez patienter..." et la requête est retentée automatiquement côté front (Stimulus controller). Une fois le lock acquis, le code vérifie l'idempotency (cette partie a-t-elle déjà signé ce contrat ?), enregistre la signature, met à jour le statut, puis libère le lock. Le contrat passe automatiquement à "signé" quand toutes les parties ont signé — cette vérification est sécurisée par le même lock. Bonus : un audit trail détaille pour chaque tentative de signature qui était bloqué par qui (utile pour les contestations ultérieures). Depuis déploiement, zéro incident de double-signature, zéro contrat corrompu.

🛠️ Exemple end-to-end

Endpoint de virement bancaire avec lock idempotent par clé, lock compte source, et tracé Datadog.

php
<?php
// src/Banking/TransferService.php
declare(strict_types=1);

namespace App\Banking;

use App\Entity\Account;
use App\Entity\Transfer;
use App\Repository\AccountRepository;
use App\Repository\TransferRepository;
use Doctrine\DBAL\Connection;
use Psr\Clock\ClockInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Lock\Exception\LockAcquiringException;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Uid\Uuid;

final readonly class TransferService
{
    public function __construct(
        private LockFactory $bankingLockFactory,
        private AccountRepository $accounts,
        private TransferRepository $transfers,
        private Connection $db,
        private ClockInterface $clock,
        private LoggerInterface $logger,
    ) {}

    public function execute(TransferRequest $req): TransferResult
    {
        // Niveau 1 : idempotency
        $idempotencyLock = $this->bankingLockFactory->createLock(
            resource: "transfer-{$req->idempotencyKey}",
            ttl: 60.0,
        );

        if (!$idempotencyLock->acquire(blocking: false)) {
            // Quelqu'un est déjà en train de traiter, on retourne le résultat caché
            if ($cached = $this->transfers->findCachedResultByIdempotencyKey($req->idempotencyKey)) {
                $this->logger->info('Idempotency replay', ['key' => $req->idempotencyKey]);
                return $cached;
            }
            throw new \RuntimeException('Transfer in flight, no cached result yet');
        }

        try {
            // Court-circuit : si déjà traité, on renvoie le cached
            if ($existing = $this->transfers->findCompletedByIdempotencyKey($req->idempotencyKey)) {
                return TransferResult::fromTransfer($existing);
            }

            return $this->doExecute($req);
        } finally {
            $idempotencyLock->release();
        }
    }

    private function doExecute(TransferRequest $req): TransferResult
    {
        // Niveau 2 : lock par compte source pour éviter race condition sur solde
        $accountLock = $this->bankingLockFactory->createLock(
            resource: "account-{$req->sourceAccountId}",
            ttl: 10.0,
        );

        if (!$accountLock->acquire(blocking: true)) {
            throw new LockAcquiringException('Cannot lock source account, retry later');
        }

        try {
            $source = $this->accounts->findForUpdate($req->sourceAccountId);
            $dest = $this->accounts->findForUpdate($req->destAccountId);

            if ($source->getBalance() < $req->amount) {
                throw new InsufficientFundsException($source->getId(), $req->amount);
            }

            $this->db->beginTransaction();
            try {
                $transfer = new Transfer(
                    id: Uuid::v7(),
                    idempotencyKey: $req->idempotencyKey,
                    source: $source,
                    destination: $dest,
                    amount: $req->amount,
                    currency: $req->currency,
                    initiatedAt: $this->clock->now(),
                );

                $source->debit($req->amount);
                $dest->credit($req->amount);

                $this->transfers->persist($transfer);
                $this->accounts->persist($source);
                $this->accounts->persist($dest);

                $this->db->commit();

                $this->logger->info('Transfer executed', [
                    'transfer_id' => (string) $transfer->getId(),
                    'amount' => $req->amount,
                    'currency' => $req->currency,
                ]);

                return TransferResult::fromTransfer($transfer);
            } catch (\Throwable $e) {
                $this->db->rollBack();
                throw $e;
            }
        } finally {
            $accountLock->release();
        }
    }
}
php
<?php
// src/Controller/TransferController.php
declare(strict_types=1);

namespace App\Controller;

use App\Banking\TransferRequest;
use App\Banking\TransferService;
use App\Exception\InsufficientFundsException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Lock\Exception\LockAcquiringException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('ROLE_USER')]
final class TransferController extends AbstractController
{
    public function __construct(
        private readonly TransferService $service,
        private readonly RateLimiterFactory $transferApiLimiter,
    ) {}

    #[Route('/api/transfers', methods: ['POST'])]
    public function create(Request $request): Response
    {
        $idempotencyKey = $request->headers->get('Idempotency-Key')
            ?? throw $this->createAccessDeniedException('Idempotency-Key required');

        // Rate limit par utilisateur authentifié
        $limit = $this->transferApiLimiter
            ->create($this->getUser()->getUserIdentifier())
            ->consume(1);

        if (!$limit->isAccepted()) {
            return new JsonResponse(
                ['error' => 'Too many transfer requests'],
                Response::HTTP_TOO_MANY_REQUESTS,
                [
                    'Retry-After' => max(0, $limit->getRetryAfter()->getTimestamp() - time()),
                    'X-RateLimit-Remaining' => 0,
                ],
            );
        }

        try {
            $payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);

            $req = new TransferRequest(
                idempotencyKey: $idempotencyKey,
                sourceAccountId: $payload['source'],
                destAccountId: $payload['destination'],
                amount: (int) $payload['amount'],
                currency: $payload['currency'],
            );

            $result = $this->service->execute($req);

            return new JsonResponse([
                'transfer_id' => (string) $result->transferId,
                'status' => $result->status,
                'executed_at' => $result->executedAt->format(DATE_ATOM),
            ], Response::HTTP_CREATED);
        } catch (InsufficientFundsException $e) {
            return new JsonResponse(['error' => 'insufficient_funds'], 422);
        } catch (LockAcquiringException) {
            return new JsonResponse(['error' => 'account_busy'], 503, ['Retry-After' => 2]);
        }
    }
}
yaml
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        transfer_api:
            policy: 'sliding_window'
            limit: 30
            interval: '1 minute'
yaml
# config/packages/lock.yaml
framework:
    lock:
        banking: '%env(LOCK_REDIS_DSN)%'

Tests d'intégration cruciaux : (1) appeler 5 fois le même endpoint avec la même Idempotency-Key et vérifier qu'on a un seul Transfer en DB et 5 réponses identiques, (2) tirer 100 virements concurrents sur le même compte source et vérifier que le solde final est cohérent et qu'aucun solde négatif n'a transité (via lecture isolée), (3) saturer le rate limiter et vérifier le 429 avec Retry-After correctement renseigné.

🔁 Quand utiliser / éviter

Lock — utiliser quand :

  • Vous avez une section critique distribuée (plusieurs workers/pods).
  • Vous voulez de l'idempotency (webhook, retry Messenger).
  • Vous coordonnez l'élection de leader (1 seul exécute un job global).

Lock — éviter quand :

  • Vous coordonnez à l'intérieur d'un seul process ⇒ utilisez Mutex PHP natif ou un simple flag mémoire.
  • Vous coordonnez des opérations base de données ⇒ utilisez les transactions + SELECT ... FOR UPDATE (verrouillage pessimiste SQL) ou versioning (optimiste).
  • Vous voulez de la "sérialisation par agrégat" lourde ⇒ envisagez un acteur model ou un partitionnement de queue (1 partition = 1 worker).

Rate Limiter — utiliser quand :

  • Vous exposez une API publique (anti-abus, fair-use).
  • Vous voulez protéger un endpoint sensible (login, signup, reset password).
  • Vous appelez une API tierce avec quota (Stripe, Twilio) et devez respecter le rate côté client.

Rate Limiter — éviter quand :

  • Vous voulez bloquer un utilisateur de manière permanente ⇒ utilisez plutôt une blacklist / ban.
  • Vous voulez modeler des quotas business (tier gratuit = 100/jour) ⇒ Rate Limiter convient mais combinez avec une persistance (Redis avec TTL au mois) ; pour des quotas annuels, mieux vaut un compteur métier explicite.
  • La protection est réseau (DDoS) ⇒ Cloudflare/WAF, pas Symfony.

Comparaison synthétique des policies

PolicyComportementMémoirePrécisionBursts
fixed_windowCompteur reset à intervalle fixeTrès faibleFaible (effet bordure)Possibles à la jonction
sliding_windowCompteur pondéré sur fenêtre glissanteFaibleÉlevéeLimités
token_bucketBucket avec refill linéaireFaibleÉlevéeBursts contrôlés autorisés

🧭 Comment un·e staff raisonne — modèle mental approfondi

Le lock distribué est un mensonge utile, pas une garantie

Un point que les seniors comprennent et que les juniors ratent : un lock distribué sur un store réseau n'est pas une exclusion mutuelle au sens OS du terme. Entre le moment où acquire() retourne true et le moment où votre code exécute la section critique, il peut s'écouler un temps arbitraire (GC pause, page fault, swap, CPU steal d'un voisin bruyant en cloud, network partition). Pendant ce temps, le TTL peut expirer côté store, le lock être réattribué, et deux process se croient propriétaires en même temps. C'est le scénario que Martin Kleppmann illustre avec son diagramme « process 1 pause → lock expire → process 2 acquiert → process 1 reprend et écrit ».

Conséquence pratique : un lock seul ne suffit jamais à garantir l'intégrité d'une donnée critique (argent, stock). Il réduit la probabilité de collision et sérialise le cas normal, mais la correction finale doit reposer sur une protection côté store de vérité :

  • un fencing token (numéro de version monotone retourné par acquire, vérifié à l'écriture — la ressource refuse une écriture portant un token plus ancien) ;
  • ou une contrainte/transaction DB (SELECT … FOR UPDATE, contrainte d'unicité, version optimiste).

symfony/lock n'expose pas nativement de fencing token ; on l'émule via une colonne version sur l'agrégat. Règle staff : le lock optimise le chemin heureux, la DB garantit la correction.

        acquire(ttl=10s) ──► token=42
   t=0  ┌──────────────────────────────────────────┐
        │ process A : long GC pause (12s) ...       │
   t=10 │            TTL expiré côté Redis           │
   t=10 │   process B acquire ──► token=43, écrit    │
   t=12 │ process A reprend, écrit avec token=42 ────┼─► REFUSÉ (42 < 43)
        └──────────────────────────────────────────┘
              le fencing token sauve l'intégrité

Choisir la policy : un arbre de décision

QuestionRéponse → policy
Le client a-t-il besoin de bursts (SDK qui groupe, batch nocturne) ?Oui → token_bucket
La limite est-elle contractuelle/stricte (« jamais plus de N ») ?Oui → sliding_window
Volume énorme, limite souple, coût mémoire critique ?fixed_window (accepter l'effet bordure)
Faut-il lisser un flux vers une API tierce (1 req toutes les X ms) ?token_bucket + reserve()->wait()

token_bucketdébit moyen borné, pics tolérés. sliding_windowplafond dur sur une fenêtre. Mentalement : token_bucket régule le débit, sliding_window plafonne le comptage.

Coût mémoire réel (ce qu'on stocke dans Redis)

  • fixed_window : 1 clé + 1 entier par (clé métier × fenêtre courante). Le moins cher.
  • token_bucket : 1 hash (tokens restants + timestamp du dernier refill). Bon marché, O(1) par clé.
  • sliding_window : Symfony n'utilise pas un sorted-set de timestamps (le « vrai » sliding log, coûteux). Il garde le compteur de la fenêtre courante + celui de la précédente et interpole au prorata du temps écoulé. C'est le sliding window counter, O(1) mémoire — précis à ~quelques % près, suffisant en pratique. Important à savoir en entretien : la précision n'est pas parfaite, c'est un compromis assumé.

Observabilité — les métriques qui comptent

On n'opère pas un lock/rate-limiter sans télémétrie. Le minimum à exporter (Prometheus/Datadog) :

MétriqueTypePourquoi
lock_acquire_total{result=success|conflict}countertaux de contention réel
lock_acquire_latency_secondshistogramdétecte un store lent (Redis saturé)
lock_held_duration_secondshistogramdétecte les sections critiques qui s'allongent (→ risque d'expiration TTL)
lock_ttl_expired_totalcounteralerte rouge : un lock a expiré sous le job → double exécution possible
ratelimit_consume_total{accepted=true|false}countertaux de rejet 429 par clé/route
ratelimit_remaininggauge (sampled)proximité du plafond, base de l'autoscaling/alerting

Règle d'alerte staff : un lock_ttl_expired_total qui monte = soit le TTL est trop court, soit le job a dégradé ; dans les deux cas, votre garantie d'unicité est tombée — page d'astreinte.

Sécurité — le rate limiter comme surface d'attaque

  • Clé empoisonnable : si la clé dérive d'un header contrôlé par le client (X-Forwarded-For non filtré, User-Agent, un userId non authentifié), l'attaquant fabrique une clé différente par requête et contourne entièrement la limite. La clé doit venir d'une source de confiance : IP après trusted_proxies, ou identité authentifiée.
  • Login throttling — DoS sur compte cible : limiter uniquement par email permet à un attaquant de verrouiller le compte d'une victime en spammant de mauvais mots de passe. Symfony mesure email + IP précisément pour ça. Ne jamais revenir à email seul.
  • Énumération via timing/headers : renvoyer un X-RateLimit-Remaining différent selon que le compte existe ou non leak de l'information. Garder les réponses uniformes sur les endpoints d'auth.
  • Fail-open vs fail-closed : si Redis est down, que fait votre limiter ? Par défaut il throw. Décidez consciemment : un endpoint de login devrait fail-closed (refuser) ; une API best-effort peut fail-open (laisser passer) pour ne pas transformer une panne Redis en panne totale. Wrappez consume() et tranchez explicitement.

Autowiring des limiters — le piège du nom

L'autowiring d'un RateLimiterFactory se fait par nom de propriété/argument : un limiter nommé anonymous_api dans le YAML s'injecte via un argument $anonymousApiLimiter (camelCase + suffixe Limiter). Si vous renommez l'argument, l'injection casse silencieusement (service introuvable au build, ou pire, mauvais limiter). Pour rendre ça explicite et refactor-safe, utilisez #[Target] :

php
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\RateLimiter\RateLimiterFactory;

public function __construct(
    #[Target('anonymous_api')] private RateLimiterFactory $limiter,
) {}

Note Symfony 7.3+ : RateLimiterFactory devient générique (RateLimiterFactoryInterface) et la classe concrète RateLimiterFactory est dépréciée comme type d'injection direct au profit de l'interface. Le #[Target] reste la façon idiomatique de désigner quel limiter on veut.

🔗 Liens


🏋️ Exercices

1. #[WithLock] — verrou déclaratif par attribut (implement)

Objectif : écrire un attribut #[WithLock(resource: 'invoice-{id}', ttl: 30)] + un listener qui acquiert automatiquement un lock avant l'appel d'une méthode de service et le relâche après (interpolation des arguments dans le template de ressource). Indice/Solution : un #[\Attribute] PHP 8 portant resource/ttl/blocking. Branchez-le via un decorator généré (#[AsDecorator]) ou un ServiceLocator + proxy, ou plus simplement un Messenger middleware si l'opération passe par un message. Interpolez {id} avec preg_replace_callback sur les noms d'arguments (ReflectionMethod::getParameters()). Piège à gérer : la réentrance — si la même méthode est rappelée dans le même process avec la même ressource, un store non-réentrant la bloquera ; documentez le comportement ou utilisez LockFactory::createLock() partagé.

2. Limiter le débit sortant vers une API tierce (production-grade)

Objectif : un consumer Messenger traite 5000 messages, chacun appelant l'API Stripe plafonnée à 100 req/s. Implémentez un throttle qui n'abandonne aucun message et respecte le quota, même avec 8 workers concurrents. Indice/Solution : token_bucket (limit=100, rate 100/s) sur store Redis (partagé entre workers, sinon chaque worker a 100 req/s → 800 au total). Dans le handler : $limiter->reserve(1, maxTime: 5)->wait() avant l'appel — reserve() calcule le délai exact et wait() bloque sans rejeter. Si maxTime dépassé → throw new RecoverableMessageHandlingException pour relivraison plus tard. Bonus : exportez ratelimit_wait_seconds pour voir le throttle agir.

3. Idempotency-Key avec réponse mémorisée (production-grade)

Objectif : sur POST /api/transfers, garantir qu'un retry client (même Idempotency-Key) renvoie exactement la même réponse (même statut, même body, même transfer_id) sans ré-exécuter le virement, même si les 2 requêtes arrivent en parallèle. Indice/Solution : lock non-bloquant sur transfer-{key}. Si acquis → exécuter, persister la réponse sérialisée (statut + body) sous la clé en Redis (TTL 24h), relâcher. Si non acquis → poller le cache de réponse quelques centaines de ms ; si présent → le renvoyer, sinon 409 Conflict (« in flight »). Testez les 3 cas : séquentiel (replay), concurrent (un gagne, l'autre lit le cache), et clé jamais vue.

4. Casser puis réparer : le compteur FPM fantôme (break-then-fix)

Objectif : configurez un rate limiter 60/min avec un cache array (in-memory) sur une app à 4 workers FPM. Montrez par un test de charge que vous obtenez ~240 req acceptées au lieu de 60, puis corrigez. Indice/Solution : le bug — cache.adapter.array est par-process ; chaque worker FPM a son propre compteur isolé. Reproduisez avec ab -n 300 -c 20. Le fix : cache.adapter.redis partagé. Démontrez le retour à 60 ±marge. Leçon : un store local ne coordonne rien dès qu'il y a plus d'un process.

5. Casser puis réparer : le lock qui expire sous le job (break-then-fix)

Objectif : un import CSV de 10 min sous lock ttl: 60. Provoquez le scénario où le TTL expire, un second worker démarre, et les deux écrivent en parallèle (doublons en DB). Puis corrigez sans simplement « mettre un gros TTL ». Indice/Solution : le bug — TTL < durée du job. Le « gros TTL » est mauvais (si le worker crash, la ressource reste bloquée des heures). Le bon fix : $lock->refresh() périodique dans la boucle (prolonge le TTL tant qu'on vit) + un fencing token (colonne version sur la ressource) pour qu'un worker zombie ne puisse plus écrire après réattribution. Mesurez lock_ttl_expired_total à 0 après correction.

6. Rate limiter résilient à la panne Redis (architect)

Objectif : votre limiter throw quand Redis est down → toute l'API tombe. Concevez une stratégie où le login fail-closed (refuse) mais l'API publique fail-open (laisse passer), avec un circuit breaker pour ne pas marteler un Redis mort. Indice/Solution : wrappez consume() dans un service ResilientLimiter qui catch les exceptions du store. Politique injectée par limiter (failMode: closed|open). Ajoutez un mini circuit breaker (compteur d'échecs Redis sur N secondes) : une fois ouvert, on shortcut vers le failMode sans tenter Redis pendant un cooldown. Exportez ratelimit_store_unavailable_total et alertez. Discutez le tradeoff sécurité (fail-open = fenêtre d'abus pendant la panne) vs disponibilité.

🎤 En entretien

Q : Un lock distribué garantit-il qu'une seule machine exécute la section critique ? Justifiez. R : Non — c'est une garantie probabiliste, pas absolue. Une pause (GC, swap, CPU steal) après acquire() peut dépasser le TTL : le lock est réattribué et deux process se croient propriétaires. La correction réelle doit reposer sur un fencing token monotone vérifié à l'écriture, ou une contrainte/transaction DB. Le lock optimise le chemin heureux et sérialise le cas normal ; le store de vérité garantit l'intégrité.

Q : fixed_window vs sliding_window vs token_bucket — quand chacun, et leur coût mémoire ? R : fixed_window = compteur reset à intervalle fixe, mémoire minimale, mais vulnérable au burst de bordure (2× la limite à la jonction). sliding_window = plafond dur sur fenêtre glissante ; Symfony l'implémente en O(1) (compteur courant + précédent interpolés au prorata, pas un sorted-set de timestamps) — précis à quelques % près. token_bucket = débit moyen borné avec bursts tolérés, idéal pour un client qui groupe ou pour lisser un flux sortant via reserve()->wait().

Q : Quelle clé choisir pour un rate limiter, et pourquoi c'est un sujet de sécurité ? R : Une clé issue d'une source de confiance : IP après trusted_proxies (sinon on compte l'IP du proxy = un seul compteur global), ou identité authentifiée. Une clé dérivée d'un header client (X-Forwarded-For brut, User-Agent) est empoisonnable : l'attaquant change la clé à chaque requête et contourne la limite. Pour le login, mesurer email + IP (jamais email seul, sinon DoS sur le compte d'une victime).

Q : Redis tombe. Que doit faire votre rate limiter, et est-ce une seule bonne réponse ? R : Ça dépend de l'endpoint — c'est une décision consciente, pas un défaut. Login/paiement → fail-closed (refuser, la sécurité prime). API publique best-effort → fail-open (laisser passer, ne pas transformer une panne Redis en panne totale). Par défaut Symfony throw ; il faut wrapper consume() et trancher par limiter, idéalement derrière un circuit breaker pour ne pas marteler un store mort.

Q : Pourquoi ne pas remplacer un SELECT … FOR UPDATE par un symfony/lock Redis sur la table comptes ? R : Le lock Redis et la transaction DB sont deux univers de cohérence différents. FOR UPDATE verrouille la ligne dans la même transaction qui la modifie : l'atomicité et l'isolation sont garanties par la DB, sans risque de TTL ni de fencing. Un lock Redis externe peut expirer ou diverger de l'état DB. On utilise le lock Redis pour coordonner au-dessus de la DB (idempotency, leader election, sérialiser un agrégat distribué), et FOR UPDATE/version optimiste pour l'intégrité des données elles-mêmes — souvent les deux ensemble.


Récap final

symfony/lock et symfony/rate-limiter sont les outils standard de la concurrence et de la protection dans une stack Symfony. Le lock règle "qui a le droit d'exécuter quoi en même temps". Le rate limiter règle "à quelle fréquence quelqu'un peut demander quoi". Les deux s'appuient sur un store partagé (Redis idéalement) pour fonctionner en cluster. Trois règles d'or : toujours TTL sur les locks, toujours Retry-After sur les 429, et toujours mesurer la bonne clé (user authentifié vs IP vs combinaison). Pour 90 % des besoins applicatifs, ces composants suffisent ; pour les cas extrêmes (Redlock strict, quotas multi-tenants complexes, DDoS), il faut compléter avec des outils dédiés.

Bibliothèque tech perso — Achref