Symfony HttpClient — consommer des APIs comme un ninja
TL;DR —
symfony/http-clientest le client HTTP officiel : performant (curlmulti par défaut), non-bloquant (concurrence native viastream()), instrumenté (logs, profiler), composable (decorators pour retry, rate limit, cache, scoping). Il bat Guzzle sur la concurrence et le streaming, et il est natif Symfony — donc traçable dans le profiler sans configuration. Pour aller en prod : scoping clients par API,RetryableHttpClient+RateLimiter, etMockHttpClienten CI. Évitezfile_get_contents()etcURLbrut — vous perdez observabilité et résilience.
🧠 Mental model — ASCII + analogie
Pensez au HttpClient comme à une flotte de drones de livraison. Vous lui donnez une adresse (URL) + une charge utile (body), il s'envole, et il revient avec un ResponseInterface. La magie : par défaut, la requête est lazy — elle ne part vraiment que quand vous lisez getContent() ou getStatusCode(). Tant que vous ne lisez pas, vous pouvez en lancer 10 en parallèle (curl_multi_* derrière), puis attendre les réponses qui arrivent dans l'ordre où elles finissent (stream()).
┌──────────────────────────────────────────────────────────────────┐
│ Vous (votre service métier) │
└────────────────────────────┬────────────────────────────────────┘
│ $client->request('GET', $url);
▼
┌─────────────────────────────────┐
│ HttpClientInterface │ ← contrat unique
└───────┬─────────────┬───────────┘
│ │
décorateurs ──▶ │ │ ◀── named clients (scoping)
▼ ▼
┌────────────────────────────────────────────┐
│ ScopingHttpClient / RetryableHttpClient │
│ RateLimiterHttpClient / CachingHttp... │
│ TraceableHttpClient (profiler) │
└─────────────────────┬──────────────────────┘
▼
┌──────────────────────────────┐
│ CurlHttpClient (par défaut) │
│ ou NativeHttpClient │
│ ou AmpHttpClient │
└──────────────┬───────────────┘
│
▼
┌──────────┐
│ Internet │
└──────────┘L'analogie clé : request() = "préparer un drone", getContent() = "récupérer le colis". Entre les deux, vous pouvez préparer plusieurs drones. Avec stream(), vous traitez les colis dans l'ordre d'arrivée, pas dans l'ordre d'envoi.
🛠️ Code minimal (PHP 8.2+)
Installation et usage de base
composer require symfony/http-client
# Optionnel mais recommandé pour performance maximale
# php-curl doit être installé côté système<?php
use Symfony\Component\HttpClient\HttpClient;
$client = HttpClient::create([
'headers' => ['Accept' => 'application/json'],
'timeout' => 5.0, // socket idle timeout
'max_duration' => 15.0, // hard ceiling pour la requête
'http_version' => '2.0', // négocie HTTP/2 si possible
]);
$response = $client->request('GET', 'https://api.example.com/users/42', [
'auth_bearer' => 'TOKEN_ABC',
'query' => ['expand' => 'profile'],
]);
if ($response->getStatusCode() === 200) {
$data = $response->toArray(); // décode JSON et throw si erreur
}Injection en service (autowiring + named clients)
# config/packages/framework.yaml
framework:
http_client:
max_host_connections: 6 # connexions parallèles par host
default_options:
timeout: 5
max_duration: 15
headers:
User-Agent: 'learning-hub/1.0'
scoped_clients:
github.client:
base_uri: 'https://api.github.com'
headers:
Accept: 'application/vnd.github+json'
Authorization: 'Bearer %env(GITHUB_TOKEN)%'
max_host_connections: 4
stripe.client:
base_uri: 'https://api.stripe.com'
auth_basic: ['%env(STRIPE_KEY)%', '']
retry_failed:
max_retries: 3<?php
namespace App\Integration\GitHub;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class GitHubClient
{
public function __construct(
// autowire par nom — récupère le scoped client "github.client"
private HttpClientInterface $githubClient,
) {}
public function getUser(string $login): array
{
return $this->githubClient
->request('GET', "/users/{$login}")
->toArray();
}
}Le nom de l'argument $githubClient est dérivé automatiquement de github.client (camelCase) — l'autowiring fait le binding.
Concurrence — N requêtes en parallèle
use Symfony\Contracts\HttpClient\HttpClientInterface;
function fetchAll(HttpClientInterface $client, array $urls): array
{
// 1. Lance toutes les requêtes (lazy)
$responses = [];
foreach ($urls as $url) {
$responses[$url] = $client->request('GET', $url);
}
// 2. Itère dans l'ordre d'arrivée (pas d'envoi)
$results = [];
foreach ($client->stream($responses) as $response => $chunk) {
if ($chunk->isLast()) {
$url = array_search($response, $responses, true);
$results[$url] = $response->toArray();
}
}
return $results;
}Le stream() itère sur les chunks (morceaux de réponse) au fur et à mesure qu'ils arrivent. Pour du streaming pur (ex : télécharger un gros fichier), on traite chaque $chunk sans attendre isLast().
Streaming d'une grosse réponse vers un fichier
$response = $client->request('GET', 'https://example.com/big.zip');
$fh = fopen('/tmp/big.zip', 'wb');
foreach ($client->stream($response) as $chunk) {
fwrite($fh, $chunk->getContent());
}
fclose($fh);RAM constante, peu importe la taille du fichier.
🎯 Patterns courants — 5
1. RetryableHttpClient — retry exponentiel
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Component\HttpClient\RetryableHttpClient;
$base = HttpClient::create();
$strategy = new GenericRetryStrategy(
statusCodes: [423, 425, 429, 500, 502, 503, 504, 507, 510],
delayMs: 500,
multiplier: 2.0,
maxDelayMs: 30_000,
jitter: 0.2,
);
$client = new RetryableHttpClient($base, $strategy, maxRetries: 4);
// Avec scoped client
// framework.yaml :
// scoped_clients:
// github.client:
// base_uri: 'https://api.github.com'
// retry_failed:
// max_retries: 4
// delay: 500
// multiplier: 2
// max_delay: 30000
// jitter: 0.2
// http_codes: [429, 500, 502, 503, 504]Le jitter (variation aléatoire) est crucial : sans, vos N workers se synchronisent et martèlent l'API au même millième de seconde. Avec jitter, ils se dispersent.
Strategy personnalisée
<?php
namespace App\Integration\Stripe;
use Symfony\Component\HttpClient\Retry\RetryStrategyInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class StripeRetryStrategy implements RetryStrategyInterface
{
public function shouldRetry(
ResponseInterface $response,
?string $responseContent,
?TransportExceptionInterface $exception,
): ?bool {
// Réseau qui tombe : retry
if ($exception !== null) {
return true;
}
$status = $response->getStatusCode();
// 429 = rate limited — toujours retry
if ($status === 429) {
return true;
}
// 4xx (sauf 429) = pas la peine, faute client
if ($status >= 400 && $status < 500) {
return false;
}
// 5xx = serveur, retry
return $status >= 500;
}
public function getDelay(
ResponseInterface $response,
?string $responseContent,
?TransportExceptionInterface $exception,
): int {
// Respect du header Retry-After si présent
$retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
if ($retryAfter !== null && is_numeric($retryAfter)) {
return (int) $retryAfter * 1000; // en ms
}
return 1000; // 1s par défaut
}
}2. Rate limiting (côté client)
symfony/rate-limiter + decorator HttpClient.
framework:
rate_limiter:
github_api:
policy: 'token_bucket'
limit: 5_000
rate: { interval: '1 hour', amount: 5_000 }<?php
namespace App\Integration\GitHub;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class RateLimitedHttpClient implements HttpClientInterface
{
public function __construct(
private HttpClientInterface $inner,
private RateLimiterFactory $githubApi,
) {}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$limiter = $this->githubApi->create('global');
// reserve() rend une Reservation : si le bucket est vide,
// wait() bloque jusqu'à ce qu'un jeton soit dispo.
// (consume() ne bloque PAS — il faut tester isAccepted() soi-même.)
$reservation = $limiter->reserve(1);
$reservation->wait();
return $this->inner->request($method, $url, $options);
}
public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): \Symfony\Contracts\HttpClient\ResponseStreamInterface
{
return $this->inner->stream($responses, $timeout);
}
public function withOptions(array $options): static
{
return new self($this->inner->withOptions($options), $this->githubApi);
}
}À déclarer dans services.yaml comme decorator du github.client. Combinez avec retry : si l'API renvoie 429 malgré le rate limit côté client (parce que d'autres apps frappent), le retry s'occupe du reste.
Bloquer ou échouer ?
reserve()->wait()bloque le worker — acceptable pour un job batch, dangereux pour une requête web (vous tenez un PHP-FPM worker en otage). Sur le chemin HTTP synchrone, préférezconsume(1)+if (!$limit->isAccepted()) throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time()): vous propagez la pression vers l'appelant au lieu de l'absorber silencieusement. Règle staff : un rate limiter qui bloque déplace le problème de latence sans le résoudre — il transforme un 429 visible en p99 invisible.
3. MockHttpClient — tests sans réseau
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
$mock = new MockHttpClient([
new MockResponse(json_encode(['id' => 42, 'login' => 'octocat']), [
'http_code' => 200,
'response_headers' => ['Content-Type' => 'application/json'],
]),
new MockResponse('', ['http_code' => 404]),
]);
$client = new GitHubClient($mock);
$user = $client->getUser('octocat');
self::assertSame(42, $user['id']);Les responses sont consommées dans l'ordre. Pour un mock par URL, passez un callable :
$mock = new MockHttpClient(function (string $method, string $url, array $options): MockResponse {
if (str_contains($url, '/users/octocat')) {
return new MockResponse(json_encode(['id' => 42]), ['http_code' => 200]);
}
return new MockResponse('not found', ['http_code' => 404]);
});4. TraceableHttpClient + profiler
En dev, le bundle injecte automatiquement un TraceableHttpClient qui logge chaque requête (URL, méthode, durée, body) dans le profiler. Onglet HTTP Client dans la toolbar : ultra utile pour identifier les "10 appels Stripe par page" et les remplacer par du caching.
En prod, il est désactivé pour des raisons de mémoire (toutes les requêtes en RAM). Mais vous pouvez activer du logging :
services:
Symfony\Component\HttpClient\HttpClient:
arguments:
$logger: '@logger'Ou plus simple : passer un LoggerInterface au HttpClient::create([]) constructor — chaque requête sera loggée en INFO avec URL + durée + statut.
5. Circuit breaker pattern (manuel)
Symfony n'a pas de circuit breaker natif (contrairement à resilience4j en Java), mais on l'implémente proprement avec un store atomique. Trois états : closed (tout passe), open (on coupe court, fail fast), half-open (on laisse passer une requête sonde pour tester si l'amont est revenu). Le point critique est l'atomicité du compteur : sous concurrence, un get + set non atomique perd des incréments et le circuit ne s'ouvre jamais. On utilise donc Redis INCR (atomique) plutôt que le cache PSR-6.
<?php
namespace App\Integration\Resilience;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
/**
* Circuit breaker minimal mais correct (état partagé via Redis, compteur atomique).
* États : closed → (échecs ≥ seuil) → open → (après cooldown) → half-open → closed|open.
*/
final class CircuitBreakerHttpClient implements HttpClientInterface
{
private const FAILURE_THRESHOLD = 5;
private const OPEN_DURATION = 60; // secondes de cooldown avant half-open
public function __construct(
private HttpClientInterface $inner,
private \Redis $redis,
private string $serviceKey,
) {}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$openUntilKey = "cb:{$this->serviceKey}:open_until";
$openUntil = (int) ($this->redis->get($openUntilKey) ?: 0);
if ($openUntil > time()) {
// Circuit OPEN → fail fast, on ne touche même pas le réseau.
throw new CircuitOpenException($this->serviceKey, $openUntil - time());
}
try {
$response = $this->inner->request($method, $url, $options);
$response->getStatusCode(); // force la résolution (lazy → réel)
$this->recordSuccess(); // half-open réussit → on referme
return $response;
} catch (HttpExceptionInterface|TransportException $e) {
$this->recordFailure($openUntilKey);
throw $e;
}
}
private function recordFailure(string $openUntilKey): void
{
$failKey = "cb:{$this->serviceKey}:failures";
// INCR est atomique : pas de race entre workers concurrents.
$failures = $this->redis->incr($failKey);
$this->redis->expire($failKey, self::OPEN_DURATION); // fenêtre glissante grossière
if ($failures >= self::FAILURE_THRESHOLD) {
$this->redis->set($openUntilKey, time() + self::OPEN_DURATION);
$this->redis->del($failKey);
}
}
private function recordSuccess(): void
{
// Une requête a réussi : on remet le compteur à zéro (sort de half-open).
$this->redis->del("cb:{$this->serviceKey}:failures");
}
public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->inner->stream($responses, $timeout);
}
public function withOptions(array $options): static
{
return new self($this->inner->withOptions($options), $this->redis, $this->serviceKey);
}
}
final class CircuitOpenException extends \RuntimeException
{
public function __construct(string $service, public readonly int $retryAfterSeconds)
{
parent::__construct("Circuit open for '{$service}' (retry in {$retryAfterSeconds}s)");
}
}Pourquoi un circuit breaker en plus du retry ? Le retry protège contre les pannes transitoires (un 503 isolé). Le circuit breaker protège contre les pannes soutenues : quand l'amont est mort, retry × N workers = vous DDoS votre propre dépendance et vous épuisez vos workers en attendant des timeouts. Le breaker coupe en O(1) (un GET Redis) au lieu de payer le timeout réseau. Ordre de composition recommandé : CircuitBreaker( Retryable( RateLimited( Curl ) ) ) — le breaker à l'extérieur pour court-circuiter avant de dépenser le budget retry.
En production, préférez la lib
ackintosh/ganesha: elle implémente une vraie fenêtre glissante temporelle (vs le compteur grossier ci-dessus), le half-open avec probabilité de sonde, et des adapters Redis/APCu/Memcached testés sous charge.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
| Version | Apport principal |
|---|---|
| 5.4 (LTS) | API HttpClient stable. MockHttpClient, ScopingHttpClient, RetryableHttpClient, TraceableHttpClient tous présents. EventSourceHttpClient pour SSE. |
| 6.0–6.4 | AsyncDecoratorTrait pour écrire des decorators async-friendly. Améliorations MockHttpClient (matcher par URL plus simple). |
| 6.4 (LTS) | Améliorations sur request()-level retry strategy et options extra pour custom data. |
| 7.0 | Nettoyage deprecations 6.x. Nouvelles signatures strictes (stream() accepte uniquement `iterable |
| 7.1+ | vars pour interpoler base_uri ('https://{region}.example.com' + vars: { region: 'eu' }). Très pratique pour APIs régionales. |
| 7.2+ | Améliorations sur Server-Sent Events (EventSourceHttpClient). |
Composants liés :
symfony/rate-limiter(depuis 5.2) — décorateur HttpClient possible.symfony/http-client-contracts— l'interface partagée (Symfony et libs tierces peuvent l'implémenter).
⚠️ Pitfalls — 8
- Oublier que
request()est lazy. Si vous ne lisez jamaisgetStatusCode()ougetContent(), la requête ne part pas. Bug classique en oubliant unreturn. max_durationmal configuré. Sans cap, une API lente bloque votre worker. Toujours fixermax_duration(hard timeout, distinct detimeoutqui est idle).- Mélanger
cURLbrut et HttpClient. Vous perdez l'instrumentation. Tout passer par HttpClient — y compris les téléchargements de fichiers. - Retry sur des opérations non-idempotentes. Rejouer un
POST /chargesStripe = double facturation. Vérifiez côté API si unIdempotency-Keyest supporté. json_decodeà la main.$response->toArray()est plus sûr : il vérifie le content-type et lève une exception structurée si JSON invalide.- Header
User-Agentvide. Beaucoup d'APIs (GitHub) renvoient403sans UA. Toujours en mettre un identifiable. - Pas de logging en prod. Une intégration silencieuse = un incident invisible. Loggez au moins les erreurs 5xx et les retries.
- Confondre
max_host_connectionsetmax_pending_pushes. La première limite la concurrence par hostname (donc impacte les performances). La seconde concerne HTTP/2 server push (rarement utilisé). - Mémoire qui explose en streaming. Si vous bufferez les chunks dans une variable au lieu de les écrire au fur et à mesure, vous reproduisez le bug que le streaming devait éviter.
- Mock incorrect en test :
MockResponseretourne 200 par défaut, même si vous voulez tester un 503. Toujours expliciterhttp_code.
🧪 Testing — phpunit + KernelTestCase
Test unitaire avec MockHttpClient
<?php
namespace App\Tests\Integration\GitHub;
use App\Integration\GitHub\GitHubClient;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class GitHubClientTest extends TestCase
{
public function testGetUserParsesJson(): void
{
$callable = function (string $method, string $url, array $options): MockResponse {
self::assertSame('GET', $method);
self::assertStringEndsWith('/users/octocat', $url);
return new MockResponse(
json_encode(['id' => 42, 'login' => 'octocat']),
['response_headers' => ['Content-Type' => 'application/json']],
);
};
$client = new GitHubClient(new MockHttpClient($callable));
$user = $client->getUser('octocat');
self::assertSame(42, $user['id']);
self::assertSame('octocat', $user['login']);
}
public function testGetUserHandles404(): void
{
$mock = new MockHttpClient([
new MockResponse('Not Found', ['http_code' => 404]),
]);
$client = new GitHubClient($mock);
$this->expectException(\Symfony\Component\HttpClient\Exception\ClientException::class);
$client->getUser('ghost-user');
}
}Test d'intégration avec KernelTestCase
<?php
namespace App\Tests\Integration\GitHub;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class GitHubKernelTest extends KernelTestCase
{
public function testServiceIsAutowiredWithScopedClient(): void
{
self::bootKernel();
$container = static::getContainer();
// Override avec un MockHttpClient pour neutraliser le réseau
$mock = new MockHttpClient(new MockResponse(json_encode(['id' => 1])));
$container->set('github.client', $mock);
$service = $container->get(\App\Integration\GitHub\GitHubClient::class);
$result = $service->getUser('any');
self::assertSame(1, $result['id']);
}
}Comparaison vs Guzzle
| Critère | symfony/http-client | guzzlehttp/guzzle |
|---|---|---|
| Performance HTTP/2 multi | ✅ excellent (curl_multi) | ✅ correct |
| Concurrence native | ✅ stream() simple | ⚠️ Pool un peu verbeux |
| Streaming | ✅ chunks | ✅ |
| Instrumentation Symfony | ✅ natif (profiler) | ❌ middleware à écrire |
| Écosystème PSR-18 | ✅ via HttplugClient | ✅ natif |
| Découpe en decorators | ✅ idiomatique | ❌ middleware stack à la place |
| Mock testing | ✅ MockHttpClient simple | ✅ MockHandler mais verbeux |
| Communauté hors Symfony | Symfony only | massive (Laravel, etc.) |
Pour un projet Symfony, symfony/http-client est toujours le bon choix — moins de boilerplate, intégration native.
🎬 Cas d'usage concrets
Scénario 1 — Intégration API Société Générale (Open Banking DSP2)
Une fintech française agrège les comptes bancaires de ses utilisateurs via Société Générale Open Banking. L'authentification est OAuth2 + mTLS (eIDAS Qualified Web Authentication Certificate). HttpClient est configuré via une scope sg_open_banking avec cert/key PEM stockés dans Symfony Vault, base_uri: https://api.societegenerale.fr/dsp2/v1/, headers: ['Accept' => 'application/json'], et un RetryableHttpClient avec backoff exponentiel sur les 5xx. Un decorator OAuth2TokenRefreshingHttpClient rafraîchit le access_token 60s avant expiration. Le LoggerInterface est injecté pour tracer chaque requête (request_id, status, durée). Les réponses sont mappées en DTO Account/Transaction via le Serializer. Les WebHooks de la SG (notification d'un nouveau consentement, expiration) arrivent en POST sur /webhook/sg-dsp2 et sont vérifiés par signature HMAC. Performance : 90% des appels < 400ms (côté SG), HttpClient overhead < 5ms. Concurrent : la synchro d'un compte (12 endpoints à appeler) est faite en parallèle via stream() — 12 × 400ms séquentiel → 450ms en parallèle.
Scénario 2 — Intégration Legifrance cabinet (API juridique officielle)
Un cabinet d'avocats consomme l'API Legifrance (PISTE de la DILA) pour rechercher des textes de loi, décrets, jurisprudences administratives. L'authentification est OAuth2 client_credentials (token valide 30 min). HttpClient scope legifrance avec base_uri: https://api.piste.gouv.fr/dila/legifrance/lf-engine-app/, retry sur 502/503 (Legifrance a parfois des hoquets), cache HTTP local pour les requêtes par identifiant (un décret n'évolue pas — TTL 24h). Le MockHttpClient est utilisé en tests avec des fixtures de réponses Legifrance archivées. Une seconde API consommée : Pappers (registre commerce) pour vérifier les sociétés citées dans les dossiers. Le RateLimiterFactory (5 req/s côté Pappers free tier) régule via un decorator RateLimitingHttpClient. Les recherches Code civil les plus fréquentes sont mises en cache Redis 1h. L'intégration permet de pré-remplir les conclusions du cabinet avec les références officielles citées (gain : 30 min/dossier de recherche).
Scénario 3 — Intégration Mirakl e-commerce (marketplace multi-marchands)
Une marketplace (Cdiscount, Carrefour Marketplace, Boulanger) consomme l'API Mirakl pour synchroniser les offres marchands, commandes, retours. Le volume est conséquent : 200k offres synchronisées 4x/jour. HttpClient scope mirakl avec base_uri marketplace tenant, header Authorization: Bearer {mirakl_api_key}, et timeout: 30s (les exports Mirakl renvoient parfois de gros fichiers). Le téléchargement de fichiers de catalogue (OF24 endpoint) se fait en streaming pour ne pas saturer la mémoire (->stream() + traitement chunk par chunk). Pour les uploads (catalog products), le multipart/form-data est généré via FormDataPart + DataPart. La Pool parallel est utilisée pour les imports : 50 produits en parallèle vs sequential. L'application surveille les 429 Too Many Requests Mirakl et applique automatiquement le Retry-After. Les webhooks Mirakl (nouvelle commande) arrivent sur /webhook/mirakl avec signature HMAC — la signature est vérifiée via un EventSubscriber. Tests : MockHttpClient avec scénarios de fixtures Mirakl, plus tests de contrat (Pact) vs un sandbox Mirakl real.
🛠️ Exemple end-to-end
Cas : intégration API DSP2 Société Générale avec OAuth2 refresh + retry + cache + concurrence + tests.
<?php
// src/Integration/SocieteGenerale/AccountsClient.php
declare(strict_types=1);
namespace App\Integration\SocieteGenerale;
use App\Integration\SocieteGenerale\Dto\Account;
use App\Integration\SocieteGenerale\Dto\Transaction;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class AccountsClient
{
public function __construct(
private HttpClientInterface $sgClient, // scope auto-injecté
private SerializerInterface $serializer,
) {}
/**
* @return Account[]
*/
public function listAccounts(string $consentId): array
{
$response = $this->sgClient->request('GET', 'aisp/accounts', [
'headers' => ['Consent-ID' => $consentId, 'X-Request-ID' => bin2hex(random_bytes(8))],
]);
return $this->serializer->deserialize(
$response->getContent(),
Account::class . '[]',
'json',
);
}
/**
* Charge en parallèle les comptes + transactions du dernier mois pour chacun.
*
* @return array<string, Transaction[]>
*/
public function loadAccountsWithTransactions(string $consentId): array
{
$accounts = $this->listAccounts($consentId);
$responses = [];
foreach ($accounts as $account) {
$responses[$account->iban] = $this->sgClient->request(
'GET',
sprintf('aisp/accounts/%s/transactions', $account->iban),
[
'query' => ['dateFrom' => (new \DateTimeImmutable('-30 days'))->format('Y-m-d')],
'headers' => ['Consent-ID' => $consentId],
],
);
}
$result = [];
foreach ($this->sgClient->stream($responses, timeout: 5.0) as $response => $chunk) {
if (!$chunk->isLast()) {
continue;
}
$iban = array_search($response, $responses, true);
$result[$iban] = $this->serializer->deserialize(
$response->getContent(),
Transaction::class . '[]',
'json',
);
}
return $result;
}
}# config/packages/http_client.yaml
framework:
http_client:
default_options:
timeout: 10
max_duration: 30
scoped_clients:
sg_open_banking.client:
base_uri: '%env(SG_OPENBANKING_BASE_URI)%'
headers:
Accept: 'application/json'
User-Agent: 'fintech-aggregator/2.4 ([email protected])'
local_cert: '%env(SG_MTLS_CERT_PATH)%'
local_pk: '%env(SG_MTLS_KEY_PATH)%'
retry_failed:
max_retries: 3
delay: 1000
multiplier: 2
http_codes: [502, 503, 504, 429]<?php
// src/Integration/SocieteGenerale/OAuth2HttpClientDecorator.php
declare(strict_types=1);
namespace App\Integration\SocieteGenerale;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsDecorator(decorates: 'sg_open_banking.client')]
final class OAuth2HttpClientDecorator implements HttpClientInterface
{
public function __construct(
#[AutowireDecorated] private HttpClientInterface $inner,
private CacheInterface $cache,
private TokenEndpoint $tokenEndpoint,
) {}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$token = $this->cache->get('sg.oauth2.token', function (ItemInterface $item): string {
$item->expiresAfter(3300); // 60s avant les 3600s vrai
return $this->tokenEndpoint->fetchClientCredentialsToken();
});
$options['headers']['Authorization'] = 'Bearer ' . $token;
return $this->inner->request($method, $url, $options);
}
public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): \Symfony\Contracts\HttpClient\ResponseStreamInterface
{
return $this->inner->stream($responses, $timeout);
}
public function withOptions(array $options): static
{
return new self($this->inner->withOptions($options), $this->cache, $this->tokenEndpoint);
}
}<?php
// tests/Integration/SocieteGenerale/AccountsClientTest.php
declare(strict_types=1);
namespace App\Tests\Integration\SocieteGenerale;
use App\Integration\SocieteGenerale\AccountsClient;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
final class AccountsClientTest extends TestCase
{
public function testListAccountsReturnsTypedDtos(): void
{
$mock = new MockHttpClient([
new MockResponse(json_encode([
['iban' => 'FR7630001007941234567890185', 'currency' => 'EUR', 'balance' => '1234.56'],
['iban' => 'FR7630001007941234567890196', 'currency' => 'EUR', 'balance' => '780.00'],
])),
]);
$client = new AccountsClient(
$mock,
new Serializer([new ObjectNormalizer()], [new JsonEncoder()]),
);
$accounts = $client->listAccounts('consent-abc');
self::assertCount(2, $accounts);
self::assertSame('FR7630001007941234567890185', $accounts[0]->iban);
}
}Couverture : client scope dédié + decorator OAuth2 + appels concurrents + cache token + mock-based tests. Pattern réplicable pour Legifrance, Mirakl, ou toute API tierce.
📊 Observabilité & production — comment un staff engineer raisonne
Une intégration HTTP sans télémétrie est une boîte noire qui finira par causer un incident silencieux. Le réflexe staff : chaque appel sortant doit produire 4 signaux — durée, statut, retry-count, et un identifiant de corrélation.
Timeouts : le piège du modèle mental
timeout et max_duration ne mesurent pas la même chose, et confondre les deux est la cause n°1 de workers bloqués.
| Option | Mesure | Reset ? | Sert à se protéger de |
|---|---|---|---|
timeout | inactivité (idle) sur le socket | ✅ remis à zéro à chaque octet reçu | une connexion qui gèle |
max_duration | durée totale wall-clock de la requête | ❌ plafond absolu | une réponse lente mais vivante (qui trickle un octet/s) |
connect_timeout (option extra/curl) | établissement TCP+TLS seulement | — | un host injoignable |
Conséquence : avec timeout: 5 seul, une API qui renvoie 1 octet toutes les 4 secondes pendant 10 minutes ne déclenche jamais le timeout. Il faut max_duration pour borner. Règle : max_duration ≈ votre budget de latence p99 acceptable, jamais infini.
Propagation de contexte (distributed tracing)
En microservices, propagez un X-Request-ID / traceparent (W3C Trace Context) sur tout appel sortant, sinon vous perdez la trace dès la frontière HTTP. Un decorator centralise ça :
<?php
namespace App\Integration\Observability;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
final readonly class TracingHttpClient implements HttpClientInterface
{
public function __construct(
private HttpClientInterface $inner,
private \Closure $currentTraceparent, // () => string, fourni par votre tracer
) {}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$options['headers']['traceparent'] = ($this->currentTraceparent)();
// 'on_progress' donne du temps réel : utile pour timeout custom / métriques.
$start = hrtime(true);
$options['on_progress'] = function (int $dl, int $dlTotal, array $info) use ($start): void {
// hook : exporter $info['url'], $info['http_code'], durée vers Prometheus/OTel
};
return $this->inner->request($method, $url, $options);
}
public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->inner->stream($responses, $timeout);
}
public function withOptions(array $options): static
{
return new self($this->inner->withOptions($options), $this->currentTraceparent);
}
}Métriques à exporter (RED method)
- Rate : req/s par scope (
github.client,stripe.client…) — un pic = bug ou boucle. - Errors : taux de 4xx vs 5xx vs
TransportException(réseau) par scope. Un 4xx massif = bug chez vous ; un 5xx massif = amont en panne (→ circuit breaker). - Duration : histogramme p50/p95/p99 par scope. Le
ResponseInterface::getInfo()exposetotal_time,connect_time,namelookup_time,pretransfer_time— décomposez pour distinguer "DNS lent" de "serveur lent".
$info = $response->getInfo();
// total_time, connect_time, namelookup_time, starttransfer_time, redirect_count, http_code...
$logger->info('outbound_http', [
'scope' => 'github',
'method' => $info['http_method'] ?? 'GET',
'url' => $info['url'],
'status' => $info['http_code'],
'total_ms' => (int) (($info['total_time'] ?? 0) * 1000),
'retries' => $info['retry_count'] ?? 0, // exposé par RetryableHttpClient
]);Sécurité — la checklist non négociable
- SSRF : ne passez jamais une URL d'origine utilisateur à
request()sans allowlist d'hôtes. Un attaquant viseraithttp://169.254.169.254/(metadata cloud) ouhttp://localhost:6379(Redis). Validez le host après résolution DNS (un DNS peut pointer 1.2.3.4 puis re-résoudre en 127.0.0.1 — DNS rebinding). - Vérification TLS :
verify_peer/verify_hostsonttruepar défaut. Les désactiver "pour que ça marche en dev" est la backdoor qui finit en prod. Pour un cert self-signed, fournissez le CA viacafile. - Fuite de secrets dans les logs : le
TraceableHttpClientlogge les headers. UnAuthorization: Bearer …en clair dans Kibana = incident. Filtrez les headers sensibles avant export. - Redirections :
max_redirects(défaut 20) suivies automatiquement. Une redirection cross-origin peut leaker votreAuthorizationheader vers un domaine tiers — HttpClient strippe les headers sensibles sur redirect cross-host, mais vérifiez-le si vous portez vos propres headers d'auth.
🏋️ Exercices
1. Concurrence bornée (implement)
Objectif : écrire fetchAllBounded(HttpClientInterface $c, array $urls, int $concurrency): array qui lance au plus $concurrency requêtes en vol simultanément (pas toutes d'un coup), en utilisant stream(). Indice/Solution : amorcez $concurrency requêtes, puis dans la boucle stream(), à chaque $chunk->isLast() dépilez l'URL suivante et lancez sa requête. Maintenez un index $next. Le secret : stream() accepte qu'on lui ajoute des réponses pendant l'itération si on les ajoute au tableau itéré — sinon, ré-appelez stream() sur le batch courant + les nouvelles. Vérifiez que vous ne dépassez jamais max_host_connections (sinon la borne est ignorée côté curl).
2. Decorator d'idempotence (production-grade)
Objectif : un IdempotentHttpClient qui, pour les POST/PUT/PATCH, génère un Idempotency-Key déterministe (hash du body) et le cache 24h, de sorte qu'un retry ne crée pas de doublon côté API. Indice/Solution : withOptions + intercepter dans request(). Clé = sha256($method.$url.$body). Stockez la réponse (statut + body) en cache sous cette clé ; au second appel identique dans la fenêtre, renvoyez un MockResponse reconstruit sans toucher le réseau. Attention : ne cachez que les réponses 2xx, et seulement pour les méthodes que l'API déclare idempotentes via Idempotency-Key.
3. Budget de retry global (production-grade)
Objectif : le RetryableHttpClient retry par requête, mais sous panne globale vos 50 workers retryent tous → effet thundering herd. Implémentez un token bucket partagé de retries (ex : max 100 retries/minute tous workers confondus) ; au-delà, on ne retry plus et on fail fast. Indice/Solution : RateLimiterFactory (token_bucket) consommé uniquement quand on s'apprête à retry, dans une RetryStrategyInterface::shouldRetry() custom. Si consume(1) n'est pas accepté → return false. Couplez avec un compteur Prometheus retry_budget_exhausted_total.
4. Casser puis réparer : le streaming qui OOM (break-then-fix)
Objectif : on vous donne un téléchargement de 4 Go qui fait exploser la RAM. Reproduisez le bug, puis corrigez-le. Indice/Solution : le bug — $content = ''; foreach ($client->stream($r) as $chunk) { $content .= $chunk->getContent(); } accumule tout en mémoire (et $response->getContent() aussi, par défaut, il buffer). Le fix : écrire chaque $chunk dans un fopen() immédiatement, et pour la réponse passer 'buffer' => false dans les options pour désactiver le buffering interne. Mesurez avec memory_get_peak_usage() avant/après — vous devez passer de ~4 Go à ~quelques Mo.
5. Circuit breaker sous concurrence (break-then-fix)
Objectif : prenez le CircuitBreakerHttpClient mais remplacez le Redis::incr atomique par un get+set PHP naïf. Lancez 20 requêtes concurrentes contre un mock qui échoue toujours et observez que le circuit ne s'ouvre pas de façon fiable (incréments perdus). Puis re-corrigez avec une opération atomique. Indice/Solution : le read-modify-write non atomique perd des incréments sous concurrence (lost update). Démontrez-le avec un test parallèle (pcntl_fork ou un simple bench). Le fix : INCR Redis, ou un script Lua EVAL si vous voulez incrémenter+lire+ouvrir atomiquement en un aller-retour.
6. Contract testing vs SDK officiel (architect)
Objectif : votre StripeClient maison dérive du SDK officiel après une mise à jour API. Mettez en place un test de contrat (Pact ou fixtures versionnées) qui échoue en CI dès que la forme de réponse change. Indice/Solution : enregistrez (record) des réponses réelles du sandbox une fois, rejouez-les via MockHttpClient en CI, et faites un diff de schéma (ex : justinrainbow/json-schema). Bonus staff : un job nightly qui frappe le vrai sandbox et alerte si le contrat enregistré diverge — détecte les breaking changes amont avant la prod.
🎤 En entretien
Q : Pourquoi request() est-il lazy, et quelle classe de bugs ça crée ? R : Pour permettre la concurrence — on accumule N requêtes "préparées" puis on les exécute en parallèle via curl_multi dès qu'on lit la première réponse. Le bug : si on ne lit jamais getStatusCode()/getContent()/toArray(), la requête ne part pas (un POST "fire-and-forget" oublié), et les exceptions HTTP ne sont levées qu'au moment de la lecture, pas de l'appel.
Q : Retry, rate limiter et circuit breaker — lequel résout quoi, et dans quel ordre les composer ? R : Retry = pannes transitoires (503 isolé) ; rate limiter = respecter un quota (le sien ou celui de l'amont) ; circuit breaker = pannes soutenues (fail fast quand l'amont est mort, pour ne pas épuiser ses workers ni DDoS la dépendance). Ordre : CircuitBreaker(Retryable(RateLimited(client))) — le breaker à l'extérieur court-circuite avant de consommer le budget retry ; le rate limiter au plus près du transport pour compter les requêtes réellement émises.
Q : timeout vs max_duration — donnez le cas où l'un sauve et l'autre pas. R : timeout est un idle timeout (remis à zéro à chaque octet) ; max_duration est un plafond wall-clock absolu. Une API malveillante/cassée qui renvoie 1 octet toutes les 4 s avec timeout: 5 ne timeoutera jamais et tiendra votre worker indéfiniment — seul max_duration la coupe. Inverse : un host injoignable est mieux borné par connect_timeout.
Q : Comment éviter le double-charge lors d'un retry sur un POST de paiement ? R : Le retry ne doit s'appliquer qu'aux opérations idempotentes ; pour un POST mutant, on s'appuie sur l'Idempotency-Key (header) que les API sérieuses (Stripe) honorent : même clé = même résultat, le serveur déduplique. Sans support amont, on désactive le retry sur ces routes ou on implémente une déduplication côté client (cache de la réponse par hash de requête).
Q : Une URL fournie par l'utilisateur arrive dans request(). Quels risques, comment les fermer ? R : SSRF — l'attaquant vise les endpoints internes (169.254.169.254 metadata cloud, services en localhost). Défense : allowlist d'hôtes, blocage des IP privées/loopback/link-local après résolution DNS, et protection contre le DNS rebinding (revalider l'IP au moment de la connexion, pas seulement à la validation). Ne jamais désactiver verify_peer.
🔁 Quand utiliser / éviter
Utilisez HttpClient quand :
- Vous consommez n'importe quelle API externe (REST, GraphQL via POST, SSE).
- Vous voulez observabilité (profiler, logs structurés).
- Vous avez besoin de concurrence (paralléliser N appels).
- Vous voulez resilience (retry, rate limit) sans dépendances externes.
Évitez (ou ajoutez des libs spécifiques) si :
- API GraphQL complexe : prenez
softonic/graphql-clientau-dessus de HttpClient. - API très spécifique (Stripe, AWS) : utilisez le SDK officiel — il gère signature, retries, types.
- WebSockets : pas le bon outil. Utilisez
ratchet/pawloureact/socket. - Web scraping avec exécution JS : il faut un navigateur headless (Panther, Symfony BrowserKit avec Chrome).
🔗 Liens
- Doc HttpClient : https://symfony.com/doc/current/http_client.html
- Retry strategy : https://symfony.com/doc/current/http_client.html#retry-failed-requests
- Scoping clients : https://symfony.com/doc/current/http_client.html#scoping-client
- Rate Limiter : https://symfony.com/doc/current/rate_limiter.html
MockHttpClient: https://symfony.com/doc/current/http_client.html#testing-http-clients-and-responses- Ganesha (circuit breaker PHP) : https://github.com/ackintosh/ganesha