Ir al contenido principal
Laravel, shipping fast.

Esta es una regla transversal, así que vive donde el Capítulo 6 puso las reglas transversales: en un middleware, aplicado a las rutas que crean cosas.

// app/Http/Middleware/Idempotent.php
public function handle(
    Request $request,
    Closure $next,
): Response {
    $key = $this->keyFrom($request); // null, o un UUID

    if ($key === null) {
        return $next($request);
    }

    $cacheKey = "idem:{$request->user()->id}:{$key}";
    $lock = Cache::lock("{$cacheKey}:lock", self::LOCK_FOR);

    if (! $lock->get()) {
        return $this->inFlight();
    }

    try {
        return $this->replay($request, $cacheKey)
            ?? $this->run($request, $next, $cacheKey);
    } finally {
        $lock->release();
    }
}
private const LOCK_FOR = 120; // segundos

private function keyFrom(Request $request): ?string
{
    $key = $request->header('Idempotency-Key');

    if ($key !== null && ! Str::isUuid($key)) {
        abort(
            Response::HTTP_BAD_REQUEST,
            __('errors.idempotency_key_invalid'),
        );
    }

    return $key;
}

Léelo contra las cuatro reglas.

La clave es un UUID, y cualquier otra cosa se rechaza. Una cabecera es una entrada. Una cadena sin límite que viene de un cliente no debería acabar formando parte de una clave de caché.

La clave de caché incluye al consumidor. Una clave es única por consumidor, no globalmente. Sin el ID del consumidor dentro, un consumidor podría enviar la clave de otro y recibir la respuesta del otro.

Un bloqueo lo cubre todo. Cache::lock() es atómico: de dos peticiones que llegan juntas con la misma clave, exactamente una lo adquiere. La otra recibe la respuesta de «en curso», que es la regla cuatro. Los dos minutos son una red de seguridad por si el proceso muere con el bloqueo tomado, y deben ser más largos que lo más lento que pueda hacer la petición. El Capítulo 9 hace esa suma para el proveedor. (Documentación de Laravel: Cache › Atomic Locks.)

La respuesta guardada se busca dentro del bloqueo. Este orden es la parte en la que es fácil equivocarse. Comprueba primero la caché y toma después el bloqueo, y una petición puede no encontrar nada en la caché, esperar a que la primera termine y libere el bloqueo, y entonces tomar el bloqueo y hacer el trabajo otra vez. Dentro del bloqueo, o hay una respuesta guardada que repetir, o esta petición es la que se ejecuta.

Hacer el trabajo y recordarlo:

private function run(
    Request $request,
    Closure $next,
    string $cacheKey,
): Response {
    $response = $next($request);

    if ($response->isSuccessful()) {
        Cache::put($cacheKey, [
            'fingerprint' => $this->fingerprint($request),
            'status' => $response->getStatusCode(),
            'body' => $response->getContent(),
        ], now()->addDay());
    }

    return $response;
}

Lo que se recuerda

Solo se guarda el éxito.

Un 422 no creó nada. Si el consumidor corrige el cuerpo y reintenta con la misma clave, hay que permitirle que salga bien. Un 504 significa que el proveedor no contestó, y recordarlo haría que cada reintento fallara con el mismo 504 obsoleto durante un día, que es lo contrario de aquello para lo que sirve un reintento.

La regla es recordar la respuesta cuando la petición tuvo un efecto. Si no pasó nada, no hay nada que proteger, y el siguiente intento debe ejecutarse de verdad.

Un día es una decisión de criterio. Tiene que ser más largo que el calendario de reintentos de cualquier consumidor y lo bastante corto para que el almacén no crezca sin límite. Veinticuatro horas es la elección habitual, y lo que elijas debe estar en tu documentación.

Hay un tipo de respuesta que nunca debe recordarse: la que contiene un secreto. El endpoint de tokens del Capítulo 5 devuelve un token en texto plano exactamente una vez, y el Capítulo 4 dijo que no se guarda en ningún sitio. Pon este middleware en esa ruta y el token se queda un día en tu caché, listo para ser repetido. La idempotencia es para las rutas que crean licencias. No corresponde a todos los POST.

Repetir, y rechazar

private function replay(
    Request $request,
    string $cacheKey,
): ?Response {
    $saved = Cache::get($cacheKey);

    if ($saved === null) {
        return null;
    }

    $sameRequest = $saved['fingerprint']
        === $this->fingerprint($request);

    abort_unless(
        $sameRequest,
        Response::HTTP_UNPROCESSABLE_ENTITY,
        __('errors.idempotency_key_reused'),
    );

    return response($saved['body'], $saved['status'], [
        'Content-Type' => 'application/json',
        'Idempotent-Replayed' => 'true',
    ]);
}
private function fingerprint(Request $request): string
{
    return hash('sha256', implode('|', [
        $request->method(),
        $request->fullUrl(),
        $request->getContent(),
    ]));
}

La huella es la regla tres. Si una clave llega con una petición distinta de aquella con la que se usó por primera vez, el consumidor está reutilizando claves entre operaciones diferentes. Repetir la respuesta vieja sería mentir, y ejecutar la petición nueva rompería la garantía. La respuesta honesta es un error que diga lo que pasó.

Idempotent-Replayed: true le dice al consumidor que esta respuesta es una copia. Cuesta una cabecera y acorta una conversación de soporte confusa.

Y la regla cuatro:

private function inFlight(): Response
{
    return response()->json([
        'message' => __('errors.request_in_progress'),
        'code' => ErrorCode::RequestInProgress,
    ], Response::HTTP_CONFLICT, ['Retry-After' => 1]);
}

Un 409 con Retry-After y su propio código dice «la primera sigue trabajando, pregunta otra vez en un segundo». Cuando el consumidor lo hace, encuentra la respuesta guardada. El código importa aquí: el límite de licencias del Capítulo 7 también es un 409, y ese nunca se resolverá esperando.

Dale un alias al middleware añadiéndolo al array que empezó el Capítulo 4, y aplícalo a las acciones que crean licencias:

// bootstrap/app.php, dentro de withMiddleware()
$middleware->alias([
    'ability' => CheckForAnyAbility::class,
    'idempotent' => Idempotent::class,
]);
// app/Http/Controllers/LicenseController.php
#[Middleware('ability:licenses:read', only: ['index'])]
#[Middleware('ability:licenses:write', except: ['index'])]
#[Middleware('idempotent', only: ['store'])]
class LicenseController
{
    // ...
}

La habilidad se comprueba primero. Un token sin licenses:write debe ser rechazado antes de que se le pueda repetir una respuesta guardada.

No se pudo cargar el audio. Inténtalo de nuevo en un momento.