Un usuario rellena un formulario de «Crear pedido» y le da doble clic al botón. O tiene mala cobertura, el móvil reintenta la petición, y el servidor la recibe dos veces. Resultado: dos pedidos idénticos en la base de datos.
Esto no es un bug de frontend. Es que tu endpoint no es idempotente, y en cualquier operación
que cree algo (POST), tarde o temprano te va a pasar.
Qué significa idempotente aquí
Una petición es idempotente si hacerla una vez o cien veces produce el mismo resultado.
GET, PUT y DELETE suelen serlo por naturaleza.
POST no, porque su trabajo es precisamente crear un recurso nuevo cada vez que
se ejecuta.
El objetivo no es prohibir POST duplicados a nivel de red (eso ya lo intenta el
propio navegador y no siempre funciona), sino que si dos peticiones idénticas llegan al
servidor, solo una cree el recurso y la otra devuelva el resultado de la primera.
El patrón: clave de idempotencia
La idea, tomada de cómo lo resuelven Stripe o PayPal en sus APIs de pagos:
- El cliente genera un identificador único para esa operación concreta (un UUID) antes de enviarla.
- Lo manda en una cabecera, típicamente
Idempotency-Key. - El servidor guarda qué clave ya ha procesado y qué respuesta dio.
- Si llega otra petición con la misma clave, se devuelve la respuesta guardada sin volver a ejecutar la lógica.
Si el usuario hace doble clic, el frontend reutiliza la misma clave para ambos intentos (la genera una vez, no en cada submit). Si es un reintento automático de red, la clave viaja igual porque es la misma petición.
Implementación en Symfony
1. La entidad para guardar las claves usadas
#[ORM\Entity]
#[ORM\Table(name: 'idempotency_key')]
class IdempotencyKey
{
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
private string $key;
#[ORM\Column(type: 'integer')]
private int $responseStatus;
#[ORM\Column(type: 'json')]
private array $responseBody;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct(string $key, int $responseStatus, array $responseBody)
{
$this->key = $key;
$this->responseStatus = $responseStatus;
$this->responseBody = $responseBody;
$this->createdAt = new \DateTimeImmutable();
}
public function getResponseStatus(): int
{
return $this->responseStatus;
}
public function getResponseBody(): array
{
return $this->responseBody;
}
}
La clave (Idempotency-Key) es la propia clave primaria. Eso te da la garantía de
unicidad gratis: si dos peticiones concurrentes intentan insertar la misma clave a la vez, la
base de datos rechazará la segunda por violación de constraint, y ese es justo el
comportamiento que quieres.
2. Un servicio que envuelve la operación
class IdempotencyGuard
{
public function __construct(
private EntityManagerInterface $em,
) {
}
public function handle(string $key, callable $operation): array
{
$existing = $this->em->find(IdempotencyKey::class, $key);
if ($existing !== null) {
return [
'status' => $existing->getResponseStatus(),
'body' => $existing->getResponseBody(),
'replayed' => true,
];
}
$result = $operation();
$record = new IdempotencyKey($key, $result['status'], $result['body']);
try {
$this->em->persist($record);
$this->em->flush();
} catch (UniqueConstraintViolationException) {
// Perdimos la carrera contra una petición concurrente con la misma clave.
// La otra ya insertó el resultado real; lo leemos y lo devolvemos.
$existing = $this->em->find(IdempotencyKey::class, $key);
return [
'status' => $existing->getResponseStatus(),
'body' => $existing->getResponseBody(),
'replayed' => true,
];
}
return ['status' => $result['status'], 'body' => $result['body'], 'replayed' => false];
}
}
El try/catch no es paranoia: si el doble clic dispara dos peticiones casi
simultáneas, ambas pueden pasar el find() inicial antes de que ninguna haya
insertado nada todavía. La constraint única de la base de datos es la que realmente decide
cuál gana.
3. El controlador
class OrderController extends AbstractController
{
#[Route('/orders', methods: ['POST'])]
public function create(
Request $request,
IdempotencyGuard $guard,
OrderService $orderService,
): JsonResponse {
$idempotencyKey = $request->headers->get('Idempotency-Key');
if ($idempotencyKey === null) {
return $this->json(
['error' => 'Falta la cabecera Idempotency-Key'],
Response::HTTP_BAD_REQUEST
);
}
$data = json_decode($request->getContent(), true);
$result = $guard->handle($idempotencyKey, function () use ($data, $orderService) {
$order = $orderService->create($data);
return [
'status' => Response::HTTP_CREATED,
'body' => ['id' => $order->getId(), 'total' => $order->getTotal()],
];
});
return $this->json($result['body'], $result['status']);
}
}
El controlador queda limpio: valida que la cabecera existe, delega en el guard, y no le importa si la operación se ejecutó de verdad o si solo se está devolviendo una respuesta ya guardada.
4. En el frontend
Solo hay que generar el UUID una vez, en el momento en que el usuario abre el formulario o pulsa el botón por primera vez, y reutilizarlo en los reintentos:
const idempotencyKey = crypto.randomUUID();
async function submitOrder(data) {
return fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(data),
});
}
Si el fetch falla por timeout y el código de retry lo relanza, o si el usuario le
da otra vez al botón antes de que se deshabilite, la clave sigue siendo la misma.
Detalles que se suelen pasar por alto
- Expiración de las claves. Guardarlas para siempre no tiene sentido. Un cronjob o comando de Symfony que borre las de más de 24-48 horas es suficiente para la mayoría de casos.
- La clave no sustituye la validación de negocio. Si dos usuarios distintos intentan comprar el último artículo en stock, eso es un problema de concurrencia de dominio, no de idempotencia HTTP. Son cosas distintas.
- No apliques esto a todo. Un
GETde listado no lo necesita. Resérvalo paraPOSTque creen algo con efecto real: pedidos, pagos, envíos de formularios, altas de usuario. - Devuelve el mismo status code en el replay. Si la primera vez
respondiste
201, la segunda también debe ser201, no200. El cliente no debería notar la diferencia entre «se creó ahora» y «ya se había creado».
Cuándo no hace falta tanto
Si tu formulario ya deshabilita el botón al enviarse y no tienes reintentos automáticos en el cliente, quizás con eso te baste para el 90% de los casos. El patrón de la clave de idempotencia merece la pena cuando la operación es costosa de deshacer (un pago, un envío de email, una alta que dispara otros procesos) o cuando no controlas el cliente que te llama, como en una API pública.