Ir al contenido principal
Laravel, shipping fast.
Capítulo 9 · Rendimiento y optimización

Un presupuesto de tiempo para cada petición

Julian Beaujardin

La caché hace rápida la lista. No hace nada por una licencia que se está creando, ni por el momento en que la caché está vacía. En esos casos la petición espera al proveedor, y cuánto se le permite esperar es una decisión que tienes que tomar tú, porque el valor por defecto es demasiado largo.

Una aplicación PHP atiende las peticiones con un grupo fijo de workers. Digamos que tienes veinte. Cada petición ocupa uno hasta que termina. Ahora el proveedor se vuelve lento y cada llamada tarda treinta segundos. En unos instantes los veinte workers están metidos en una llamada a Statamic, y la petición número veintiuno, un consumidor que no pide más que la lista cacheada, espera en una fila que no avanza.

Así es como una dependencia lenta se convierte en una caída total. Que el proveedor fuera lento no tumbó tu API. La tumbó tu disposición a esperar.

Por eso el cliente del Capítulo 3 fijó dos límites y no los explicó:

->connectTimeout(3)
->timeout(10)

connectTimeout es cuánto esperar a que se abra la conexión. Un servidor sano acepta en milisegundos, así que tres segundos es generoso, y un proveedor caído falla rápido. timeout es el intercambio entero. Diez segundos es mucho para una llamada a una API. Elígelo a partir de las mediciones: si la respuesta normal más lenta del proveedor es de dos segundos, un techo de diez solo corta llamadas que ya estaban perdidas.

Después suma el peor caso, de fuera hacia dentro. Tres intentos de diez segundos, más dos esperas de hasta cinco segundos entre ellos, son cuarenta segundos para una llamada al proveedor. Crear una licencia puede hacer dos, la lista y la creación, que son ochenta. Tu servidor web tiene que permitir una petición más larga que eso, o la mata mientras el último intento sigue en curso y el consumidor recibe un error de gateway a secas donde debería haber estado tu 504 limpio. El bloqueo de idempotencia del Capítulo 8 también tiene que durar más que ella, y por eso ese bloqueo se mantiene dos minutos. Y el timeout del propio consumidor tiene que ser más largo que todo ello. Cada capa espera un poco más que la que tiene dentro.

Deja de llamar a lo que está caído

Los timeouts limitan cuánto espera cada petición. No impiden que la siguiente petición espere lo mismo. Mientras el proveedor está caído, cada petición de creación sigue gastando su presupuesto entero en descubrirlo otra vez.

Un cortacircuitos (circuit breaker) lo arregla. Tras cierto número de fallos seguidos, deja de llamar durante un rato y falla de inmediato. El limitador de Laravel, que el Capítulo 6 usó para contar peticiones, cuenta fallos igual de bien:

// app/Services/License/StatamicDriver.php
private const FAILURES = 'provider-failures:statamic';

private function guarded(Closure $call): ClientResponse
{
    if (RateLimiter::tooManyAttempts(self::FAILURES, 5)) {
        throw new ProviderUnavailable;
    }

    try {
        $response = $call();
    } catch (ConnectionException $e) {
        RateLimiter::hit(self::FAILURES, decaySeconds: 60);

        throw $e;
    }

    RateLimiter::clear(self::FAILURES);

    return $response;
}

ClientResponse es la clase de respuesta del cliente HTTP, importada con ese nombre para no confundirla con la respuesta que devuelve un controlador. Cada llamada al proveedor en el driver va envuelta en guarded(). Así queda delete() al final:

public function delete(string $key): void
{
    $response = $this->guarded(fn () => $this->http()
        ->withUrlParameters(['key' => $key])
        ->delete('/sites/{key}'));

    Cache::forget($this->cacheKey());

    if (! $response->notFound()) {
        $response->throw();
    }
}

fetchAll() envuelve su get() de la misma manera. create() necesita un poco más de cuidado, porque la llamada envuelta está dentro de su try:

public function create(
    string $name,
    string $domain,
): License {
    try {
        $data = $this->guarded(fn () => $this->http()
            ->post('/sites', [
                'name' => $name,
                'domain' => $domain,
            ]))->throw()->json('data');
    } finally {
        Cache::forget($this->cacheKey());
    }

    return License::fromProvider((array) $data);
}

Cinco fallos de conexión en un minuto, y durante el resto de ese minuto el driver lanza la excepción sin tocar la red. ProviderUnavailable es una excepción propia de la aplicación que se renderiza a sí misma como el mismo 504, con el mismo código, que el Capítulo 7 le dio a un timeout. Un consumidor recibe esa respuesta en un milisegundo, tus workers quedan libres, y no se machaca al proveedor mientras intenta recuperarse. Cuando pasa el minuto el contador ha caducado, la siguiente llamada pasa, y un solo éxito deja la cuenta a cero.

Solo cuenta ConnectionException. Un 422 del proveedor significa que el proveedor está en pie y no estuvo de acuerdo contigo, lo que no es razón para dejar de llamarlo.

Son veinte líneas de protección, no un framework de resiliencia. No lleva estados semiabiertos ni proporciones de éxito. Para un proveedor detrás de una API es suficiente, y sabrás cuándo no lo es, porque el panel del Capítulo 13 te lo mostrará.

El driver se ha construido ya a lo largo de tres capítulos. Esto es todo lo que tiene:

StatamicDriver implements LicenseContract
  __construct(string $token) Cap. 3
  for(Consumer): self        Cap. 4  un driver por consumidor
  all(): Collection          Cap. 9  en caché, luego tipado
  create(name, domain)       Cap. 9  siempre olvida la lista
  delete(key)                Cap. 9  un 404 es un éxito
  fetchAll(): array          Cap. 9  el GET, comprobado
  http(): PendingRequest     Cap. 3  la macro; reintenta 429
  backoff(), wasThrottled()  Cap. 3  cuándo reintentar
  guarded(Closure)           Cap. 9  el cortacircuitos
  cacheKey(): string         Cap. 9  una lista por cuenta

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