API Rate Limits : comprendre les quotas, headers de limite et erreurs 429

Comprendre quotas, headers de limite et erreurs 429 : stratégies concrètes (backoff+jitter, lissage via queue, idempotence) et exemple d’implémentation PHP/PrestaShop.

Trois écrans d'ordinateur montrant des informations sur les limites d'API et des visualisations de données.

Table des matières :

  1. Quotas, rate limits et fenêtres : ce que le fournisseur mesure réellement
  2. Headers de limitation : X-RateLimit-* vs RateLimit-* et interprétation utile
  3. Erreurs 429 : différencier dépassement, saturation et anti-abus
  4. Stratégies client : backoff + jitter, lissage via queue, idempotence
  5. Implémentation PHP/PrestaShop (8.1–9.x) : parse headers et limiter la concurrence
  6. Quand vous contrôlez l’API : limiter au gateway (Nginx/Envoy) et exposer des signaux exploitables

Les API rate limits ne sont pas un détail “devops” : dès que vous synchronisez un catalogue, envoyez des SMS transactionnels, ou poussez des commandes vers un ERP, vous êtes en concurrence avec d’autres clients de l’API et avec les propres mécanismes de protection du fournisseur. Si vous ne modélisez pas les quotas, vous finirez avec des pics de latence, des boucles de retry qui empirent la situation, et des erreurs HTTP 429 (Too Many Requests) qui masquent la vraie cause.

Mini-scénario très courant en e-commerce : à 09:00, un cron lance une synchro “stock + prix” sur 40 000 références. Le module ouvre 10 workers en parallèle, chaque worker envoie des rafales de PUT et GET (stock, déclinaisons, images). Résultat : les premiers lots passent, puis les 429 s’enchaînent. Si le retry est mal borné, vous créez un incident auto-entretenu (les retries prennent plus de place que le trafic “utile”), et la fenêtre suivante démarre déjà avec des files en retard.

Quotas, rate limits et fenêtres : ce que le fournisseur mesure réellement

Un quota est une enveloppe de consommation (par exemple “1 000 000 requêtes/jour”), alors qu’un rate limit est une contrainte de débit (par exemple “10 requêtes/seconde”). Les deux coexistent souvent : vous pouvez être sous le quota journalier et quand même vous faire bloquer à la seconde. Dans la pratique, les fournisseurs imposent aussi des limites par clé API, par IP, par utilisateur, par endpoint, voire par coût (une recherche = 5 unités, une création de commande = 20 unités). Lire uniquement “X-RateLimit-Remaining” sans comprendre l’unité sous-jacente est une recette pour les faux diagnostics.

Deux points qui font souvent perdre du temps en incident :

  • La limite est rarement “globale” : il peut exister une limite “lecture” et une limite “écriture”, ou une limite spécifique à un endpoint critique (ex. POST /orders) plus stricte que le reste.
  • Le fournisseur mesure parfois des unités de calcul, pas des requêtes : une requête “simple” compte 1, une requête avec filtre/tri ou payload lourd compte 5–10. Sans documentation explicite, vous ne pouvez pas déduire le coût à partir du seul volume HTTP.

Côté implémentation, les algorithmes les plus courants sont :

  • Fixed window (fenêtre fixe) : simple mais crée des effets de bord au “top” minute/seconde.
  • Sliding window / rolling window : plus juste, mais plus complexe (souvent côté gateway).
  • Token bucket : autorise les bursts jusqu’à une capacité, puis lisse le débit.
  • Leaky bucket : “égoutte” un débit constant, les bursts sont absorbés par une file.

Ces choix ont un impact direct sur votre stratégie client : un backoff naïf peut rester inefficace si la fenêtre est fixe et que vous retombez exactement au mauvais moment.

Pour éviter de “surprendre” vos limites, calculez un ordre de grandeur avant de coder (ou avant une mise en prod) :

  • Nombre d’entités × nombre d’appels par entité × multiplicateur de variantes (déclinaisons, stocks par entrepôt, images, attributs)
  • Ajoutez les appels “invisibles” : authentification/refresh token, pagination, recherche d’existence (GET avant POST), vérification d’état, etc.

Exemple concret (volumétrie masquée) : 50 000 produits, 1 GET pour vérifier + 1 PUT pour mettre à jour = 100 000 requêtes. Si vous ajoutez 1 appel stock + 1 appel image : 200 000. Avec une limite de 10 req/s, cela représente ~5h30 de trafic continu (200 000 / 10 / 3600 ≈ 5,55 h), sans compter les erreurs, les retries et les rafales.

En e-commerce, le piège classique c’est la volumétrie masquée : une “simple” synchro de 50 000 produits peut générer 50 000 GET + 50 000 PUT + des appels auxiliaires (stocks, images, attributs). Sur PrestaShop, ça arrive très vite si votre module travaille “dans” un hook (ex. actionProductSave) au lieu de basculer sur une file de jobs. Pour des architectures de synchronisation plus robustes (polling, webhooks, batching), voir aussi l’approche décrite dans l’article sur la synchronisation bidirectionnelle à forte volumétrie.

Checklist rapide (côté client) pour éviter les surprises :

  • identifier toutes les limites annoncées (par seconde, minute, jour, par endpoint, par méthode)
  • vérifier si la limite est par application ou par utilisateur final
  • mesurer votre latence moyenne et p95 (elle influence le nombre de requêtes “en vol”)
  • décider si vous êtes en mode burst (import ponctuel) ou mode continu (sync temps réel)

Headers de limitation : X-RateLimit-* vs RateLimit-* et interprétation utile

Deux familles cohabitent en 2026 : les headers “historiques” X-RateLimit-* (non standardisés, sémantique variable) et les headers standard RateLimit-* spécifiés par l’IETF. Beaucoup d’APIs exposent encore des variantes type X-RateLimit-Reset en epoch seconds, X-RateLimit-Limit par minute, ou plusieurs couples X-RateLimit-Limit selon l’endpoint. Votre parseur doit accepter l’hétérogénéité et, surtout, ne jamais “deviner” une unité.

Les trois informations réellement exploitables côté client sont : 1) La capacité (limite) sur une fenêtre ou une période donnée.
2) Le restant (remaining) : ce qui est consommable immédiatement.
3) Le moment de reset (reset) : quand la consommation redevient possible.

À ça s’ajoute souvent Retry-After, qui est votre signal le plus actionnable lors d’un blocage. MDN résume correctement l’intention du header : « The HTTP Retry-After response header indicates how long the user agent should wait before making a follow-up request. » (MDN Web Docs, Retry-After) : MDN Web Docs — Retry-After

Exemple typique (fournisseur “legacy”) :

HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 17
X-RateLimit-Reset: 1725903600

Exemple typique (standardisation RFC 9331, formats parfois plus riches) :

HTTP/1.1 200 OK
RateLimit-Limit: 100;w=60
RateLimit-Remaining: 42
RateLimit-Reset: 35

Ici, w=60 est une fenêtre de 60 secondes et RateLimit-Reset: 35 indique “reset dans 35 secondes”. Attention : certains fournisseurs renvoient RateLimit-Reset en timestamp absolu, d’autres en delta.

Une interprétation utile (et robuste) consiste à traiter les headers comme des indices et à toujours garder une preuve brute :

Header Ce que vous pouvez en faire côté client Piège fréquent
Retry-After délai d’attente prioritaire (seconds ou HTTP-date) oublier le format date HTTP et le traiter comme un entier
RateLimit-Limit / X-RateLimit-Limit dimensionner la concurrence (cible RPS) ignorer w= (fenêtre) ou supposer “par seconde”
RateLimit-Remaining / X-RateLimit-Remaining décider de ralentir avant le blocage (“soft limit”) croire que “remaining=0” implique un 429 immédiat (ce n’est pas systématique)
RateLimit-Reset / X-RateLimit-Reset savoir quand relancer le flux confondre epoch et delta

La seule approche robuste : implémenter une table de compatibilité par fournisseur et journaliser les headers bruts en cas d’incident (au minimum pour les réponses 429 et 5xx). En clair : vous ne cherchez pas à “supporter toutes les APIs du monde”, vous cherchez à ne pas être aveugle quand cette API change un format ou un comportement.

Deux optimisations simples qui réduisent la pression sur les limites, sans toucher au backoff :

  • caching (si l’API le permet) et respect des headers ETag / If-None-Match ou If-Modified-Since pour éviter de re-télécharger des ressources inchangées ;
  • batching quand il existe des endpoints “bulk” (souvent présents pour catalogues/prix/stocks) : 1 requête bulk vaut presque toujours mieux que 100 requêtes unitaires, même si le coût “unité” est supérieur.

Erreurs 429 : différencier dépassement, saturation et anti-abus

La définition normative est sans ambiguïté. RFC 6585 (HTTP: Additional Status Codes) indique : « The 429 status code indicates that the user has sent too many requests in a given amount of time (« rate limiting »). » (RFC 6585, section 4) : RFC 6585

Dans la vraie vie, certains fournisseurs utilisent 429 pour des cas qui ne sont pas “trop de requêtes” au sens strict : limitation anti-scraping, détection d’anomalies, quotas par action métier, ou même protection contre des payloads trop lourds. À l’inverse, une API peut renvoyer 503 quand elle est saturée côté serveur (et non quand vous dépassez un débit). Si vous confondez 429 et 503, vous allez appliquer le mauvais remède : sur 503 vous devez réduire la charge globale et vérifier la santé infra ; sur 429 vous devez réguler votre client.

Une méthode de tri efficace en incident consiste à regarder, dans l’ordre :

  • le statut (429 vs 503 vs 401/403) ;
  • la présence de Retry-After (souvent présent sur 429, plus rare sur 503) ;
  • un code applicatif dans le body (rate_limit_exceeded, too_many_requests, throttled, etc.) ;
  • la distribution : est-ce que tous les endpoints tombent (plutôt 503 / incident fournisseur), ou seulement un endpoint (plutôt limite ciblée / quota métier) ?

Pour diagnostiquer proprement, il faut des preuves :

  • Trace ID / Request ID renvoyé par l’API (souvent via X-Request-Id ou Traceparent).
  • Timestamp précis, endpoint, méthode, taille payload.
  • Nombre de tentatives et délais (backoff) appliqués.
  • Headers de rate limiting capturés.

Un format de log “utile” (même en JSON) inclut généralement : provider, endpoint, method, status, attempt, sleep_s, retry_after, ratelimit_remaining, ratelimit_reset, request_id. Sans ces champs, vous vous retrouvez à débattre de causes au lieu de les prouver.

Côté hébergement, si votre boutique part déjà en erreur (timeouts PHP, saturation workers), vous pouvez aussi voir apparaître des erreurs en cascade. Pour recouper, l’article sur le diagnostic des erreurs 503 côté PHP et serveur web est un bon complément : il aide à distinguer “API distante qui bloque” vs “infra boutique qui sature et amplifie le problème” (par exemple si vos workers restent bloqués dans des retries).

Stratégies client : backoff + jitter, lissage via queue, idempotence

La base, c’est exponential backoff avec jitter. Sans jitter, si vous avez 20 workers qui prennent tous un 429, ils vont retenter en même temps et recréer le pic (thundering herd). Stratégie minimale : si Retry-After est présent, privilégier Retry-After ; sinon appliquer un backoff exponentiel capé (ex. 0,5s → 1s → 2s → 4s → max 30s) avec un jitter aléatoire (full jitter : délai = random(0, base)). Important : caper aussi le nombre total de retries, sinon votre module peut transformer un incident de 2 minutes en incident de 2 heures.

Deux règles pratiques, très “terrain”, qui stabilisent beaucoup d’intégrations :

  • Limiter la concurrence avant d’augmenter les délais : si la limite est 10 req/s et votre latence moyenne est 300 ms, vous pouvez estimer un maximum de requêtes “en vol” de l’ordre de 10 * 0,3 ≈ 3. Au-delà, vous créez des rafales (et donc des 429), même si chaque worker “respecte” un sleep local.
  • Ralentir avant le mur : si vous observez RateLimit-Remaining qui tombe régulièrement à 0–2, vous n’êtes pas “presque bon”, vous êtes déjà instable. Ajustez le débit pour rester dans une zone tampon.

Ensuite, il faut arrêter de traiter la synchro comme une boucle foreach qui “tape l’API jusqu’à ce que ça passe”. À partir d’un certain volume, vous devez mettre en file les tâches : RabbitMQ, Redis streams, SQS, ou au minimum une table SQL de jobs (moins recommandé). Ça permet de contrôler le débit, d’appliquer un consumer concurrency (ex. 2 workers), et de reprendre après incident.

Concrètement, une queue vous donne trois leviers qu’un simple cron n’a pas :

  • débit (rate) : X jobs/minute
  • concurrence : N workers maximum
  • stratégie d’échec : retry borné, dead-letter, et “pause” contrôlée

Sur des intégrations qui mixent webhooks et polling (TikTok Shop, marketplaces), la gestion des échecs est un sujet entier : l’article sur webhooks, polling et gestion des échecs illustre bien pourquoi les retries doivent être pensés comme un flux (avec file et états), pas comme un simple try/catch.

Enfin, un point souvent négligé : la répétabilité. Si vous retry une requête POST de création de commande après un 429 ou un timeout, vous pouvez créer des doublons si l’API n’est pas idempotente. Quand le fournisseur le supporte, utilisez une clé d’idempotence (header Idempotency-Key ou champ dédié). Sinon, implémentez un dédoublonnage applicatif (hash du payload + horodatage + état) et loggez explicitement les “replays”.

Dans PrestaShop, ça se traduit souvent par une table “outbox” (pattern transactional outbox) pour garantir qu’un événement de commande n’est envoyé qu’une fois : vous enregistrez l’événement dans la même transaction que la commande, puis un worker l’envoie “plus tard”, avec un statut (pending/sent/failed) et une clé de déduplication. C’est particulièrement important quand un 429 survient après que le fournisseur a traité la requête, mais avant que votre client ait reçu la réponse (cas typique de timeout réseau).

Implémentation PHP/PrestaShop (8.1–9.x) : parse headers et limiter la concurrence

Contexte et prérequis : exemples testés en PHP 8.2/8.3, applicables à des modules PrestaShop 8.1+ et 9.x. Risques : si vous stockez des compteurs en base MySQL, vous allez créer un point chaud et potentiellement dégrader la boutique. Pour un état de rate limit partagé entre workers, Redis est un meilleur choix (latence, atomicité via INCR/EXPIRE). Si vous n’avez pas Redis, passez par une queue externe plutôt que de bricoler un throttle SQL.

Exemple avec Symfony HttpClient (utilisable même dans un module) : l’idée est de wrapper l’appel, lire Retry-After/RateLimit-Reset, et dormir côté worker. Oui, sleep() dans un worker, c’est acceptable si vous contrôlez la concurrence (sinon vous bloquez des process). Pour des délais inférieurs à 1 seconde, préférez usleep().

Ci-dessous, une version légèrement plus robuste sur Retry-After (support “seconds” ou HTTP-date), et qui garde les headers bruts pour les logs :

<?php
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class RateLimitedClient
{
    private $client;

    public function __construct()
    {
        $this->client = HttpClient::create([
            'timeout' => 20,
        ]);
    }

    public function request(string $method, string $url, array $options = [], int $maxRetries = 5): ResponseInterface
    {
        $attempt = 0;

        while (true) {
            $attempt++;
            $response = $this->client->request($method, $url, $options);

            $status = $response->getStatusCode();
            if ($status !== 429) {
                return $response;
            }

            $headers = $response->getHeaders(false);

            if ($attempt > $maxRetries) {
                // Important: remonter une exception explicite, avec headers bruts.
                throw new \RuntimeException('Rate limit hit (429) after retries. Headers: '.json_encode($headers));
            }

            $retryAfter = $headers['retry-after'][0] ?? null;

            // Retry-After peut être un nombre de secondes ou une date HTTP.
            $sleepSeconds = null;

            if ($retryAfter !== null && ctype_digit((string) $retryAfter)) {
                $sleepSeconds = (int) $retryAfter;
            } elseif ($retryAfter !== null) {
                $ts = strtotime((string) $retryAfter);
                if ($ts !== false) {
                    $sleepSeconds = max(1, $ts - time());
                }
            }

            // Fallback: certains fournisseurs donnent un reset en delta (secondes).
            if ($sleepSeconds === null && isset($headers['ratelimit-reset'][0]) && ctype_digit((string) $headers['ratelimit-reset'][0])) {
                $sleepSeconds = max(1, (int) $headers['ratelimit-reset'][0]);
            }

            // Dernier recours: backoff exponentiel + jitter (full jitter)
            if ($sleepSeconds === null) {
                $base = min(30.0, 0.5 * (2 ** ($attempt - 1)));
                $sleepSeconds = max(1, (int) floor(mt_rand(0, (int) ($base * 1000)) / 1000));
            }

            sleep($sleepSeconds);
        }
    }
}

Deux remarques importantes, spécifiques aux modules PrestaShop :

  • Ne “rate-limitez” pas dans un hook synchronisé si le hook est déclenché par une action utilisateur (back-office, front). Déportez dans une queue/worker, sinon vous allez dégrader l’expérience (lenteurs BO, timeouts, commandes qui prennent 30s).
  • Limitez la concurrence globalement, pas “par instance d’objet” : si vous avez 5 processus PHP-FPM qui exécutent la même synchro, chacun respectant son propre backoff, vous pouvez quand même dépasser la limite globale du fournisseur.

Pour des intégrations à forte volumétrie, le vrai gain vient de la limitation de concurrence (ex. 2 workers au lieu de 20) plus que du backoff. Si vous êtes en architecture “cron + batch”, déplacez la logique dans un command Symfony (PrestaShop 9.x) ou un contrôleur CLI dédié et exécutez via supervisor. Et si vous livrez un module, évitez les overrides : gardez un service HTTP isolé, testable, et injecté (voir méthode de cadrage et livraison d’un module sans override).

Quand vous contrôlez l’API : limiter au gateway (Nginx/Envoy) et exposer des signaux exploitables

Si vous exposez une API (ex. une Admin API interne ou un endpoint custom), faire le rate limiting “dans” le contrôleur est trop tard : vous avez déjà consommé des workers PHP, potentiellement touché la DB, et aggravé une saturation. Le bon niveau est Nginx (ou un API gateway) pour rejeter tôt, avec un code 429 et des headers explicites.

Exemple Nginx (token bucket simplifié) :

# 10 requêtes/s par clé (ici IP), burst 20
limit_req_zone $binary_remote_addr zone=api_ratelimit:10m rate=10r/s;

server {
  location /api/ {
    limit_req zone=api_ratelimit burst=20 nodelay;
    limit_req_status 429;

    # Optionnel: exposer Retry-After (Nginx ne le calcule pas nativement)
    add_header Retry-After 1 always;

    proxy_pass http://php_upstream;
  }
}

Deux ajustements “réalistes” quand on passe d’un exemple à une vraie API :

  • La clé n’est pas toujours l’IP. Si vous avez une authentification par token, vous pouvez limiter par consommateur (ex. $http_authorization ou un header X-Api-Key). Sinon, un NAT (réseau d’entreprise, proxy) peut faire tomber plusieurs clients légitimes sous la même limite IP.
  • Le burst doit être choisi en fonction de vos clients : trop bas, vous cassez des usages normaux (ex. pagination rapide en back-office) ; trop haut, vous laissez passer des pics qui saturent l’app avant que la régulation n’agisse.

Si vous avez plusieurs instances (Kubernetes, autoscaling), un rate limit par Nginx est local : 10r/s par pod. Pour un contrôle global, vous passez sur des solutions distribuées (Envoy + service de rate limiting, gateways managés, Redis centralisé). Dans tous les cas, exposez des métriques (Prometheus) : compteur de 429, latence p95/p99, et surtout un indicateur de “requests dropped”. Sans observabilité, vous allez “corriger” à l’aveugle.

Une bonne pratique consiste à corréler :

  • 429 émis par le gateway (trafic rejeté tôt, protection efficace)
  • latence applicative (si elle monte en même temps, vous avez un problème de capacité côté app)
  • taux d’erreur 5xx (si 5xx monte quand 429 monte, votre régulation est probablement trop tardive ou votre burst trop permissif)

Pour structurer un diagnostic performance reproductible (côté boutique et côté intégration), l’approche décrite dans la méthode d’audit performance PrestaShop se transpose bien : preuves (logs/metrics), reproduction, puis correction mesurable.


En pratique, si vous devez rendre une intégration PrestaShop stable face aux API rate limits, retenez trois priorités : (1) lissage via queue + faible concurrence, (2) retry borné et jitter, (3) logs/metrics exploitables (headers bruts, trace id, délais). Le reste (optimisations micro, parse “parfait” des headers) ne compensera jamais une architecture qui envoie 500 requêtes en parallèle parce qu’un hook a été déclenché 500 fois.



À lire aussi