Disjoncteur et limite de concurrence pour Http::client() dans Laravel (circuit breaker + bulkhead)
Implémentez un circuit breaker et un bulkhead (limite de concurrence) sur Laravel Http::client(), sans package, pour stabiliser vos appels API tiers.
Sommaire
Disjoncteur et limite de concurrence pour Http::client() dans Laravel (circuit breaker + bulkhead)
Ce tutoriel montre comment stabiliser des intégrations HTTP externes dans Laravel sans ajouter de package, en combinant un disjoncteur (circuit breaker) et une limite de concurrence (bulkhead). Vous repartirez avec un service prêt à l’emploi, configurable, testable et compatible Laravel 9/10/11.
Objectif
L’objectif est de mettre en place un disjoncteur léger et une limite de concurrence pour les appels vers des APIs tierces. Vous encapsulerez Http::client() avec un wrapper qui décide s’il est prudent d’appeler l’API, qui enregistre les succès/échecs, et qui limite le nombre d’appels simultanés par ressource pour éviter de saturer une dépendance.
Objectif et périmètre
Nous allons protéger des intégrations HTTP avec:
- Un circuit breaker à trois états (closed, open, half-open) par ressource, capable de s’ouvrir automatiquement après un nombre d’échecs dans une fenêtre temporelle, de se réessayer en half-open, puis de se refermer sur succès.
- Un bulkhead basé sur Redis::funnel() pour limiter la concurrence par ressource/endpoint.
Le résultat inclut:
- Une classe app/Support/CircuitBreaker.php avec canProceed(), reportSuccess(), reportFailure().
- Un wrapper app/Support/HttpBreaker.php qui exécute la requête et notifie le disjoncteur.
- Une limite de concurrence Redis::funnel() dans un service concret (GitHub).
- Des tests avec Http::fake(), du time travel et des cas limites.
Aucune dépendance externe n’est requise. L’usage de Redis est fortement recommandé pour la performance et la précision des TTL.
Pré-requis et configuration rapide
Commencez par activer un cache/lock performant. Dans .env, configurez Redis pour le cache et les limiteurs:
# .env
CACHE_DRIVER=redis
REDIS_CLIENT=phpredis
Créez un fichier de configuration pour centraliser les seuils et les tailles de bulkhead. Par exemple:
// config/http_integrations.php
<?php
return [
'defaults' => [
'failure_threshold' => env('HTTP_CB_FAILURE_THRESHOLD', 5),
'window_seconds' => env('HTTP_CB_WINDOW_SECONDS', 60),
'open_seconds' => env('HTTP_CB_OPEN_SECONDS', 30),
'half_open_max_attempts' => env('HTTP_CB_HALF_OPEN_ATTEMPTS', 1),
'bulkhead_size' => env('HTTP_CB_BULKHEAD_SIZE', 8),
'prefix' => env('HTTP_CB_PREFIX', 'cb:v1:'),
],
// Overrides par ressource si besoin
'resources' => [
// 'github-search' => [
// 'failure_threshold' => 3,
// 'bulkhead_size' => 12,
// ],
],
];
Avec cette configuration, vous pouvez par défaut tolérer 5 échecs en 60 secondes, ouvrir le circuit pendant 30 secondes, n’autoriser qu’une tentative de test en half-open, et limiter la concurrence à 8 requêtes simultanées par ressource. Ajustez par ressource si nécessaire.
Modèle d'état du disjoncteur (clés Cache)
Chaque ressource a ses propres clés dans le cache/Redis:
- cb:{key}:state contient closed, open ou half.
- cb:{key}:failures stocke le nombre d’échecs sur une fenêtre glissante; on lui applique un TTL égal à window_seconds.
- cb:{key}:opened_at mémorise le timestamp Unix du passage à l’état open, pour savoir quand re-tenter.
- cb:{key}:half_attempts compte les essais en half-open pour ne laisser passer qu’un petit nombre de requêtes de test.
Le fonctionnement est le suivant: à chaque échec, on incrémente cb:{key}:failures en lui assignant un TTL (si c’est la première incrémentation) et, si le seuil est atteint, on passe en open et on note opened_at. Après open_seconds, on autorise half_open_max_attempts requêtes test en passant l’état à half. Si une requête half-open réussit, on referme (closed) et on réinitialise les compteurs. Si elle échoue, on ré-ouvre immédiatement.
Implémentation CircuitBreaker (service autonome)
La classe suivante encapsule toute la logique du circuit breaker. Elle lit les seuils depuis config/http_integrations.php, manipule les clés dans le cache et publie des logs et métriques basiques.
// app/Support/CircuitBreaker.php
<?php
namespace App\Support;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CircuitBreaker
{
public const STATE_CLOSED = 'closed';
public const STATE_OPEN = 'open';
public const STATE_HALF = 'half';
public function canProceed(string $key): bool
{
$cfg = $this->settings($key);
$stateKey = $this->k($key, 'state');
$state = Cache::get($stateKey, self::STATE_CLOSED);
// Si open, vérifier la fenêtre d'ouverture
if ($state === self::STATE_OPEN) {
$openedAt = (int) Cache::get($this->k($key, 'opened_at'), 0);
$elapsed = now()->timestamp - $openedAt;
if ($elapsed < $cfg['open_seconds']) {
return false;
}
// La fenêtre d'ouverture est écoulée: passer en half-open
Cache::put($stateKey, self::STATE_HALF);
Cache::put($this->k($key, 'half_attempts'), 0);
Log::info('CB half-open', ['key' => $key]);
$state = self::STATE_HALF;
}
// Si half-open, ne laisser passer que N tentatives
if ($state === self::STATE_HALF) {
$attemptsKey = $this->k($key, 'half_attempts');
$attempts = (int) Cache::get($attemptsKey, 0);
if ($attempts >= $cfg['half_open_max_attempts']) {
return false;
}
Cache::put($attemptsKey, $attempts + 1);
return true;
}
// Closed
return true;
}
public function reportFailure(string $key): void
{
$cfg = $this->settings($key);
// Fenêtre glissante: initialiser le compteur d'échecs avec TTL s'il n'existe pas encore
$failKey = $this->k($key, 'failures');
Cache::add($failKey, 0, $cfg['window_seconds']);
$failures = Cache::increment($failKey);
$stateKey = $this->k($key, 'state');
$state = Cache::get($stateKey, self::STATE_CLOSED);
// Si on est en half-open, ré-ouvrir immédiatement
if ($state === self::STATE_HALF) {
$this->open($key);
$this->metric('failures', $key);
return;
}
// Si on est closed et qu'on dépasse le seuil, ouvrir
if ($state === self::STATE_CLOSED && $failures >= $cfg['failure_threshold']) {
$this->open($key);
}
$this->metric('failures', $key);
}
public function reportSuccess(string $key): void
{
$stateKey = $this->k($key, 'state');
$state = Cache::get($stateKey, self::STATE_CLOSED);
if ($state === self::STATE_HALF) {
$this->close($key);
}
// En closed: no-op (on peut choisir de reset le compteur, mais le TTL naturel suffit)
$this->metric('successes', $key);
}
protected function open(string $key): void
{
Cache::put($this->k($key, 'state'), self::STATE_OPEN);
Cache::put($this->k($key, 'opened_at'), now()->timestamp);
Log::warning('CB open', ['key' => $key, 'at' => now()->toIso8601String()]);
$this->metric('opens', $key);
}
protected function close(string $key): void
{
Cache::put($this->k($key, 'state'), self::STATE_CLOSED);
Cache::forget($this->k($key, 'half_attempts'));
Cache::forget($this->k($key, 'opened_at'));
// On laisse failures expirer naturellement sur sa fenêtre
Log::info('CB closed', ['key' => $key, 'at' => now()->toIso8601String()]);
}
protected function metric(string $name, string $key): void
{
$metricKey = $this->k($key, "metrics:{$name}");
Cache::add($metricKey, 0, 86400);
Cache::increment($metricKey);
}
protected function k(string $key, string $suffix): string
{
$prefix = config('http_integrations.defaults.prefix', 'cb:v1:');
return "{$prefix}{$key}:{$suffix}";
}
protected function settings(string $key): array
{
$defaults = config('http_integrations.defaults', []);
$overrides = config("http_integrations.resources.{$key}", []);
return array_merge($defaults, $overrides);
}
}
Cette implémentation est idempotente et simple à lire. Elle s’appuie sur Cache::add() pour poser un TTL au premier échec de la fenêtre et sur Cache::increment() pour compter. Les transitions d’état (open, half-open, closed) sont loguées et des compteurs de métriques sont incrémentés.
Wrapper pour Http::client() (sans magie)
Le wrapper HttpBreaker exécute une requête HTTP et notifie le disjoncteur de l’issue. Il considère par défaut comme échecs les 5xx et les 429, ainsi que toute exception réseau.
// app/Support/HttpBreaker.php
<?php
namespace App\Support;
use Closure;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class CircuitOpenException extends RuntimeException {}
class ApiUnavailableException extends RuntimeException {}
class HttpBreaker
{
public function __construct(
protected CircuitBreaker $breaker
) {}
public function request(string $key, Closure $callback): Response
{
if (! $this->breaker->canProceed($key)) {
throw new CircuitOpenException("circuit-open: {$key}");
}
try {
/** @var Response $response */
$response = $callback();
if ($this->shouldCountAsFailure($response)) {
$this->breaker->reportFailure($key);
} else {
$this->breaker->reportSuccess($key);
}
return $response;
} catch (Throwable $e) {
$this->breaker->reportFailure($key);
Log::warning('HTTP request failed', ['key' => $key, 'error' => $e->getMessage()]);
throw $e; // ou new ApiUnavailableException(...), selon votre politique
}
}
protected function shouldCountAsFailure(Response $response): bool
{
// Par défaut: 5xx et 429 (rate limit) comptent comme des échecs du point de vue du disjoncteur.
// Les 4xx "métier" n'ouvrent pas le circuit (ex: 404, 422).
return $response->serverError() || $response->status() === 429;
}
}
Vous pouvez adapter shouldCountAsFailure() par ressource si nécessaire (par exemple, traiter des 4xx spécifiques comme des échecs).
Retry + backoff avec le client HTTP
Le retry s’adresse aux pannes transitoires et se pilote directement par le client HTTP. Il ne remplace pas le disjoncteur, qui évite d’inonder une dépendance déjà défaillante. Un exemple simple avec 3 tentatives et 200ms entre chaque:
use Illuminate\Support\Facades\Http;
$response = Http::retry(3, 200, when: function ($exception, $request) {
// Retenter sur toute exception ou statut d'échec côté serveur (si response connu)
return true;
}, throw: true)->get('https://api.example.com/health');
Si vous souhaitez un backoff exponentiel, vous pouvez:
- soit implémenter votre propre boucle de retry dans votre callback (avec usleep et comptage des tentatives),
- soit écrire un middleware Guzzle personnalisé.
Restez pragmatique: commencez par un retry fixe simple et laissez le disjoncteur faire son travail de protection macro.
Limiter la concurrence (bulkhead) par ressource
La stratégie bulkhead consiste à isoler la concurrence par ressource via Redis::funnel(). Avant l’appel HTTP, on tente d’entrer dans un “entonnoir”. Si c’est saturé, on rejette immédiatement. Si on entre, la libération se fait automatiquement quand la closure “success” se termine.
use Illuminate\Support\Facades\Redis;
$key = 'github-search';
$size = config("http_integrations.resources.{$key}.bulkhead_size")
?? config('http_integrations.defaults.bulkhead_size');
Redis::funnel("bulkhead:{$key}")
->limit($size)
->block(0)
->then(
function () {
// Faire l'appel HTTP ici
},
function () use ($key) {
throw new \RuntimeException("bulkhead-saturated: {$key}");
}
);
En pratique, 8 à 16 peut être une bonne taille pour des endpoints tiers classiques. Ajustez en fonction des contraintes de l’API, de vos timeouts et du trafic réel.
Service concret: GitHubService avec breaker + bulkhead
Voici un service complet interrogeant l’API GitHub Search, combinant bulkhead, retry et disjoncteur. Il met aussi en cache la dernière réponse valide comme fallback si le circuit est ouvert.
// app/Services/GitHubService.php
<?php
namespace App\Services;
use App\Support\CircuitBreaker;
use App\Support\HttpBreaker;
use App\Support\CircuitOpenException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Redis;
use RuntimeException;
class GitHubService
{
protected string $key = 'github-search';
public function __construct(
protected HttpBreaker $httpBreaker,
protected CircuitBreaker $breaker,
) {}
public function searchRepos(string $q): Response
{
$size = config("http_integrations.resources.{$this->key}.bulkhead_size")
?? config('http_integrations.defaults.bulkhead_size');
return Redis::funnel("bulkhead:{$this->key}")
->limit($size)
->block(0)
->then(function () use ($q) {
try {
$response = $this->httpBreaker->request($this->key, function () use ($q) {
return Http::retry(3, 200)
->acceptJson()
// GitHub exige un User-Agent
->withHeaders(['User-Agent' => 'Laravel-HttpBreaker'])
->get('https://api.github.com/search/repositories', ['q' => $q]);
});
if ($response->successful()) {
Cache::put("{$this->key}:last-ok", $response->json(), now()->addMinutes(10));
}
return $response;
} catch (CircuitOpenException $e) {
// Fallback: dernière réponse valide si disponible
$cached = Cache::get("{$this->key}:last-ok");
if ($cached) {
// On renvoie un objet Response "synthétique"
return new Response(new \GuzzleHttp\Psr7\Response(
200,
['X-Fallback' => 'true'],
json_encode($cached)
));
}
throw new RuntimeException("circuit-open-no-fallback: {$this->key}");
}
}, function () {
throw new RuntimeException("bulkhead-saturated: {$this->key}");
});
}
}
Dans ce service, si le circuit est ouvert, on tente un fallback depuis un cache “last-ok”. Sinon, on exécute l’appel avec retry et on reporte l’issue au disjoncteur. La libération du bulkhead est automatique à la fin de la closure success.
Observabilité minimale
Pour piloter en production, vous avez besoin d’un minimum de visibilité. Les transitions d’état du disjoncteur sont déjà loguées (open, half-open, closed). Vous pouvez compléter avec des métriques simples, agrégées dans Redis puis scrapées par un collecteur Prometheus ou lues par des jobs de monitoring.
Un exemple d’export minimal est déjà présent dans CircuitBreaker::metric(). Vous pouvez aussi enrichir avec des compteurs bulkhead:
// Exemple d'incréments bulkhead côté service (optionnel)
use Illuminate\Support\Facades\Cache;
Cache::add("bulkhead:{$this->key}:acquired", 0, 86400);
Cache::increment("bulkhead:{$this->key}:acquired");
// En cas de saturation:
Cache::add("bulkhead:{$this->key}:rejected", 0, 86400);
Cache::increment("bulkhead:{$this->key}:rejected");
Surveillez les opens fréquents, les échecs qui explosent sur une ressource donnée, et les saturations de bulkhead. Un channel Monolog dédié aux “CB open” est une bonne pratique.
Gestion des erreurs et fallbacks
Quand HttpBreaker détecte un circuit ouvert, il lance une exception dédiée. Le service peut alors proposer un fallback (cache de la dernière réponse valide) ou renvoyer un 503 contrôlé au client en surface. Pour les exceptions réseau (ConnectionException, RequestException), encapsulez-les dans une ApiUnavailableException si vous voulez des erreurs métier propres.
Configurez clairement quelles erreurs affectent le circuit. Par défaut, on considère les 5xx et 429 comme des échecs du système. Les 4xx métier (404, 422) ne doivent généralement pas ouvrir le circuit, sous peine de masquer des problèmes fonctionnels attendus.
Tests: fakes, time travel, cas limites
Testez le comportement du disjoncteur et du bulkhead avec Http::fake(), le time travel et différents statuts.
Ouverture du circuit après N échecs:
// tests/Feature/GitHubServiceTest.php
public function test_circuit_opens_after_failures()
{
config()->set('http_integrations.resources.github-search.failure_threshold', 2);
config()->set('http_integrations.resources.github-search.open_seconds', 30);
Http::fake([
'api.github.com/*' => Http::response(['error' => 'boom'], 500),
]);
$svc = app(\App\Services\GitHubService::class);
// 1er appel -> 500, échec
$this->expectException(\RuntimeException::class);
$svc->searchRepos('laravel');
// 2e appel -> dépasse le seuil, ouvre le circuit
try {
$svc->searchRepos('laravel');
} catch (\Throwable $e) {
// ok
}
// 3e appel -> circuit doit être déjà ouvert, exception immédiate (pas d'HTTP)
Http::assertSentCount(2);
$this->expectException(\RuntimeException::class);
$svc->searchRepos('laravel');
}
Half-open puis fermeture sur succès:
use Carbon\Carbon;
public function test_half_open_then_close_on_success()
{
config()->set('http_integrations.resources.github-search.failure_threshold', 1);
config()->set('http_integrations.resources.github-search.open_seconds', 5);
config()->set('http_integrations.resources.github-search.half_open_max_attempts', 1);
// 1er échec pour ouvrir
Http::fake(['api.github.com/*' => Http::response(null, 500)]);
$svc = app(\App\Services\GitHubService::class);
try { $svc->searchRepos('laravel'); } catch (\Throwable $e) {}
// On avance le temps pour permettre half-open
Carbon::setTestNow(now()->addSeconds(6));
// Réponse 200 au prochain appel half-open
Http::fake(['api.github.com/*' => Http::response(['ok' => true], 200)]);
$resp = $svc->searchRepos('laravel');
$this->assertTrue($resp->successful());
// Le circuit doit être revenu en closed: un nouvel appel doit passer sans restriction
Http::fake(['api.github.com/*' => Http::response(['ok' => true], 200)]);
$resp2 = $svc->searchRepos('laravel');
$this->assertTrue($resp2->successful());
}
Saturation du bulkhead:
public function test_bulkhead_saturation_immediate_reject()
{
config()->set('http_integrations.resources.github-search.bulkhead_size', 1);
// On simule un appel long pour monopoliser le bulkhead
Http::fake(['api.github.com/*' => function () {
usleep(300_000); // 300ms
return Http::response(['ok' => true], 200);
}]);
$svc = app(\App\Services\GitHubService::class);
// 1er appel: entre dans le bulkhead
try { $svc->searchRepos('laravel'); } catch (\Throwable $e) {}
// 2e appel immédiat: doit être rejeté par le bulkhead (block(0))
$this->expectException(\RuntimeException::class);
$svc->searchRepos('laravel');
}
Vérification que les 4xx n’ouvrent pas le circuit:
public function test_4xx_do_not_open_circuit_by_default()
{
config()->set('http_integrations.resources.github-search.failure_threshold', 1);
Http::fake(['api.github.com/*' => Http::response(['not-found' => true], 404)]);
$svc = app(\App\Services\GitHubService::class);
// 404 ne compte pas comme échec du disjoncteur
$resp = $svc->searchRepos('foo');
$this->assertSame(404, $resp->status());
// Un nouvel appel doit encore passer
Http::fake(['api.github.com/*' => Http::response(['ok' => true], 200)]);
$resp2 = $svc->searchRepos('bar');
$this->assertTrue($resp2->successful());
}
Ces tests montrent les transitions d’état, la fenêtre d’ouverture, la stratégie half-open, la saturation du bulkhead et le mapping 4xx.
Durcissement et production
En production, affinez les seuils par ressource directement dans config/http_integrations.php pour isoler les endpoints les plus sensibles ou gourmands. Donnez un TTL raisonnable à chaque clé et utilisez un préfixe global (cb:v1:) pour faciliter des migrations ultérieures. Préférez Redis (phpredis ou predis) au file cache pour la précision des TTL et l’exactitude des incréments. Enfin, alertez quand le nombre d’ouvertures dépasse une tolérance donnée, par exemple via un channel Monolog dédié ou un job planifié qui inspecte cb:*:metrics:opens.
Snippets clés (référence rapide)
Implémentation conceptuelle de canProceed:
if state == open and now - opened_at < open_seconds => refuse
if state == open and fenêtre écoulée => passer à half-open
if state == half-open and half_attempts < max => ++attempts et autoriser
sinon => autoriser (closed)
Règles de reportFailure:
incrémenter failures (TTL=window_seconds);
si state == half-open => ouvrir immédiatement (open + opened_at=now)
sinon si failures >= threshold => ouvrir (open)
Règles de reportSuccess:
si state == half-open => fermer (closed) et reset half_attempts/opened_at
sinon (closed) => ne rien faire (la fenêtre glissante expirera naturellement)
Logique du wrapper HttpBreaker:
si !canProceed => throw CircuitOpen
try:
response = callback()
si response 5xx/429 => reportFailure
sinon => reportSuccess
return response
catch Exception:
reportFailure
relancer l’exception
Bulkhead Redis:
Redis::funnel('bulkhead:'.$key)
->limit($size)
->block(0)
->then(fn () => doRequest(), fn () => throw new \RuntimeException('bulkhead-saturated'));
Checklist
Avant de déployer, relisez les seuils par ressource et vérifiez les logs de transitions. Exécutez la suite de tests incluant les fakes HTTP et le time travel pour valider les cas limites. Enfin, publiez la configuration et surveillez en environnement de recette les métriques d’ouverture du disjoncteur et les rejets du bulkhead pour ajuster les paramètres.
Conclusion
En quelques classes et une poignée de clés Redis, vous disposez d’un disjoncteur et d’un bulkhead efficaces et transparents pour protéger vos intégrations HTTP. Cette approche réduit l’effet domino lors des pannes tierces, améliore la résilience de vos services et reste très simple à tester et opérer. Adaptez les seuils par ressource, observez les métriques et faites évoluer progressivement vos politiques de retry et de fallback.
Ressources
- Doc Laravel HTTP Client: https://laravel.com/docs/http-client
- Rate limiting et funnels (Redis): https://laravel.com/docs/redis#atomic-locks-and-funnels
- Circuit Breaker pattern (Martin Fowler): https://martinfowler.com/bliki/CircuitBreaker.html
- GitHub Search API: https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28
- Guzzle PSR-7 Response (utilisé pour construire la réponse fallback): https://docs.guzzlephp.org/en/stable/psr7.html