Ir al contenido principal
Laravel, shipping fast.
Capitulo 16 · Trabajo que sobrevive a la red

El reintento y la espera viven en el cliente, una vez

Julian Beaujardin

Cada servicio hoja al que llama api-server pasa por su propia clase de integración pequeña. Todas acabarán encontrándose con un límite de frecuencia, porque todas llaman a un sistema con su propia capacidad, y todos esos sistemas lo dicen igual: un HTTP 429. Si cada clase de integración implementara su propio bucle de reintentos, la corrección aquí habría que derivarla una docena de veces por separado, y la duodécima vez, con una fecha encima, es exactamente cuando alguien copia la undécima implementación con la espera partida por la mitad «sólo por ahora». La respuesta de la flota es la misma que ya usa para autenticación y formas de error: ponlo una vez en el paquete compartido.

// api-infrastructure/src/Concerns/SendsRequests.php (condensed)
trait SendsRequests
{
    protected function send(string $method, string $url, array $data = []): Response
    {
        $maxAttempts = max(1, $this->intConfig('webplo.throttle_retry.max_attempts', 3));
        $attempt = 0;

        while (true) {
            $attempt++;
            $response = $this->dispatchRequest($method, $url, $data);

            if (! $response->failed()) {
                return $response;
            }

            if ($response->status() === 429 && $attempt < $maxAttempts) {
                $this->backoffForThrottle($response, $attempt);

                continue;
            }

            throw new RequestException(response: $response);
        }
    }

    private function backoffForThrottle(Response $response, int $attempt): void
    {
        $base = max(0, $this->intConfig('webplo.throttle_retry.base_delay_ms', 500));
        $max = max(0, $this->intConfig('webplo.throttle_retry.max_delay_ms', 5000));

        $retryAfter = $response->header('Retry-After');
        $delayMs = is_numeric($retryAfter)
            ? (int) ((float) $retryAfter * 1000)
            : $base << ($attempt - 1);

        usleep(max(0, min($delayMs, $max)) * 1000);
    }
}

Todo lo que una integración concreta necesita de esto se hereda, no se escribe:

// app/Http/Integrations/Cloud/GithubAPI.php
final class GithubAPI
{
    use SendsRequests {
        send as protected sendRequest;
    }
    use TracksDependencyHealth;

    protected function send(string $method, string $url, array $data = []): Response
    {
        return $this->trackDependency(
            Dependency::GitHub,
            fn (): Response => $this->sendRequest($method, $url, $data),
        );
    }
}

GithubAPI no sabe qué es un 429. No tiene una llamada a usleep() en ninguna parte. Renombra el send() del trait, envuelve esa única llamada en su propio rastreador de dependencias, y cada método que defina después obtiene el comportamiento de reintento y espera gratis, porque está construido sobre send() y no sobre Http::post() directamente. Respetar Retry-After correctamente es un detalle que merece la pena acertar exactamente una vez. Hornéalo en el trait y ningún servicio hoja que se añada a la flota el año que viene podrá equivocarse con la espera, porque ninguno tendrá que escribirla.

Este es el mismo principio que el capítulo 3 aplicó a la autenticación y el capítulo 5 al renderizado de errores, sólo que apuntado a las llamadas salientes en lugar de a las entrantes. Una preocupación transversal que vive en un trait compartido es una garantía estructural. Una preocupación transversal que se supone que cada integración debe recordar es una política con un agujero, esperando a la única clase que nadie llegó a actualizar.

Repaso

Encadenar entre servicios

  • [X] Trata cada paso como una llamada a un sistema cuya base de datos no controlas, no sólo como un job que podría correr dos veces
  • [X] Protege cada paso comprobando el estado confirmado de la hoja, no sólo tu propio contador de intentos
  • [X] Detén un lote —no dejes que los hermanos sigan mutando estado— en cuanto un paso ya ha fallado

Nombres que sobreviven a un reintento

  • [X] Deriva los nombres de recursos externos de algo estable y ya único, como el identificador del registro dueño, nunca de una marca de tiempo
  • [X] Un nombre determinista hace converger un flujo reintentado sobre un solo recurso en lugar de filtrar un huérfano por intento

Segar lo que se atasca en silencio

  • [X] Añade un barrido programado que busque trabajo marcado como iniciado y nunca como terminado, pasado un umbral
  • [X] Pon tope a cuánto actúa un barrido por ejecución, y niégate a superarlo en lugar de truncar en silencio
  • [X] Vuelve a comprobar el estado justo antes de actuar, y pasa por el mismo camino idempotente de fallo terminal que ya usa un fallo normal

Reintento y espera como infraestructura compartida

  • [X] Pon el comportamiento de reintento y espera en un único trait compartido sobre el que construyan todos los clientes
  • [X] Deja que una clase de API concreta añada sus preocupaciones encima de ese send() compartido, nunca alrededor

Una docena de APIs que se comportan como un solo sistema no llegan ahí porque todo el mundo recordara las mismas reglas. Llegan ahí porque las reglas sólo hubo que escribirlas una vez, y olvidarlas dejó de ser una opción.