A user clicks a button. The request stalls. Ten seconds of silence. They click the fallback button. Now two jobs are running against a single intention. You end up with duplicate side effects, double charges, and a data mess that eats your afternoon.

This is not a frontend bug. A disabled button or a debounce timer in React will not save you. The first request was already in flight. The network simply swallowed the response. If your backend treats every incoming request as a brand new instruction, retries become liabilities. You need to fix this in your API design and your database schema.

The solution starts with a simple structural split.

Split Jobs from Attempts

Think of a job as the durable record of what the user wants. It captures the owner, the parameters, the target provider, and the exact intent. An attempt is a specific try at fulfilling that intent.

Picture a print shop. You hand over a file and they give you ticket #45. That ticket is the job. The shop tries the inkjet printer. It jams. That is attempt one. They move the file to the laser printer. That is attempt two. Throughout the process, ticket #45 never changes. If the shop issued a new ticket for every printer they tried, you would pay three times and receive three unwanted copies.

Your database should mirror this. One table holds jobs. Another table holds attempts. The job row stays constant while the attempts accumulate beneath it.

This separation gives you control. It also gives you a place to attach an idempotency key that survives network blips.

Require an Idempotency Key on Every Job

Every POST request that creates a job must carry a unique idempotency key. This key belongs to the user, not the session. Combine the owner ID and the key, then enforce a unique database constraint across those two columns.

Why a database constraint? Because checking for existence in application code before inserting is a race condition waiting to happen. Two identical requests can slip through the same microsecond gap. Let the database be the enforcer. If a user sends the same owner ID and key twice, the second request catches the unique violation and you return the existing job. Both requests get the same job ID. No duplicate work starts.

Be strict about scope. If someone reuses the key but changes the input payload, return a conflict. The idempotency key must bind to an exact intention, not just the user. Same key with different input means the client is confused, and your system should reject it rather than guess.

Protect State Transitions

An attempt is a state transition, not a new job. Your API must refuse to spawn a fresh attempt if a previous attempt is still hanging in a starting or unknown state.

Timeouts are the reason. When a provider request times out, the client sees failure, but the server-side process might still be alive. The GPU cluster could still be churning on your inference request. The container might still be writing to blob storage. If you mark the timed-out attempt as failed and immediately fire a second attempt, you are gambling with duplicate side effects.

Treat a timeout as an unknown state, not a failed one. Block new attempts until the earlier one reaches a terminal state or is explicitly cancelled by an out-of-band process. This pause is uncomfortable. It forces the user to wait. It also prevents the chaos of two workers mutating the same downstream resources.

Resolve Races with Compare-and-Swap

The hardest problems show up when multiple attempts finish. Maybe your system fired attempt one against the primary provider. After ten seconds of silence, it fired attempt two against the fallback. Now both attempts are done. You cannot let both write their results to the same job row.

Use compare-and-swap logic. Add a version number to the job row. When an attempt finishes, it runs an update with conditions:

  • The current version must match what the attempt read at the start.
  • No other attempt must have already claimed the result slot.
  • If both pass, write the result and increment the version.

In SQL terms, that looks like an update statement with a WHERE id = $1 AND version = $2 AND completed_by IS NULL. If the update returns zero rows, another attempt already won. The late arrival must be ignored. Drop its result. Do not merge. Do not append. Throw the work away. A late result that overwrites an earlier winner is data corruption, and the only safe move is to discard it.

Це дозволяє коректно обробити завершення у зворотному порядку. Спроба A виходить першою, але повертається через тридцять секунд. Спроба B виходить другою, але повертається через п'ять секунд. Спроба B виграє операцію compare-and-swap. Оновлення спроби A не зачіпає жодного рядка. Ваша система реєструє стан гонки (race condition), ігнорує застаріле корисне навантаження (payload) і рухається далі.

Тестування точок зламу

Ви не виявите ці баги під час тестування за «щасливим шляхом» (happy-path testing). Ваш набір тестів має бути спрямований на виявлення тріщин.

  • Симулюйте подвійне натискання. Два одночасні POST-запити з однаковим ключем ідемпотентності повинні повертати ідентичні ID завдань.
  • Надішліть той самий ключ із невідповідними вхідними даними. Очікуйте відповідь про конфлікт. Система не повинна мовчки повертати існуюче завдання, якщо параметри відрізняються.
  • Спровокуйте тайм-аут. Переконайтеся, що завдання переходить у невідомий стан (unknown state), а не у стан помилки (failed state), і що система блокує подальші спроби, доки неоднозначність не буде усунута.
  • Примусово завершіть дві спроби у зворотному порядку. Підтвердьте, що та спроба, яка повернулася другою, програє, навіть якщо першою вийшла відповідь від основного провайдера.

Ці тести — не розкіш для граничних випадків. Це контракт, який ваш API укладає з рештою системи.

Перевіряйте наміри провайдера перед перемиканням на резерв

Якщо ви використовуєте систему з кількома провайдерами, у вас може виникнути спокуса сприймати різні моделі ШІ як взаємозамінні слоти. Вони мають спільний шлях виконання коду, той самий HTTP-клієнт і ту саму JSON-схему. Це не означає, що вони поводяться однаково.

Одна модель може галюцинувати на рівні ключа верхнього рівня. Інша може ігнорувати форматування вашого системного промпту. Валідація схеми виявляє синтаксичні помилки, але вона пропустить відповідь, яку ваша бізнес-логіка не зможе інтерпретувати. Провайдер може повернути валідний JSON, який просто неправильно обробляє ваш шаблон промпту.

Запускайте специфічні для провайдера тести перед тим, як дозволити автоматичне перемикання моделей. Переконайтеся, що резервна модель (fallback model) дійсно дотримується вашої структури виводу при низькій температурі (low temperature). Перевірте, чи правильно ваш промпт рендериться через токенізатор цього провайдера. Протестуйте повний цикл (round trip) із реальними вхідними даними. Автоматичне перемикання на резерв є безпечним лише тоді, коли ви довели, що резервна модель має той самий операційний контракт.

Один запит — одне завдання

Шляхи відкату (fallback paths) — це добре. Неконтрольоване множення відкатів — це баг. Кожен рівень вашого стека має оцінювати, чи не бачив він уже саме це завдання. Балансувальник навантаження, обробник API, база даних і воркер (worker) повинні всі дотримуватися однієї й тієї ж ідентичності.

Будуйте свою систему так, щоб повторні спроби (retries) та резервні варіанти (fallbacks) відображалися як нові спроби в межах одного стабільного завдання. Заблокуйте завдання за допомогою ключа ідемпотентності, що зберігається в базі даних. Контролюйте переходи. Створюйте стан гонки для спроб. Дозвольте перемогти лише одній. Саме так ви захистите себе від того, щоб один клік користувача не перетворився на цілі вихідні з очищення даних.