Un refus qui n'empêche pas : l'ordre des vérifications dans une API
Du 26 juillet au 13 septembre, la route d'indexation de Heurix a vérifié le plafond de produits après avoir écrit le lot. Sur un essai plafonné à 2 000 produits, deux lots de 2 500 étaient refusés en 429 tous les deux, et le catalogue en comptait 5 000. Le correctif tient en quelques lignes. Ce qui l'entoure est plus utile : ce que coûte chaque ordre, ce que le compte exige pour être juste, et pourquoi il a fallu corriger un connecteur avant le moteur.
Ce que faisait la route
POST /v1/index/{catalog}/items écrit un lot de produits. Chaque plan plafonne le nombre de produits d'un catalogue, avec une marge de 10 % : 2 200 pour l'essai. Le contrôle existait. Il était placé après l'écriture :
# avant le correctif
count = store.upsert_products(catalog, items) # le lot est écrit
usage.check_product_limit(key, len(catalog.products)) # puis compté, et refusé
Rejoué sur le commit qui précède le correctif, avec une clé d'essai :
| Envoi | Réponses | Produits indexés ensuite |
|---|---|---|
| Deux lots de 2 500 | 429, 429 | 5 000 |
| Cinquante lots de 100 | 22 fois 200, puis 28 fois 429 | 5 000 |
Le corps de la réponse était exact :
{
"detail": "Plafond de 2000 produits du plan 'trial' dépassé (2500 produits, marge de 10% également dépassée). Passez à un plan supérieur pour indexer davantage.",
"plan": "trial",
"limit_type": "products",
"upgrade_url": "https://heurix.fr/pricing.html"
}
Le plafond était bien dépassé. Mais un code 4xx se lit « rien n'a eu lieu », et ici tout avait eu lieu.
Ce qu'un connecteur en affichait
Le connecteur WooCommerce indexe par lots de 100, c'est la seconde ligne du tableau. Pour 5 000 produits sur un essai, son écran de réglages affichait « 5000 / 5000 produits traités, 2800 échec(s) ». Les 5 000 produits étaient pourtant dans le catalogue. Le marchand qui lisait cet écran cherchait 2 800 produits manquants qui ne manquaient pas.
Après le correctif, le même parcours rend le même écran. Cette fois, il est exact : 2 200 produits indexés, 2 800 refusés.
Pourquoi il a duré
Le contrôle après écriture figure dans le premier commit du dépôt du moteur, le 26 juillet. Il a été corrigé le 13 septembre. Deux raisons l'expliquent, et aucune n'est une absence de tests.
Les tests lisaient la réponse, pas le catalogue. Les deux tests de la route envoyaient un lot au-dessus du plafond, puis vérifiaient le statut 429 et limit_type. L'un vérifiait aussi que la recherche fonctionnait encore après le refus. Aucun ne recomptait les produits. Un test qui s'arrête au statut valide ce que l'API annonce, pas ce qu'elle a fait.
Une fois mesuré, le défaut a été contourné avant d'être corrigé. Il a été mesuré le 7 septembre, sur un banc qui monte l'API. Dans les six jours qui ont suivi, deux connecteurs l'ont intégré comme un fait. L'importeur Shopify enregistrait les empreintes d'un lot refusé, parce que le lot était écrit. Le connecteur WooCommerce ne s'arrêtait pas sur un 429, parce que le moteur avait déjà écrit le lot. Les deux commentaires du code étaient vrais. Ensemble, ils faisaient d'un défaut du moteur un contrat pour ses appelants, et c'est ce contrat qui a rendu le correctif délicat.
Ce que coûte chaque ordre
Vérifier avant l'écriture demande de savoir combien de produits le catalogue aurait après le lot, sans l'écrire. C'est une opération sur le lot : l'ensemble de ses identifiants, moins ceux que le catalogue connaît déjà.
# après le correctif, sous le verrou d'écriture
nouveaux = {cle_produit(p) for p in items} - catalog.products.keys()
if nouveaux:
usage.check_product_limit(key, len(catalog.products) + len(nouveaux)) # 429, rien n'est écrit
count = store.upsert_products(catalog, items)
L'écriture, elle, travaille sur une copie des index du catalogue puis bascule, pour qu'une recherche concurrente ne lise jamais un index à moitié écrit. Son coût suit la taille du catalogue, pas celle du lot. Mesures en local, pack outillage, médianes :
| Lot | Compte préalable | Écriture, catalogue de 27 500 | Écriture, catalogue de 50 000 |
|---|---|---|---|
| 1 produit | 0,0003 ms | 146 ms | 258 ms |
| 100 produits | 0,010 ms | 153 ms | 268 ms |
| 2 500 produits | 0,30 ms | 256 ms | 382 ms |
Le compte ne dépend pas du catalogue : il donne les mêmes valeurs à 27 500 et à 50 000. Mesuré juste après une écriture plutôt qu'isolé, il vaut 0,54 ms pour 2 500 produits sur 27 500, et 0,58 ms sur 50 000. La lecture du plan de la clé, environ 0,6 ms, était payée dans les deux ordres.
Les écritures varient d'une lecture à l'autre : de 136 à 168 ms pour un produit sur 27 500, de 176 à 363 ms pour 2 500. Le rapport, lui, ne bouge pas : le compte coûte moins d'un demi pour cent de l'écriture la plus rapide mesurée. Vérifier après, c'était payer l'écriture entière d'un lot qu'on allait refuser, et laisser l'état faux.
Avant ou après : la règle
L'ordre qui compte n'est pas « contrôle, puis écriture ». C'est « contrôle, puis validation ».
- Vérifier avant quand la grandeur contrôlée se déduit de la requête et de l'état courant, sans faire le travail : un nombre de produits, une taille annoncée, un solde. Le coût suit la requête.
- Vérifier après seulement quand la grandeur n'existe qu'une fois le travail fait, par exemple une taille après compression. Et alors sur la copie, avant la bascule ou le commit, jamais après. Le refus garde son sens : rien n'a été écrit.
- Si l'on ne peut refuser qu'après avoir validé, ne pas répondre par une erreur. Répondre ce qui a eu lieu, dans un succès qui porte l'information, et laisser l'appelant décider.
Un code 4xx promet un état inchangé. Les appelants construisent dessus : ils rejouent, ils sautent, ils comptent.
Ce que l'exactitude du compte exige
Un contrôle préalable qui compte mal refuse à tort. C'est pire qu'un refus tardif : un lot légitime n'est pas indexé. Quatre conditions, chacune couverte par un test.
Compter ce que le lot ajoute, pas sa longueur. Prenons un lot de 300 éléments sur un catalogue de 2 100 : 100 identifiants déjà présents, 99 nouveaux envoyés deux fois, puis 7 et "7", qui désignent le même produit. Il ajoute 100 produits et amène le catalogue à 2 200, le plafond compris. Compter sa longueur l'aurait refusé, à 2 400.
Compter avec la clé du stockage. Le moteur range un produit sous str(id). Cette règle était écrite en six endroits. Elle n'en a plus qu'un, que le compte et l'écriture appellent tous les deux.
Compter sous le verrou d'écriture. Deux lots de 300 arrivent sur un catalogue à 1 800. Chacun tient seul (2 100), les deux ensemble non (2 400).
| Où se fait le compte | Essais | Résultat |
|---|---|---|
| Sous le verrou d'écriture | 30 | 30 fois un 200 et un 429 : 2 100 produits |
| Hors du verrou, départs simultanés | 30 | 27 fois deux 200 : 2 400 produits. 3 fois 2 100. |
Hors du verrou, les deux requêtes lisent 1 800 avant que l'une n'écrive. Aucun ralentissement artificiel n'est nécessaire pour le produire. Un contrôle avant écriture n'est exact que s'il forme un tout avec l'écriture.
Décider ce qu'on ne contrôle pas. Un lot qui n'ajoute aucun produit n'est pas vérifié. Prenons un marchand redescendu sur un plan inférieur : il garde ses produits au-dessus du nouveau plafond. Avant le correctif, ses mises à jour de stock étaient écrites, puis refusées. Vérifier avant sans cette exception aurait figé ses prix et ses stocks. Un lot qui ajoute ne serait-ce qu'un produit reste refusé en entier, mises à jour comprises.
Le correctif qui rend un défaut muet
Une fois le moteur corrigé, un 429 veut dire « rien écrit ». Or l'importeur Shopify enregistrait, après un refus, l'empreinte de chaque produit du lot, parce que le moteur l'avait écrit. L'empreinte sert à ne pas renvoyer un produit inchangé.
Déployé seul, le correctif du moteur aurait produit ceci :
| Moteur d'avant | Moteur corrigé, connecteur inchangé | |
|---|---|---|
| Lot refusé | écrit | non écrit |
| Empreintes du lot | posées, justes | posées, fausses |
| Import suivant | produits sautés, présents | produits sautés, absents |
| Message | aucun, et aucun n'est dû | aucun, et des produits manquent |
Le contournement était juste tant que le défaut existait. Le correctif seul le rendait faux, et sans bruit. Côté WooCommerce, le défaut se voyait : des échecs affichés sur des produits présents. Côté Shopify, le remède l'aurait rendu muet : des produits absents, qu'aucun écran ne signale et que chaque import suivant saute comme inchangés. Un défaut visible se signale. Un défaut muet ne se trouve que si quelqu'un cherche un produit précis et ne le trouve pas.
Le connecteur a donc été corrigé en premier : il ne pose plus aucune empreinte sur un lot refusé. Ce changement est juste sous les deux versions du moteur. Au pire, l'import suivant renvoie un lot déjà écrit, et l'indexation ne décompte pas le quota de requêtes. Le correctif du moteur, déjà écrit, a attendu.
Repérer ce cas avant de déployer
Rien dans ce relevé n'est propre à Heurix. Il vaut pour tout changement qui modifie ce qu'un statut veut dire :
- Lister tous les appelants de la route, dans tous les dépôts, pas seulement celui du ticket. Chez nous, trois appellent la route d'indexation : le connecteur WooCommerce, l'app Shopify et l'import CSV de la console. Le module PrestaShop et le serveur MCP ne la touchent pas.
- Lire, dans chaque appelant, les branches qui traitent ce statut, et relever ce qu'elles écrivent : empreintes, curseurs, dates de dernière synchronisation, compteurs, files de rejeu. Un message à l'écran s'efface. Une écriture décide des appels suivants.
- Demander, pour chaque écriture, quel comportement du serveur elle suppose. Si elle suppose le défaut, le correctif la rend fausse sans produire d'erreur.
- Classer le sens du changement. Un faux échec qui devient un vrai échec est une amélioration. Un faux échec qui devient un faux succès est le cas à bloquer : plus rien ne le signalera.
- Rendre l'appelant juste sous les deux comportements, le déployer, vérifier qu'il tourne, puis déployer le serveur. Côté appelant, écrire le test du nouveau comportement du serveur : il doit échouer tant que l'appelant n'est pas corrigé.
Le connecteur WooCommerce et l'import CSV sont passés par le même relevé. Ils n'écrivaient qu'un compte d'échecs affiché. Leurs écrans sont devenus exacts sans qu'on y touche.
Pour la suite
Un refus se teste sur l'état, pas sur le statut : après chaque 4xx, relire ce qui est stocké. Le contrôle se place avant la validation, compté comme l'écriture range et sous le même verrou qu'elle. Et un correctif qui change le sens d'un statut se déploie après ses appelants, jamais avant.