Ir al contenido principal
Laravel, shipping fast.

$tries y backoff no son adornos: son la política real de cuánto pelea el sistema antes de admitir que un job ha fallado.

// app/Jobs/LicenseCreateJob.php
class LicenseCreateJob implements ShouldBeEncrypted, ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public bool $deleteWhenMissingModels = true;
    public bool $failOnTimeout = false;

    public function backoff(): array
    {
        return [180, 300, 10800, 10800];
    }

    public function handle(): void
    {
        $this->site->refresh();

        if ($this->site->trashed()) {
            return;
        }

        $url = (string) ($this->site->metadata['url'] ?? '');

        try {
            $result = (new LicenseCreateAction)(token: $this->token, url: $url);
        } catch (Throwable $e) {
            if ($this->attempts() < $this->tries) {
                $this->release($this->nextBackoffSeconds());

                return;
            }

            throw $e;
        }
        // ...
    }
}

Lee el calendario de esperas como una secuencia, porque eso es: el primer intento corre de inmediato, el segundo espera tres minutos, el tercero cinco más, y el cuarto y el quinto esperan tres horas cada uno. Los dos primeros absorben un hipo breve del servicio externo. Los dos últimos existen para algo más parecido a una ventana de mantenimiento. Es una curva deliberada, no una suposición, y por eso backoff() es un método aquí y no un entero plano: fallos distintos merecen paciencias distintas.

Algunas propiedades hacen un trabajo silencioso e importante. deleteWhenMissingModels significa que si el Site con el que se construyó el job se borra antes de que corra, Laravel descarta el job en lugar de lanzarte una ModelNotFoundException en producción. failOnTimeout = false significa que una respuesta lenta vuelve al bucle de reintentos en lugar de saltar directamente a failed(). Y el bucle es deliberadamente silencioso: release() en cada intento salvo el último, así que el rastreador de errores sólo se entera una vez, en el intento que de verdad se rinde.

Rendirse es su propio evento, no sólo la ausencia de otro reintento:

public function failed(Throwable $exception): void
{
    $this->writeStatus('failed');

    $hostname = (string) ($this->site->metadata['url'] ?? '');

    app(StaffNotifier::class)->notifyPostProvisionJobFailed(
        kind: 'license',
        siteId: (string) $this->site->getKey(),
        hostname: $hostname,
        exception: $exception,
    );
}

failed() sólo corre una vez, en el intento que agota $tries. Todo lo que una persona necesita saber para actuar sobre ese fallo —un cambio de estado, un aviso a soporte— va aquí y en ningún otro sitio del job.

La tabla de jobs fallidos no es un cementerio

Un job que agota sus reintentos aterriza en failed_jobs y ahí se queda. Nada de la cola lo reintenta automáticamente; la tabla es donde se sientan los jobs muertos hasta que una persona los mira. Tratar esa tabla como el sitio adonde van las cosas a desaparecer es cómo los fallos reintentables se vuelven calladamente permanentes.

Laravel te da queue:retry, queue:forget y queue:flush justo para esto, y la flota construye una interfaz de operador sobre esas mismas primitivas en lugar de reinventarlas. Cada reintento pasa por Gate::authorize() y cada resultado se escribe en un registro de auditoría; la acción hermana de olvidar acepta además una razón opcional que se guarda en esa misma auditoría. Ese es el instinto correcto: reintentar un job fallido vuelve a intentar un efecto secundario, olvidarlo descarta el único registro de que ese efecto nunca ocurrió, y ninguno de los dos es un botón que quieras que se pulse por accidente.