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_beareryauth_basic: autenticación sin tener que montar la cabeceraAuthorizationa mano.
Cuando algo sale mal
Aquí está la parte que más se descuida. HttpClient distingue dos tipos de problema:
- No ha habido respuesta: el servidor no existe, no contesta o se ha
cortado la conexión. Eso lanza una
TransportExceptionInterface. - Ha habido respuesta, pero de error: un 404, un 401, un 500… Al leer el
contenido con
getContent()otoArray()se lanza unaClientExceptionInterface(4xx) o unaServerExceptionInterface(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.