Un utilisateur clique sur un bouton. La requête stagne. Dix secondes de silence. Il clique sur le bouton de secours. Désormais, deux tâches s'exécutent pour une seule et même intention. Vous vous retrouvez avec des effets de bord dupliqués, des doubles facturations et un désordre de données qui vous gâche l'après-midi.
Ce n'est pas un bug frontend. Un bouton désactivé ou un timer de debounce dans React ne vous sauvera pas. La première requête était déjà en cours. Le réseau a simplement « avalé » la réponse. Si votre backend traite chaque requête entrante comme une toute nouvelle instruction, les tentatives de réessai deviennent des risques. Vous devez corriger cela dans la conception de votre API et votre schéma de base de données.
La solution commence par une simple séparation structurelle.
Séparer les tâches (Jobs) des tentatives (Attempts)
Considérez une tâche comme l'enregistrement durable de ce que l'utilisateur souhaite. Elle capture le propriétaire, les paramètres, le fournisseur cible et l'intention exacte. Une tentative est un essai spécifique pour satisfaire cette intention.
Imaginez une imprimerie. Vous remettez un fichier et ils vous donnent le ticket n°45. Ce ticket est la tâche. L'imprimerie essaie l'imprimante à jet d'encre. Elle bourre. C'est la première tentative. Ils déplacent le fichier vers l'imprimante laser. C'est la deuxième tentative. Tout au long du processus, le ticket n°45 ne change jamais. Si l'imprimerie émettait un nouveau ticket pour chaque imprimante essayée, vous paieriez trois fois et recevriez trois copies indésirables.
Votre base de données devrait refléter cela. Une table contient les tâches. Une autre table contient les tentatives. La ligne de la tâche reste constante tandis que les tentatives s'accumulent en dessous.
Cette séparation vous donne le contrôle. Elle vous offre également un endroit pour attacher une clé d'idempotence qui survit aux micro-coupures réseau.
Exiger une clé d'idempotence pour chaque tâche
Chaque requête POST qui crée une tâche doit porter une clé d'idempotence unique. Cette clé appartient à l'utilisateur, pas à la session. Combinez l'ID du propriétaire et la clé, puis imposez une contrainte d'unicité en base de données sur ces deux colonnes.
Pourquoi une contrainte de base de données ? Parce que vérifier l'existence dans le code applicatif avant l'insertion est une condition de concurrence (race condition) qui ne demande qu'à se produire. Deux requêtes identiques peuvent s'engouffrer dans le même intervalle d'une microseconde. Laissez la base de données agir comme arbitre. Si un utilisateur envoie deux fois le même ID de propriétaire et la même clé, la seconde requête sera interceptée par la violation d'unicité et vous renverrez la tâche existante. Les deux requêtes recevront le même ID de tâche. Aucun travail en double ne commencera.
Soyez strict sur la portée. Si quelqu'un réutilise la clé mais modifie la charge utile (payload) d'entrée, renvoyez un conflit. La clé d'idempotence doit être liée à une intention exacte, pas seulement à l'utilisateur. Une même clé avec une entrée différente signifie que le client est confus, et votre système doit le rejeter plutôt que de deviner.
Protéger les transitions d'état
Une tentative est une transition d'état, pas une nouvelle tâche. Votre API doit refuser de générer une nouvelle tentative si une tentative précédente est toujours bloquée dans un état de démarrage ou un état inconnu.
Les délais d'expiration (timeouts) en sont la cause. Lorsqu'une requête vers un fournisseur expire, le client voit un échec, mais le processus côté serveur peut encore être actif. Le cluster de GPU peut encore être en train de traiter votre requête d'inférence. Le conteneur peut encore être en train d'écrire dans le stockage blob. Si vous marquez la tentative ayant expiré comme échouée et que vous lancez immédiatement une seconde tentative, vous jouez avec le risque d'effets de bord dupliqués.
Traitez un timeout comme un état inconnu, et non comme un échec. Bloquez les nouvelles tentatives jusqu'à ce que la précédente atteigne un état terminal ou soit explicitement annulée par un processus hors bande (out-of-band). Cette pause est inconfortable. Elle force l'utilisateur à attendre. Elle empêche également le chaos de deux travailleurs mutant les mêmes ressources en aval.
Résoudre les conflits avec le Compare-and-Swap
Les problèmes les plus difficiles surviennent lorsque plusieurs tentatives se terminent. Peut-être que votre système a lancé la première tentative vers le fournisseur principal. Après dix secondes de silence, il a lancé la deuxième tentative vers le système de secours. Maintenant, les deux tentatives sont terminées. Vous ne pouvez pas laisser les deux écrire leurs résultats dans la même ligne de tâche.
Utilisez la logique de « compare-and-swap ». Ajoutez un numéro de version à la ligne de la tâche. Lorsqu'une tentative se termine, elle exécute une mise à jour avec des conditions :
- La version actuelle doit correspondre à ce que la tentative a lu au début.
- Aucune autre tentative ne doit déjà avoir revendiqué l'emplacement du résultat.
- Si les deux conditions sont remplies, écrivez le résultat et incrémentez la version.
En termes SQL, cela ressemble à une instruction UPDATE avec un WHERE id = $1 AND version = $2 AND completed_by IS NULL. Si la mise à jour ne retourne aucune ligne, c'est qu'une autre tentative a déjà gagné. L'arrivée tardive doit être ignorée. Ignorez son résultat. Ne fusionnez pas. N'ajoutez pas. Écartez le travail. Un résultat tardif qui écrase un gagnant précédent est une corruption de données, et la seule mesure sûre est de le rejeter.
This handles the reverse-order finish cleanly. Attempt A leaves first but returns after thirty seconds. Attempt B leaves second but returns after five seconds. Attempt B wins the compare-and-swap. Attempt A’s update touches zero rows. Your system logs the race, ignores the stale payload, and moves on.
Test the Breakpoints
You will not catch these bugs in happy-path testing. Your suite needs to target the fractures.
- Simulate a double-click. Two simultaneous POST requests with the same idempotency key must return identical job IDs.
- Send the same key with mismatched input. Expect a conflict response. The system must not silently return the existing job if the parameters differ.
- Provoke a timeout. Verify the job lands in an unknown state, not a failed state, and that the system blocks further attempts until the ambiguity clears.
- Force two attempts to finish in reverse order. Confirm that the second one to return loses, even if the first one to leave was the official primary provider.
These tests are not edge-case luxuries. They are the contract your API makes with the rest of the system.
Validate Provider Intent Before You Fail Over
If you run a multi-provider setup, you might be tempted to treat different AI models as interchangeable slots. They share the same code path, the same HTTP client, and the same JSON schema. That does not mean they behave the same.
One model might hallucinate a top-level key. Another might ignore your system prompt formatting. Schema validation catches syntax errors, but it will pass a response that your business logic cannot interpret. A provider might return valid JSON that simply does the wrong thing with your prompt template.
Run provider-specific tests before you allow automatic model switching. Confirm that the fallback model actually respects your output structure at low temperature. Verify that your prompt renders correctly through that provider’s tokenizer. Test the full round trip with real inputs. Automatic failover is only safe when you have proven that the fallback shares the same operational contract.
Keep One Job Per Intent
Fallback paths are good. Uncontrolled fallback multiplication is a bug. Every layer of your stack needs to evaluate whether it has already seen the exact task. The load balancer, the API handler, the database, and the worker must all respect the same identity.
Build your system so that retries and fallbacks surface as new attempts under one stable job. Lock the job down with a database-backed idempotency key. Guard the transitions. Race the attempts. Let exactly one win. That is how you keep a single user click from turning into a weekend of data cleanup.
