Aller au contenu
gaetancottrez.dev

Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données

Publié: at 08:00 | (14 min de lecture)
Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données

Table des matières

Ouvrir table des matières

Le jour où « observer » ne suffit plus

Vous avez des dashboards. Des compteurs Prometheus partout, des jolis graphiques Grafana, des alertes qui clignotent. Vous savez exactement combien de requêtes votre plateforme a émises dans l’heure. Vous êtes un développeur sérieux, quoi.

Et puis un jour, la règle du jeu change. La limite que vous vous contentiez de regarder devient une limite à respecter. Dans mon cas, c’était un système tiers qui impose un plafond d’émission : pas plus d’un certain nombre de flux par heure, pour toute la plateforme. Disons 1 000 par heure, pour fixer les idées.

Premier réflexe, tout naturel : « facile, j’ai déjà le compteur, je n’ai qu’à le lire avant d’émettre, et si on est au plafond, j’attends ».

Alors, spoiler : non. Ce réflexe-là, c’est le piège. Et c’est tout le sujet de cet article : un compteur permet d’observer une limite, jamais de la faire respecter. Ce sont deux métiers différents. Le premier s’appelle la métrologie. Le second s’appelle l’arbitrage. Et confondre les deux, c’est s’exposer à dépasser le quota précisément le jour où ça compte.

Voyons pourquoi — et surtout, comment s’en sortir avec une élégance surprenante : une seule ligne de base de données et un seul ordre SQL.

Le décor : une limite, beaucoup de processus

Posons le problème proprement. Une plateforme backend moderne, ce n’est jamais un programme. Chez moi, c’était :

Le twist, c’est que la limite est globale : vis-à-vis du tiers, toute la plateforme compte comme un seul émetteur. Peu importe que l’émission parte du processus API, du worker n°3 ou d’un replica fraîchement démarré : c’est le même budget de 1 000 pour tout le monde.

La question centrale devient donc : comment tenir un quota partagé entre des processus qui ne se connaissent pas ?

Pourquoi vos compteurs ne peuvent pas dire non

Reprenons le réflexe naïf : lire le compteur, comparer au plafond, émettre si ça passe. Trois problèmes, du plus bénin au plus tordu.

Un compteur de métriques est local à son processus. Votre compteur Prometheus vit dans la mémoire du processus qui l’incrémente. Le worker n°1 compte ses émissions, le worker n°2 compte les siennes, et personne n’a la somme en temps réel. La somme, elle, n’existe que côté serveur de métriques, après scrape — c’est-à-dire avec plusieurs secondes de retard, dans un système conçu pour les tableaux de bord, pas pour prendre des décisions.

La lecture-puis-écriture est une course. Même en imaginant un compteur parfaitement partagé, la séquence « je lis 999, je vérifie que 999 < 1 000, j’émets, j’incrémente » n’est pas atomique. Deux processus peuvent lire 999 en même temps, conclure chacun qu’il reste une place, et émettre tous les deux. Résultat : 1 001. Le quota est dépassé, et aucun des deux n’a rien fait de « faux » localement.

Deux processus, un compteur : la course

Un compteur, ça ne refuse rien. C’est le point philosophique, mais c’est le plus important. Un compteur enregistre ce qui s’est passé. Un quota décide de ce qui a le droit de se passer. L’un regarde dans le rétroviseur, l’autre tient le volant. Vous pouvez ajouter toutes les alertes du monde sur votre dashboard : le jour où trois workers émettent en rafale, l’alerte vous préviendra… que c’est déjà trop tard.

Bref, il nous faut un arbitre : un point unique, partagé par tous les processus, capable de répondre « accordé » ou « refusé » de façon atomique.

Redis ? Un verrou ? Pourquoi j’ai choisi la base

Bien entendu, j’aurais pu partir sur les solutions à la mode.

Redis avec INCR, c’est le grand classique du rate limiting. Ça marche très bien. Mais ça ajoute une dépendance d’infrastructure complète — déploiement, supervision, sauvegarde, panne — pour un besoin que ma base de données couvre déjà. Introduire un composant stateful entier pour tenir un entier par heure, j’avoue que ça me chagrinait. D’ailleurs, vous me connaissez : je n’aime pas empiler des technos comme un mouton, surtout quand l’outil déjà en place sait faire le travail.

Un verrou applicatif (mutex) ? Il ne traverse pas les frontières de processus. Éliminé d’office — c’est justement notre problème de départ.

Une élection de leader qui centraliserait toutes les émissions ? Complexité disproportionnée, nouveau point de contention, et tout le monde doit désormais causer au leader. Non merci.

Restait la solution la plus ennuyeuse du monde, donc la meilleure : PostgreSQL. Tous mes processus y sont déjà connectés. Une base relationnelle sait faire des écritures atomiques depuis cinquante ans, c’est littéralement son métier. Et vous allez voir que le quota complet tient dans une table minuscule et un seul ordre SQL.

On veut du code !

Voici la table. Une ligne par heure calendaire UTC, et c’est tout :

CREATE TABLE quota_window (
  window_start TIMESTAMPTZ NOT NULL UNIQUE,
  count        INTEGER     NOT NULL DEFAULT 0
);

Et voici l’arbitre. Toute la magie est dans cet unique ordre :

INSERT INTO quota_window (window_start, count)
VALUES ($1, 1)
ON CONFLICT (window_start)
DO UPDATE SET count = quota_window.count + 1
WHERE quota_window.count < $2   -- $2 = le plafond
RETURNING count;

Décortiquons cette petite mécanique, parce qu’elle est plus maligne qu’elle en a l’air :

Le point capital : tout ça se joue en une seule instruction. Pas de « je lis puis j’écris », pas de fenêtre pour qu’un autre processus se glisse entre les deux. C’est la base qui arbitre, atomiquement, pour tous les processus connectés. La propriété exacte que les compteurs locaux ne pouvaient pas nous donner.

Côté TypeScript, l’appelant reste d’une sobriété totale :

type SlotDecision = { granted: true; count: number } | { granted: false };

async function reserveSlot(now: Date, ceiling: number): Promise<SlotDecision> {
  // Garde de mauvaise configuration : un plafond < 1 refuse tout.
  // Sans elle, l'INSERT initial accorderait toujours count = 1.
  if (ceiling < 1) return { granted: false };

  const rows = await sql`
    INSERT INTO quota_window (window_start, count)
    VALUES (${startOfUtcHour(now)}, 1)
    ON CONFLICT (window_start)
    DO UPDATE SET count = quota_window.count + 1
    WHERE quota_window.count < ${ceiling}
    RETURNING count
  `;

  return rows.length > 0
    ? { granted: true, count: rows[0].count }
    : { granted: false };
}

Deux détails qui n’ont l’air de rien et qui portent tout le système.

La fenêtre fixe est une feature, pas une paresse. J’ai choisi une grille d’heures calendaires (14 h 00 → 15 h 00) plutôt qu’une fenêtre glissante (« les 60 dernières minutes »). Pourquoi ? Parce que c’est précisément la grille fixe qui permet l’incrément conditionnel sur une seule ligne. Une fenêtre glissante exigerait de compter des événements horodatés — retour à la lecture-puis-écriture, retour à la course. La fenêtre fixe est une approximation assumée, et elle achète l’atomicité.

Une seule fonction d’arrondi, partagée par tous. startOfUtcHour vit à un seul endroit du code, et tous les écrivains passent par elle. Si deux morceaux de code calculent l’heure plancher chacun dans leur coin, ils finiront par diverger (fuseau, arrondi, bord de minuit…) et écriront dans des fenêtres désalignées. Une fonction, un seul alignement possible.

Un refus n’est pas une panne

Voilà pour l’arbitre. Mais la question suivante est tout aussi importante : que fait-on d’un refus ?

Le réflexe de développeur, c’est de lever une exception. throw new QuotaExceededError(), propre et net. Eh bien c’est exactement ce qu’il ne faut PAS faire — et c’est peut-être la leçon la plus contre-intuitive de toute cette histoire.

Rappelez-vous le décor : les émissions partent de workers pilotés par une file de jobs, avec des politiques de retry parfois très agressives — certains de mes jobs retentent des dizaines de fois, d’autres sans limite. Si la saturation du quota se manifeste comme un échec, chaque refus déclenche un retry. Cent jobs en attente × des retries en boucle sur un quota plein = une tempête de retentatives qui martèle la base, sature les workers et transforme une limitation parfaitement normale en incident de production. Le quota qui devait protéger le système tiers finit par étrangler le vôtre. Un comble, me direz-vous.

La solution : le refus est une valeur de retour, une issue métier parmi d’autres. Chez moi, elle s’appelle « ajourné » (deferred) :

const decision = await reserveSlot(now, ceiling);

if (!decision.granted) {
  // Pas d'exception : le travail reste en attente, tel quel.
  metrics.increment("emission_deferred_total");
  return { outcome: "deferred" };
}

await emitToThirdParty(work);

Le job se termine en succès en ayant simplement constaté « pas de place cette heure-ci ». Le travail reste dans son état d’attente, le prochain passage du moteur le retentera naturellement. Aucun retry ne s’emballe, rien ne s’empile : la pression retombe toute seule.

Et le tour est joué ? Presque. Un dernier raffinement d’ordonnancement : je réserve le slot avant d’allouer quoi que ce soit de définitif au travail (numéro de séquence, nom de fichier, identifiant officiel…). Un travail ajourné ne doit rien consommer d’autre que du temps. La contrepartie, assumée : si le processus crashe entre la réservation et l’émission, le slot est brûlé pour rien. On sur-compte, jamais l’inverse — face à un plafond imposé par un tiers, c’est exactement le sens d’erreur qu’on veut.

Qui passe en premier quand on rationne ?

Dès qu’un quota mord vraiment, une question de fond apparaît : quand il n’y a pas de place pour tout le monde, qui passe en premier ?

Si vous ne répondez pas explicitement, votre système répondra pour vous — en général par « premier arrivé, premier servi », c’est-à-dire au hasard des ticks. Et le hasard fait mal les choses : dans mon contexte, certains travaux portaient une échéance réglementaire, une date limite de transmission avec de vraies conséquences en cas de retard.

D’où deux règles, simples à énoncer, structurantes à implémenter.

Règle 1 : rationner par échéance. Quand le moteur cherche les travaux à émettre, il les trie par date limite croissante (les travaux sans échéance passent en dernier). Les slots rares vont d’abord à ceux dont l’horloge tourne. Un banal ORDER BY deadline ASC NULLS LAST, et une politique de rationnement digne de ce nom.

Règle 2 : une obligation outrepasse une optimisation. C’est ma règle préférée, parce qu’elle demande de lire les textes plutôt que de les subir. Mon échéance de transmission était une obligation, sanctionnée en cas de manquement. Mon plafond horaire, lui, était déclaratif — une consigne de bon voisinage, sans sanction attachée. Ces deux contraintes n’ont pas le même poids juridique, elles ne doivent donc pas avoir le même poids dans le code : un travail dont l’échéance se ferme outrepasse le plafond. Il est émis, compté, signalé — mais jamais retenu.

Et le détail d’implémentation qui fait la différence : ce chemin de dérogation n’appelle jamais reserveSlot. Pas « il l’appelle et ignore le refus » — il ne l’appelle pas. Ainsi, même si le registre de quota est cassé, indisponible, corrompu, il est physiquement incapable de retenir un travail urgent. C’est du fail-safe par construction : la sûreté ne dépend pas d’un if bien écrit, elle découle de ce que le code ne peut structurellement pas faire.

Réservation atomique et chemin de dérogation

Dans le même esprit : certains flux de ma plateforme (acquittements, chemins à retry illimité ou au contraire sans aucun retry) ne sont jamais bridés, quels que soient les réglages. Eux sont simplement comptés, après coup, en fire-and-forget — la comptabilité s’exécute après l’émission et un incident de métrologie ne doit jamais faire échouer une émission déjà partie. La boucle est bouclée : le compteur retrouve son vrai métier, observer.

Livrer éteint, observer d’abord

Dernière leçon, et pas la moindre : je n’ai pas activé le blocage le jour de la mise en production. Tout est parti éteint.

Concrètement : le plafond vit dans une variable d’environnement (QUOTA_HOURLY_CEILING), et l’application effective du quota est derrière des drapeaux, réglables par type de flux, tous à false au départ. Drapeau éteint, le moteur fait tout le trajet — il tente la réservation, il compte, il trace — mais il accorde toujours.

Qu’est-ce que ça m’achète ? Un mode observation : pendant des semaines, les métriques m’ont montré ce que l’application du quota aurait fait — combien d’ajournements, à quelles heures, sur quels flux — sans bloquer une seule émission réelle. Quand les chiffres ont confirmé que le comportement était sain, l’activation est devenue une décision informée et réversible : un drapeau à retourner, un drapeau à re-retourner si ça se passe mal.

Autant dire que dormir tranquille la nuit d’une mise en prod, ça n’a pas de prix. Et ce pattern-là — livrer le mécanisme éteint, l’observer à blanc, l’activer ensuite — vaut bien au-delà des quotas.

Ajoutez-y les deux gardes de bord vues dans le code : un plafond mal configuré (< 1) refuse tout plutôt que de tout laisser passer, et un type de flux inconnu n’est jamais bridé. À chaque bord du système, demandez-vous simplement : « dans quel sens est-ce que je préfère me tromper ? » — puis câblez ce sens-là.

Bonus : une fenêtre longue est un budget, pas une cadence

Un dernier enseignement de la même famille, appris — je vous le confesse — à la dure, sur un incident.

Beaucoup de limiteurs de débit « lissent » une fenêtre : un plafond de N par minute devient « un appel toutes les 60/N secondes ». Sur une fenêtre courte, c’est la bonne lecture : ça amortit les rafales. Mais appliquez le même lissage à une fenêtre horaire et le plafond « N par heure » devient « un appel toutes les 3 600/N secondes » — potentiellement de longues secondes entre deux appels. Or une opération métier réelle enchaîne souvent plusieurs appels d’affilée (créer, téléverser, activer…). Résultat du lissage : le deuxième appel de chaque opération attend une éternité et finit refusé. 100 % d’échecs, alors qu’on est très loin du plafond horaire.

La doctrine que j’en ai tirée tient en une phrase : une fenêtre courte est un gouverneur de cadence, une fenêtre longue est un budget à dépenser. Le budget horaire doit se consommer librement — y compris en rafale après une heure d’inactivité — et c’est la fenêtre courte, elle, qui lisse l’instantané. Deux fenêtres, deux rôles ; si vous n’en gardez qu’une, vous perdez l’un des deux.

Vous remarquerez que c’est le même malentendu qu’au début de l’article, sous un autre déguisement : confondre deux notions voisines — observer/arbitrer, cadence/budget — et laisser l’outil décider à votre place. Les quotas sont un petit domaine, mais il est peuplé de faux jumeaux.

Épilogue

Récapitulons le voyage : un compteur observe, un arbitre décide — et pour arbitrer entre des processus qui s’ignorent, il faut un point de vérité partagé et atomique. Une ligne PostgreSQL et un upsert conditionnel font l’affaire, sans nouvelle brique d’infrastructure. Le refus est une valeur, pas une exception ; on rationne par échéance ; une obligation outrepasse une optimisation ; et on livre le tout éteint, observable, activable d’un drapeau.

Rien de bien sorcier au final, mais chaque décision découle d’une question qu’il fallait se poser avant d’écrire le code. C’est souvent ça, le vrai travail.

Si le sujet vous parle, j’ai d’autres articles dans la même veine : choisir le bon type de base de données (et pourquoi la plus ennuyeuse est souvent la bonne), l’architecture hexagonale qui m’a permis de loger cet arbitre à la frontière propre entre domaine et infrastructure, et les tests unitaires — parce qu’une garde comme « plafond < 1 refuse tout » sans test, c’est une garde qui finira par sauter.

Et vous, comment tenez-vous vos quotas partagés ? Redis, base, autre chose ? Racontez-moi ça en commentaire, je me ferai une joie de vous répondre — surtout si vous n’êtes pas d’accord.

Vous pourriez aussi aimer

Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard

Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard

Et si l'état de votre objet ne vous appartenait pas ? Modéliser une machine à états réglementaire

Et si l'état de votre objet ne vous appartenait pas ? Modéliser une machine à états réglementaire

Article précédent
Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard
Article suivant
Et si l'état de votre objet ne vous appartenait pas ? Modéliser une machine à états réglementaire