Un usuario hace clic en un botón. La solicitud se detiene. Diez segundos de silencio. Hace clic en el botón de respaldo. Ahora hay dos procesos ejecutándose para una misma intención. Terminas con efectos secundarios duplicados, cargos dobles y un desastre de datos que te arruina la tarde.

Esto no es un error de frontend. Un botón deshabilitado o un temporizador de debounce en React no te salvarán. La primera solicitud ya estaba en curso. La red simplemente se tragó la respuesta. Si tu backend trata cada solicitud entrante como una instrucción completamente nueva, los reintentos se convierten en un riesgo. Necesitas solucionar esto en el diseño de tu API y en tu esquema de base de datos.

La solución comienza con una simple división estructural.

Separar los trabajos de los intentos

Piensa en un trabajo (job) como el registro duradero de lo que el usuario desea. Captura al propietario, los parámetros, el proveedor de destino y la intención exacta. Un intento (attempt) es un intento específico de cumplir con esa intención.

Imagina una imprenta. Entregas un archivo y te dan el ticket #45. Ese ticket es el trabajo. La imprenta prueba la impresora de inyección de tinta. Se atasca. Ese es el primer intento. Pasan el archivo a la impresora láser. Ese es el segundo intento. Durante todo el proceso, el ticket #45 nunca cambia. Si la imprenta emitiera un nuevo ticket por cada impresora que probara, pagarías tres veces y recibirías tres copias no deseadas.

Tu base de datos debería reflejar esto. Una tabla contiene los trabajos. Otra tabla contiene los intentos. La fila del trabajo permanece constante mientras los intentos se acumulan debajo de ella.

Esta separación te brinda control. También te da un lugar para adjuntar una clave de idempotencia que sobreviva a los fallos de red.

Requerir una clave de idempotencia en cada trabajo

Cada solicitud POST que cree un trabajo debe llevar una clave de idempotencia única. Esta clave pertenece al usuario, no a la sesión. Combina el ID del propietario y la clave, luego aplica una restricción de unicidad en la base de datos para esas dos columnas.

¿Por qué una restricción de base de datos? Porque verificar la existencia en el código de la aplicación antes de insertar es una condición de carrera (race condition) a punto de ocurrir. Dos solicitudes idénticas pueden filtrarse en el mismo microsegundo de diferencia. Deja que la base de datos sea el ejecutor. Si un usuario envía el mismo ID de propietario y la misma clave dos veces, la segunda solicitud detectará la violación de unicidad y tú devolverás el trabajo existente. Ambas solicitudes obtienen el mismo ID de trabajo. No se inicia trabajo duplicado.

Sé estricto con el alcance. Si alguien reutiliza la clave pero cambia la carga útil (payload) de entrada, devuelve un conflicto. La clave de idempotencia debe vincularse a una intención exacta, no solo al usuario. La misma clave con una entrada diferente significa que el cliente está confundido, y tu sistema debería rechazarla en lugar de intentar adivinar.

Proteger las transiciones de estado

Un intento es una transición de estado, no un nuevo trabajo. Tu API debe negarse a generar un nuevo intento si un intento previo todavía está pendiente en un estado de inicio o desconocido.

Los tiempos de espera (timeouts) son la razón. Cuando una solicitud a un proveedor agota el tiempo de espera, el cliente ve un fallo, pero el proceso en el servidor podría seguir vivo. El clúster de GPU podría seguir procesando tu solicitud de inferencia. El contenedor podría estar escribiendo todavía en el almacenamiento de objetos (blob storage). Si marcas el intento agotado como fallido y lanzas inmediatamente un segundo intento, estás jugando con efectos secundarios duplicados.

Trata un tiempo de espera como un estado desconocido, no como uno fallido. Bloquea los nuevos intentos hasta que el anterior alcance un estado terminal o sea cancelado explícitamente mediante un proceso externo. Esta pausa es incómoda. Obliga al usuario a esperar. También evita el caos de tener dos trabajadores mutando los mismos recursos de destino.

Resolver condiciones de carrera con Compare-and-Swap

Los problemas más difíciles aparecen cuando finalizan múltiples intentos. Quizás tu sistema lanzó el primer intento contra el proveedor principal. Tras diez segundos de silencio, lanzó el segundo intento contra el de respaldo. Ahora ambos intentos han terminado. No puedes permitir que ambos escriban sus resultados en la misma fila del trabajo.

Utiliza la lógica de compare-and-swap. Añade un número de versión a la fila del trabajo. Cuando un intento finaliza, ejecuta una actualización con condiciones:

  • La versión actual debe coincidir con lo que el intento leyó al inicio.
  • Ningún otro intento debe haber reclamado ya el espacio del resultado.
  • Si ambas condiciones se cumplen, escribe el resultado e incrementa la versión.

En términos de SQL, eso se ve como una sentencia de actualización con un WHERE id = $1 AND version = $2 AND completed_by IS NULL. Si la actualización devuelve cero filas, otro intento ya ganó. La llegada tardía debe ignorarse. Descarta su resultado. No fusiones. No añadas. Desecha el trabajo. Un resultado tardío que sobrescribe a un ganador anterior es corrupción de datos, y la única medida segura es descartarlo.

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.