Een gebruiker klikt op een knop. Het verzoek loopt vast. Tien seconden stilte. Ze klikken op de fallback-knop. Nu draaien er twee jobs voor één enkele intentie. Je eindigt met dubbele side effects, dubbele afschrijvingen en een data-chaos die je hele middag opslokt.

Dit is geen frontend-bug. Een uitgeschakelde knop of een debounce-timer in React gaat je niet redden. Het eerste verzoek was al onderweg. Het netwerk heeft simpelweg de respons 'opgeslokt'. Als je backend elk inkomend verzoek behandelt als een volledig nieuwe instructie, worden retries een risico. Je moet dit oplossen in je API-ontwerp en je databaseschema.

De oplossing begint met een eenvoudige structurele splitsing.

Splits Jobs van Attempts

Beschouw een job als het duurzame verslag van wat de gebruiker wil. Het legt de eigenaar, de parameters, de doelprovider en de exacte intentie vast. Een attempt is een specifieke poging om die intentie te vervullen.

Stel je een drukkerij voor. Je overhandigt een bestand en ze geven je bonnetje #45. Dat bonnetje is de job. De drukkerij probeert de inkjetprinter. Deze loopt vast. Dat is de eerste attempt. Ze verplaatsen het bestand naar de laserprinter. Dat is de tweede attempt. Gedurende het hele proces verandert bonnetje #45 nooit. Als de drukkerij voor elke printer die ze probeerden een nieuw bonnetje zou uitgeven, zou je drie keer betalen en drie ongewenste kopieën ontvangen.

Je database zou dit moeten weerspiegelen. Eén tabel bevat jobs. Een andere tabel bevat attempts. De job-rij blijft constant, terwijl de attempts eronder worden verzameld.

Deze scheiding geeft je controle. Het biedt je ook een plek om een idempotency key te koppelen die netwerkstoringen overleeft.

Vereis een Idempotency Key voor elke Job

Elk POST-verzoek dat een job aanmaakt, moet een unieke idempotency key bevatten. Deze key hoort bij de gebruiker, niet bij de sessie. Combineer de owner ID en de key, en dwing vervolgens een unieke database constraint af over deze twee kolommen.

Waarom een database constraint? Omdat het controleren op bestaan in de applicatiecode voordat je invoegt, een race condition is die wacht om te gebeuren. Twee identieke verzoeken kunnen door hetzelfde microseconde-gat glippen. Laat de database de handhaver zijn. Als een gebruiker dezelfde owner ID en key twee keer stuurt, vangt het tweede verzoek de unieke schending op en geef je de bestaande job terug. Beide verzoeken krijgen hetzelfde job ID. Er wordt geen dubbel werk gestart.

Wees streng wat betreft de scope. Als iemand de key hergebruikt maar de input payload wijzigt, geef dan een conflict terug. De idempotency key moet gekoppeld zijn aan een exacte intentie, niet alleen aan de gebruiker. Dezelfde key met een andere input betekent dat de client in de war is, en je systeem moet dit weigeren in plaats van te gokken.

Bescherm State Transitions

Een attempt is een state transition, geen nieuwe job. Je API moet weigeren een nieuwe attempt te starten als een eerdere attempt nog in een 'startende' of 'onbekende' status hangt.

Timeouts zijn de reden. Wanneer een verzoek naar een provider een timeout krijgt, ziet de client een fout, maar het proces aan de serverzijde kan nog steeds actief zijn. De GPU-cluster kan nog steeds bezig zijn met je inference-verzoek. De container kan nog steeds aan het schrijven zijn naar blob storage. Als je de getimede attempt als mislukt markeert en onmiddellijk een tweede attempt start, gok je met dubbele side effects.

Behandel een timeout als een onbekende status, niet als een mislukte status. Blokkeer nieuwe attempts totdat de eerdere attempt een terminale status bereikt of expliciet wordt geannuleerd door een out-of-band proces. Deze pauze is ongemakkelijk. Het dwingt de gebruiker om te wachten. Het voorkomt ook de chaos waarbij twee workers dezelfde downstream resources aanpassen.

Los Races op met Compare-and-Swap

De moeilijkste problemen ontstaan wanneer meerdere attempts klaar zijn. Misschien heeft je systeem attempt één uitgevoerd bij de primaire provider. Na tien seconden stilte werd attempt twee uitgevoerd bij de fallback. Nu zijn beide attempts voltooid. Je kunt niet toestaan dat ze allebei hun resultaten naar dezelfde job-rij schrijven.

Gebruik compare-and-swap logica. Voeg een versienummer toe aan de job-rij. Wanneer een attempt klaar is, voert deze een update uit met voorwaarden:

  • De huidige versie moet overeenkomen met wat de attempt aan het begin heeft uitgelezen.
  • Geen andere attempt mag de resultaat-slot al hebben opgeëist.
  • Als beide punten kloppen, schrijf dan het resultaat en verhoog de versie.

In SQL-termen ziet dat eruit als een update-statement met een WHERE id = $1 AND version = $2 AND completed_by IS NULL. Als de update nul rijen retourneert, heeft een andere attempt al gewonnen. De late binnenkomst moet worden genegeerd. Gooi het resultaat weg. Niet samenvoegen. Niet toevoegen. Gooi het werk weg. Een laat resultaat dat een eerdere winnaar overschrijft is datacorruptie, en de enige veilige actie is om het te verwerpen.

Dit handelt de afronding in omgekeerde volgorde netjes af. Poging A vertrekt als eerste maar keert na dertig seconden terug. Poging B vertrekt als tweede maar keert na vijf seconden terug. Poging B wint de compare-and-swap. De update van poging A raakt nul rijen. Je systeem logt de race, negeert de verouderde payload en gaat door.

Test de breekpunten

Je zult deze bugs niet vinden tijdens happy-path testen. Je testsuite moet zich richten op de breuklijnen.

  • Simuleer een dubbelklik. Twee gelijktijdige POST-verzoeken met dezelfde idempotency key moeten identieke job-ID's retourneren.
  • Verstuur dezelfde key met afwijkende input. Verwacht een conflict-respons. Het systeem mag niet stilletjes de bestaande job retourneren als de parameters verschillen.
  • Provoceer een timeout. Controleer of de job in een onbekende status terechtkomt, en niet in een gefaalde status, en dat het systeem verdere pogingen blokkeert totdat de onduidelijkheid is opgelost.
  • Forceer twee pogingen om in omgekeerde volgorde af te ronden. Bevestig dat de tweede die terugkeert verliest, zelfs als de eerste die vertrok de officiële primaire provider was.

Deze tests zijn geen luxe voor edge-cases. Ze vormen het contract dat je API maakt met de rest van het systeem.

Valideer de intentie van de provider voordat je failover uitvoert

Als je een multi-provider setup gebruikt, word je misschien verleid om verschillende AI-modellen te behandelen als uitwisselbare slots. Ze delen hetzelfde codepad, dezelfde HTTP-client en hetzelfde JSON-schema. Dat betekent niet dat ze hetzelfde gedrag vertonen.

Het ene model kan een top-level key hallucineren. Een ander kan je system prompt-formattering negeren. Schema-validatie vangt syntaxfouten op, maar zal een respons doorlaten die je businesslogica niet kan interpreteren. Een provider kan geldige JSON retourneren die simpelweg het verkeerde doet met je prompt-template.

Voer provider-specifieke tests uit voordat je automatisch wisselen van modellen toestaat. Bevestig dat het fallback-model je outputstructuur daadwerkelijk respecteert bij een lage temperatuur. Controleer of je prompt correct wordt gerenderd via de tokenizer van die provider. Test de volledige round trip met echte inputs. Automatische failover is alleen veilig als je hebt bewezen dat de fallback hetzelfde operationele contract deelt.

Houd één job per intentie aan

Fallback-paden zijn goed. Ongecontroleerde fallback-vermenigvuldiging is een bug. Elke laag van je stack moet evalueren of deze de exacte taak al eerder heeft gezien. De load balancer, de API-handler, de database en de worker moeten allemaal dezelfde identiteit respecteren.

Bouw je systeem zo dat retries en fallbacks verschijnen als nieuwe pogingen onder één stabiele job. Vergrendel de job met een database-ondersteunde idempotency key. Bewaak de overgangen. Laat de pogingen met elkaar racen. Laat er precies één winnen. Zo voorkom je dat een enkele klik van een gebruiker uitmondt in een weekend vol data-opschoning.