Reintentos de peticiones seguros para APIs de Laravel. Un cliente envía un
encabezado Idempotency-Key con una petición de escritura; si esa misma
petición llega de nuevo —un reintento tras un timeout, un botón pulsado dos
veces, una reentrega de webhook— se reproduce la respuesta original en lugar de
ejecutar la acción dos veces.
composer require webrek/laravel-idempotency
Adjunta el middleware a las rutas que crean o modifican estado:
Route::post('/orders', [OrderController::class, 'store'])
->middleware('idempotency');
Los clientes se suscriben por petición enviando una clave única:
POST /orders HTTP/1.1
Idempotency-Key: 0f8fad5b-d9cb-469f-a165-70867728950e
Content-Type: application/json
{"sku": "ABC-123", "qty": 2}
La primera llamada ejecuta el controlador y almacena la respuesta. Cualquier
repetición de esa llamada dentro de la ventana de retención devuelve la respuesta
almacenada tal cual, con un encabezado Idempotency-Replayed: true para que el
cliente pueda distinguir una reproducción de un resultado nuevo. Sin clave, no hay
interceptación: quienes ya llaman a la API siguen funcionando.
POST no es seguro de reintentar. Cuando un cliente lanza una petición de
escritura y la conexión se cae antes de que regrese la respuesta, no tiene forma
de saber si el servidor la procesó. Ambas opciones son malas: si reintentas,
arriesgas un cargo, pedido o registro duplicado; si no reintentas, arriesgas
perder la escritura en silencio.
Las claves de idempotencia resuelven la ambigüedad. El cliente genera una clave por operación lógica y la reutiliza en cada reintento de esa operación. El servidor promete que todas las peticiones que comparten una clave producen una ejecución y la misma respuesta. Así es como Stripe, PayPal, Adyen y la mayoría de las APIs de pago serias hacen seguros los reintentos, y es exactamente lo que este paquete añade a tus rutas de Laravel.
El middleware se coloca delante de tus rutas protegidas y hace cuatro cosas:
422 en lugar de devolver en silencio la respuesta en caché equivocada.409 Conflict con un encabezado
Retry-After. El lock expira automáticamente, así que un worker caído nunca
deja atascada una clave.5xx) nunca se
almacenan, de modo que un cliente puede reintentar de forma segura tras un fallo
transitorio. Los éxitos y los errores de cliente deterministas sí se reproducen.Todo vive en la caché de Laravel, usando los mismos locks atómicos que expone
Cache::lock(). No hay migraciones ni tablas nuevas.
| Escenario | Resultado |
|---|---|
| Primera petición con una clave | Ejecuta, almacena la respuesta, Idempotency-Replayed: false |
| Misma clave, mismo payload, tras completarse | Reproduce la respuesta almacenada, Idempotency-Replayed: true |
| Misma clave, mismo payload, aún en curso | 409 Conflict + Retry-After |
| Misma clave, payload distinto | 422 Unprocessable Entity |
Sin clave (y require_key es false) |
Pasa de largo sin tocarse |
Petición GET / HEAD |
Se ignora: ya es seguro repetirla |
La respuesta es 5xx |
No se almacena: el siguiente intento la vuelve a ejecutar |
| Componente | Versión |
|---|---|
| PHP | 8.2+ |
| Laravel | 12.x / 13.x |
| Almacén de caché | Cualquier almacén que soporte locks atómicos (redis, memcached, dynamodb, database, file, array) |
Los valores por defecto están listos para producción. Publica la configuración solo si necesitas cambiarlos:
php artisan vendor:publish --tag=idempotency-config
return [
// Encabezado que envían los clientes para identificar una operación reintentable.
'header' => env('IDEMPOTENCY_HEADER', 'Idempotency-Key'),
// Rechaza con 400 las peticiones sin clave en rutas protegidas cuando es true.
'require_key' => false,
// Métodos HTTP que protege el middleware. GET/HEAD ya son seguros.
'methods' => ['POST', 'PUT', 'PATCH', 'DELETE'],
// Almacén de caché para respuestas y locks (null = almacén por defecto).
'store' => env('IDEMPOTENCY_STORE'),
'prefix' => 'idempotency:',
// Cuánto tiempo una respuesta sigue siendo reproducible, en segundos.
'ttl' => (int) env('IDEMPOTENCY_TTL', 86400),
// Tiempo máximo que una petición retiene el lock de su clave, en segundos.
'lock_timeout' => 10,
'max_key_length' => 255,
// Agrupa las claves por usuario autenticado para que no colisionen los llamadores.
'scope_by_user' => true,
// Null reproduce todo lo < 500; o lista códigos explícitos, p. ej. [200, 201, 422].
'replay_status_codes' => null,
// Encabezados copiados a la respuesta reproducida.
'persist_headers' => ['Content-Type'],
// Marca agregada a cada respuesta protegida: "true" | "false".
'replay_header' => 'Idempotency-Replayed',
];
Sobrescribe el TTL configurado (en segundos) para rutas específicas pasándolo como parámetro del middleware:
Route::post('/payments', ...)->middleware('idempotency:3600'); // 1 hora
Route::post('/imports', ...)->middleware('idempotency:86400'); // 1 día
Se despacha un evento Idempotency\Events\IdempotentReplay cada vez que se
reproduce una respuesta almacenada, para que puedas medir cuántos reintentos estás
absorbiendo:
use Webrek\Idempotency\Events\IdempotentReplay;
Event::listen(IdempotentReplay::class, function (IdempotentReplay $event) {
Metrics::increment('idempotency.replays', tags: ['key' => $event->key]);
});
Deja require_key desactivado globalmente y activa rutas individuales cambiando la
configuración en el punto de entrada, o ponlo en true si toda ruta protegida debe
llevar una clave. Con esto activado, una petición protegida sin el encabezado se
rechaza con 400 antes de hacer cualquier trabajo.
Las reproducciones son tan duraderas como el almacén que las respalda. array es
para pruebas; en producción apunta IDEMPOTENCY_STORE a redis (o cualquier
almacén compartido y persistente con locks atómicos) para que las reproducciones
sobrevivan entre web workers y despliegues. Un almacén por proceso como array no
puede coordinar locks entre máquinas.
409 aplicando backoff y reintentando: significa que un intento
anterior sigue ejecutándose. Respeta el encabezado Retry-After.422 como un error de tu lado: significa que reutilizaste una clave
para una petición genuinamente distinta.| Enfoque | Seguro ante concurrencia | Detección de payload distinto | Reproduce la respuesta completa | Migraciones |
|---|---|---|---|---|
firstOrCreate sobre una columna request_id |
No (carrera entre la comprobación y el insert) | No | No | Sí |
| Restricción única en BD + capturar duplicado | Parcialmente (depende de que la escritura llegue a la tabla restringida) | No | No | Sí |
| Este paquete | Sí (lock atómico) | Sí (huella de la petición) | Sí | No |
Una restricción única detiene una fila duplicada, pero no detiene los efectos secundarios duplicados que se ejecutaron antes del insert (el correo ya enviado, el cargo de terceros ya realizado), y le devuelve al cliente un error en lugar del éxito original. La idempotencia en la frontera HTTP detiene por completo la segunda ejecución y devuelve la primera respuesta.
composer install
composer test
La suite se ejecuta sobre el almacén de caché array, así que no se necesitan
servicios externos.
Consulta CONTRIBUTING.md.
Por favor revisa la política de seguridad antes de reportar una vulnerabilidad.
La Licencia MIT (MIT). Consulta LICENSE.
How can I help you explore Laravel packages today?