planeta jupiter planeta tierra

Peticiones HTTP en Symfony con HttpClient

· 9 min de lectura · Symfony

Un ordenador enviando una petición a un servidor y recibiendo la respuesta
nave extraterrestre
Un ordenador enviando una petición a un servidor y recibiendo la respuesta

Tarde o temprano tu aplicación tiene que hablar con otra: cobrar con una pasarela de pago, consultar el tiempo, mandar un SMS o enseñar las estrellas de tus repositorios de GitHub. Para eso Symfony tiene el componente HttpClient: se instala con un comando, se inyecta como cualquier servicio y te resuelve de serie los timeouts, los reintentos, las peticiones en paralelo y los tests. En este post lo vemos con un ejemplo real, desde la primera petición hasta probarlo sin salir a internet.

Explicación

Una petición HTTP es como pedir algo por teléfono a una tienda. Llamas a un número (la URL), dices qué quieres hacer (el método: GET para preguntar, POST para encargar algo) y, si hace falta, das tus datos (las cabeceras y el cuerpo). La tienda te contesta con un código que resume cómo ha ido (200 todo bien, 404 eso no existe, 500 se les ha roto algo) y con la respuesta en sí, que en las APIs suele ser JSON.

Lo difícil no es llamar, sino todo lo que puede salir mal: que no contesten, que tarden un minuto, que comuniquen o que te den una respuesta que no esperabas. HttpClient está pensado para que esos casos no se te olviden.

Instalación

composer require symfony/http-client

No hay que configurar nada más. A partir de ese momento tienes un servicio que implementa HttpClientInterface y que puedes pedir en el constructor de cualquier clase.

La primera petición

Vamos a mostrar en la web cuántas estrellas tiene un repositorio de GitHub. Su API es pública, así que sirve para probar sin registrarse:

namespace App\Github;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class RepositoryStats
{
    public function __construct(private HttpClientInterface $httpClient)
    {
    }

    public function stars(string $owner, string $repository): int
    {
        $response = $this->httpClient->request(
            'GET',
            "https://api.github.com/repos/{$owner}/{$repository}"
        );

        // toArray() decodifica el JSON de la respuesta
        $data = $response->toArray();

        return $data['stargazers_count'];
    }
}

Y eso es todo. $stats->stars('symfony', 'symfony') te devuelve el número. Además de toArray(), la respuesta tiene lo que esperarías:

$response->getStatusCode();  // 200
$response->getHeaders();     // ['content-type' => ['application/json; charset=utf-8'], ...]
$response->getContent();     // el cuerpo tal cual, como string

Enviar datos: query, cabeceras y JSON

El tercer argumento de request() es un array de opciones. Las que más vas a usar son estas:

// GET con parámetros: Symfony monta ?q=symfony&per_page=5 por ti
$response = $this->httpClient->request('GET', 'https://api.github.com/search/repositories', [
    'query' => [
        'q' => 'symfony',
        'per_page' => 5,
    ],
    'headers' => [
        'Accept' => 'application/vnd.github+json',
    ],
]);

// POST con un cuerpo JSON: lo codifica y añade Content-Type: application/json
$response = $this->httpClient->request('POST', 'https://api.example.com/orders', [
    'auth_bearer' => $this->apiToken,
    'json' => [
        'productId' => 42,
        'quantity' => 2,
    ],
]);
  • query: los parámetros de la URL, ya escapados.
  • headers: cabeceras extra.
  • json: un array que se envía como JSON.
  • body: el cuerpo en crudo, o un array para enviarlo como formulario.
  • auth_bearer y auth_basic: autenticación sin tener que montar la cabecera Authorization a mano.

Cuando algo sale mal

Aquí está la parte que más se descuida. HttpClient distingue dos tipos de problema:

  1. No ha habido respuesta: el servidor no existe, no contesta o se ha cortado la conexión. Eso lanza una TransportExceptionInterface.
  2. Ha habido respuesta, pero de error: un 404, un 401, un 500… Al leer el contenido con getContent() o toArray() se lanza una ClientExceptionInterface (4xx) o una ServerExceptionInterface (5xx).

Es decir: por defecto, un error HTTP no pasa desapercibido. Si la API te devuelve un 500, no te encuentras un array vacío tres clases más allá: salta una excepción justo donde lees la respuesta. Lo normal es capturarlas en la clase que habla con la API y traducirlas a algo que entienda tu aplicación:

use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;

public function stars(string $owner, string $repository): ?int
{
    try {
        $response = $this->httpClient->request(
            'GET',
            "https://api.github.com/repos/{$owner}/{$repository}"
        );

        if (404 === $response->getStatusCode()) {
            return null; // el repositorio no existe: no es un error nuestro
        }

        return $response->toArray()['stargazers_count'];
    } catch (ExceptionInterface $exception) {
        // red caída, timeout, 5xx de GitHub...
        throw new GithubUnavailableException('GitHub no responde', previous: $exception);
    }
}

ExceptionInterface es la madre de todas las excepciones del componente, así que con un solo catch cubres red, timeouts y respuestas de error. Si prefieres mirar tú el código sin que salte nada, pasa false: $response->getContent(false) o $response->toArray(false).

Ojo con una trampa: si haces una petición y nunca lees la respuesta ni su código, y resulta que era un error, la excepción salta igual cuando el objeto se destruye. Es a propósito: así un POST fallido no se pierde en silencio.

Timeouts: no esperes para siempre

Si la API de la que dependes se queda colgada, tu página también se queda colgada, y con ella el proceso de PHP que la atiende. Con unas cuantas visitas a la vez, te quedas sin procesos libres y se cae tu web por culpa de la de otro. Por eso conviene poner siempre un límite:

$response = $this->httpClient->request('GET', $url, [
    'timeout' => 2,       // segundos máximos sin recibir nada
    'max_duration' => 5,  // segundos máximos de la petición completa
]);

timeout mide el tiempo sin actividad: si el servidor va mandando datos poco a poco, no salta. max_duration pone un tope a todo. Cuando se pasa cualquiera de los dos, se lanza una TransportExceptionInterface, que ya capturabas en el ejemplo anterior.

Un cliente por API: scoped clients

Repetir la URL base, el token y las cabeceras en cada llamada es un buen sitio para equivocarse. Lo limpio es definir un cliente para cada API en la configuración:

# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            github.client:
                base_uri: 'https://api.github.com'
                headers:
                    Accept: 'application/vnd.github+json'
                auth_bearer: '%env(GITHUB_TOKEN)%'
                timeout: 2
                max_duration: 5

Symfony crea un servicio github.client y lo inyecta si llamas al argumento con el mismo nombre en camelCase, $githubClient:

public function __construct(private HttpClientInterface $githubClient)
{
}

public function stars(string $owner, string $repository): int
{
    // la URL es relativa a base_uri, y el token y las cabeceras ya van puestos
    return $this->githubClient
        ->request('GET', "/repos/{$owner}/{$repository}")
        ->toArray()['stargazers_count'];
}

Ventaja extra: el token solo viaja a api.github.com. Si con ese cliente pides una URL absoluta de otro dominio, no se le añaden las opciones del scope, así que no lo filtras por error.

Reintentos automáticos

Las APIs fallan a ratos: un 503 durante un despliegue, un 429 porque has ido demasiado rápido (lo contaba en rate limiting en Symfony). Muchas veces basta con esperar un poco y volver a intentarlo. No hace falta programarlo:

framework:
    http_client:
        scoped_clients:
            github.client:
                base_uri: 'https://api.github.com'
                retry_failed:
                    max_retries: 3
                    delay: 1000      # milisegundos antes del primer reintento
                    multiplier: 2    # 1 s, 2 s, 4 s...

Por defecto reintenta los errores temporales (429, 500, 502, 503, 504…) y respeta la cabecera Retry-After si la API la envía. Los GET se reintentan sin miedo; con los POST ten cuidado de que la API no te cobre o te cree el pedido dos veces, que es justo el problema de la idempotencia.

Varias peticiones a la vez

Esta es la parte que más sorprende. request() no espera a la respuesta: lanza la petición y vuelve enseguida. Solo se bloquea cuando lees algo (getStatusCode(), toArray()…). Así que si lanzas varias antes de leer ninguna, viajan en paralelo:

$repositories = ['symfony', 'console', 'http-client', 'messenger', 'cache'];

// 1. se lanzan las cinco, sin esperar
$responses = [];
foreach ($repositories as $repository) {
    $responses[$repository] = $this->githubClient->request('GET', "/repos/symfony/{$repository}");
}

// 2. se leen: aquí es donde se espera, pero a todas a la vez
$stars = [];
foreach ($responses as $repository => $response) {
    $stars[$repository] = $response->toArray()['stargazers_count'];
}

Si cada petición tarda medio segundo, en fila serían dos segundos y medio. Así, el total es más o menos lo que tarde la más lenta. Sin hilos, sin promesas y sin librerías extra.

Si lo que tienes que hacer es más largo que unas peticiones en paralelo (cientos de llamadas, o algo que el usuario no necesita esperar), sácalo de la petición con Messenger.

Probarlo sin salir a internet

Un test que llama a la API de verdad es lento, depende de que GitHub esté arriba y puede gastarte cuota. Para eso existe MockHttpClient: implementa la misma interfaz y devuelve las respuestas que tú le digas.

use App\Github\RepositoryStats;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class RepositoryStatsTest extends TestCase
{
    public function testReturnsTheNumberOfStars(): void
    {
        $httpClient = new MockHttpClient([
            new MockResponse(json_encode(['stargazers_count' => 42])),
        ]);

        $stats = new RepositoryStats($httpClient);

        $this->assertSame(42, $stats->stars('symfony', 'symfony'));
    }

    public function testReturnsNullWhenTheRepositoryDoesNotExist(): void
    {
        $httpClient = new MockHttpClient([
            new MockResponse('{"message": "Not Found"}', ['http_code' => 404]),
        ]);

        $stats = new RepositoryStats($httpClient);

        $this->assertNull($stats->stars('symfony', 'does-not-exist'));
    }
}

Cada MockResponse se devuelve en orden, una por petición. Con el http_code puedes simular un 404, un 500 o lo que quieras, y comprobar que tu código se comporta bien cuando la API falla, que es justo lo que nunca pruebas a mano.

El resumen

Necesito… Uso
Hacer una petición $httpClient->request('GET', $url)
Leer un JSON $response->toArray()
Enviar parámetros o datos query, json, body, headers
Capturar cualquier fallo catch (ExceptionInterface $e)
No quedarme colgado timeout y max_duration
URL base, token y cabeceras en un sitio scoped_clients
Reintentar errores temporales retry_failed
Varias peticiones en paralelo Lanzarlas todas y leerlas después
Tests sin red MockHttpClient y MockResponse

Y ya está

Hacer una petición HTTP es una línea. Hacerla bien son unas pocas más: un cliente por API en la configuración, un timeout, reintentos para los fallos pasajeros y un catch que traduzca los errores a algo que entienda tu aplicación. Con HttpClient todo eso ya viene de serie, así que no hay excusa para dejar que la web de otro tumbe la tuya.

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.

Categorías del blog