Les providers LLM imposent deux limites distinctes : requêtes/minute (RPM) et tokens/minute (TPM). Dépasser l'une ou l'autre = 429. En charge, il faut lisser côté client avant de prendre le mur.

Token bucket, la primitive

Un seau de jetons se remplit à débit constant, chaque requête en consomme. Plus de jeton = on attend. Implémentation atomique via Redis :

function acquire(Redis $r, string $key, int $rate, int $burst): bool {
    // rate = jetons/seconde, burst = capacité max
    $now = microtime(true);
    [$tokens, $ts] = $r->hMGet($key, ['t', 'ts']) ?: [$burst, $now];
    $tokens = min($burst, (float)$tokens + ($now - (float)$ts) * $rate);
    if ($tokens < 1) return false;          // pas de jeton -> refus/attente
    $r->hMSet($key, ['t' => $tokens - 1, 'ts' => $now]);
    return true;
}

Gérer le 429 quand même

Le rate limiting local réduit les 429 mais ne les élimine pas (autres instances, estimation de tokens imparfaite). Toujours respecter le header retry-after du provider :

for ($try = 0; $try < 5; $try++) {
    $res = callApi($payload);
    if ($res->status !== 429) return $res;
    $wait = (int)($res->header('retry-after') ?? (2 ** $try));
    sleep($wait); // + jitter aléatoire en prod
}
throw new RuntimeException('Rate limit: abandon après 5 essais');

Estimer les tokens AVANT l'appel

La limite TPM se gère mal en réactif. Estimer le coût en tokens d'une requête (≈ longueur / 4 pour du français) et débiter le bucket en conséquence évite de découvrir le dépassement après coup. C'est approximatif mais suffisant pour rester loin du plafond.

Règle

Un bucket par couple (clé API, type de limite). Lisser localement, respecter retry-after, et garder une marge de 20 % sous le quota officiel : les limites annoncées ne sont pas toujours celles appliquées.