Bâtir un circuit breaker léger pour l’HTTP Client Laravel (Cache + RateLimiter)
Apprends à protéger tes appels externes avec un circuit breaker pour Laravel HTTP Client: Cache, RateLimiter, verrous atomiques et tests inclus.
Sommaire
Bâtir un circuit breaker léger pour l’HTTP Client Laravel (Cache + RateLimiter)
Quand un service externe commence à répondre en erreur ou à être lent, il peut dégrader tout un système. Ce tutoriel montre comment construire un circuit breaker minimaliste autour de l’HTTP Client de Laravel en s’appuyant sur le cache (Redis), les verrous atomiques et une classification simple des erreurs. L’objectif est de bloquer rapidement les appels quand le service paraît indisponible, puis de sonder prudemment la reprise.
Objectif
L’objectif est d’encapsuler les appels HTTP externes avec un circuit breaker minimal qui ouvre le circuit après un nombre d’échecs consécutifs, refuse temporairement les nouveaux appels, puis passe en “half-open” pour tenter quelques requêtes de sonde. On intégrera du cache pour stocker l’état, des verrous pour éviter les conditions de course, des événements pour l’observabilité et des tests pour valider les transitions.
Objectif et prérequis
Le but est d’éviter de sursolliciter un service externe en panne, tout en accélérant les échecs contrôlés côté application. Concrètement, si l’API de GitHub renvoie des 500 pendant une minute, on veut échouer immédiatement et éventuellement retourner un fallback utile (cache, données dégradées). Quand la minute est écoulée, on tente une requête de reprise en “half-open” avant de refermer le circuit si tout va bien.
Les prérequis sont les suivants: un projet Laravel 10 ou 11, un cache Redis fonctionnel (store “redis” conseillé pour les verrous atomiques), et une connaissance pratique du client HTTP de Laravel, de la gestion des exceptions et des tests (Http::fake, Cache::fake, Carbon).
Concevoir le circuit breaker minimal
Le circuit breaker évolue entre trois états. En “closed”, tout passe normalement et on comptabilise les échecs. En “open”, on refuse immédiatement les appels pour une durée donnée. Après expiration, on passe en “half-open”: quelques appels de sonde sont autorisés; un succès referme le circuit, un échec le rouvre. Par exemple, on peut ouvrir le circuit après 5 échecs (failureThreshold = 5), le maintenir ouvert 60 secondes (openSeconds = 60) et autoriser 1 tentative de sonde en half-open (halfOpenMaxAttempts = 1). Un échec correspondra à un timeout, une connexion échouée, ou une réponse HTTP 5xx; selon les besoins, on peut aussi compter 429 (rate-limit) comme échec, mais ignorer des 404 métiers configurables.
Configuration dédiée
On centralise la configuration dans un fichier config/resilience.php. On y met les seuils, durées, et le mapping des codes HTTP considérés comme des échecs. On prévoit des overrides par service (github, stripe, etc.) pour ajuster finement.
Crée le fichier config/resilience.php:
<?php
return [
'defaults' => [
'failure_threshold' => env('RESILIENCE_FAILURE_THRESHOLD', 5),
'open_seconds' => env('RESILIENCE_OPEN_SECONDS', 60),
'half_open_max_attempts' => env('RESILIENCE_HALF_OPEN_MAX_ATTEMPTS', 1),
// Statuts considérés comme échec par défaut (5xx + 429)
'count_status_as_failure' => array_merge(range(500, 599), [429]),
// Statuts à ignorer côté métier (ex: 404 fonctionnel)
'ignore_status' => [404],
],
// Overrides par service (laisser à null pour hériter des defaults)
'services' => [
'github' => [
'failure_threshold' => env('RESILIENCE_GITHUB_FAILURE_THRESHOLD', null),
'open_seconds' => env('RESILIENCE_GITHUB_OPEN_SECONDS', null),
'half_open_max_attempts' => env('RESILIENCE_GITHUB_HALF_OPEN_MAX_ATTEMPTS', null),
'count_status_as_failure' => null,
'ignore_status' => null,
],
],
];
Expose quelques variables d’environnement dans ton .env pour ajuster sans redéployer:
RESILIENCE_FAILURE_THRESHOLD=5
RESILIENCE_OPEN_SECONDS=60
RESILIENCE_HALF_OPEN_MAX_ATTEMPTS=1
# Exemple spécifique à GitHub
RESILIENCE_GITHUB_FAILURE_THRESHOLD=3
RESILIENCE_GITHUB_OPEN_SECONDS=30
RESILIENCE_GITHUB_HALF_OPEN_MAX_ATTEMPTS=1
Service CircuitBreaker: squelette
On crée une interface et une classe finale dans app/Support. L’interface garantit une API simple: run pour exécuter avec protection et fallback, allow pour vérifier (et éventuellement refuser) l’appel, et state pour exposer l’état courant.
Crée app/Support/CircuitBreakerContract.php:
<?php
namespace App\Support;
interface CircuitBreakerContract
{
/**
* Exécute la closure protégée par le circuit breaker.
*
* @param string $name Nom du circuit (ex: 'github')
* @param callable $action Action principale à exécuter
* @param callable|null $fallback Fallback optionnel en cas d'échec comptabilisé
* @return mixed
*
* @throws \App\Support\CircuitOpenException Si le circuit est ouvert
* @throws \Throwable Si l'échec n'est pas comptabilisé ou pas de fallback
*/
public function run(string $name, callable $action, ?callable $fallback = null): mixed;
/**
* Vérifie/autorise l'appel (lève une exception si non permis).
*/
public function allow(string $name): void;
/**
* Retourne l'état: 'closed' | 'open' | 'half-open'
*/
public function state(string $name): string;
}
Crée une exception dédiée app/Support/CircuitOpenException.php:
<?php
namespace App\Support;
use RuntimeException;
class CircuitOpenException extends RuntimeException
{
public function __construct(string $name)
{
parent::__construct("Circuit '$name' is open");
}
}
Stockage d’état dans le cache
On stocke l’état par service sous des clés dédiées pour bénéficier d’opérations atomiques. Chaque service possède un compteur d’échecs, un timestamp d’ouverture et un compteur d’essais half-open. On initialise à la première utilisation avec des valeurs neutres.
Schéma de clés:
- cb:{name}:failures (int)
- cb:{name}:opened_at (int|null, timestamp epoch)
- cb:{name}:half_open_attempts (int|null)
- cb:{name}:lock (verrou)
Cette granularité permet d’utiliser Cache::increment de façon atomique et d’éviter les collisions entre workers.
Logique d’autorisation et transitions d’état
La méthode allow vérifie l’état et gère les transitions. Si opened_at est défini et non expiré, on refuse immédiatement en levant CircuitOpenException. Si la fenêtre d’ouverture est expirée, on passe en half-open en resetant les compteurs et en initialisant le compteur d’essais. En half-open, on autorise seulement un nombre limité d’appels concurrents. En cas d’échec, recordFailure incrémente les échecs et ouvre le circuit si le seuil est atteint (ou immédiatement en half-open). En cas de succès, recordSuccess referme le circuit depuis half-open et remet à zéro les compteurs.
Crée app/Support/CircuitBreaker.php:
<?php
namespace App\Support;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\ConnectionException;
use Throwable;
final class CircuitBreaker implements CircuitBreakerContract
{
public function __construct(
private ?CacheRepository $store = null
) {
// Utilise le store par défaut
$this->store = $store ?: Cache::store();
}
public function run(string $name, callable $action, ?callable $fallback = null): mixed
{
$this->allow($name);
try {
$result = $action();
$this->recordSuccess($name);
return $result;
} catch (Throwable $e) {
if ($this->shouldCountAsFailure($name, $e)) {
$this->recordFailure($name);
if ($fallback) {
return $fallback($e);
}
}
throw $e;
}
}
public function allow(string $name): void
{
$keys = $this->keys($name);
$conf = $this->configFor($name);
$this->initializeIfNeeded($keys);
$lock = $this->store->lock("{$keys['prefix']}:lock", 2);
// Tentative de verrouillage courte pour transitions atomiques.
$lock->block(2, function () use ($keys, $conf, $name) {
$now = $this->now()->timestamp;
$openedAt = $this->store->get($keys['opened_at']);
if ($openedAt !== null) {
$expiresAt = $openedAt + $conf['open_seconds'];
if ($now < $expiresAt) {
throw new CircuitOpenException($name);
}
// Fenêtre d'ouverture expirée -> passe en half-open.
$this->store->put($keys['opened_at'], null);
$this->store->put($keys['failures'], 0);
$this->store->put($keys['half_open_attempts'], 0);
Event::dispatch(new \App\Events\CircuitHalfOpened($name, 0, null));
Log::notice("Circuit '$name' half-open (cooldown expired)");
}
$hoa = $this->store->get($keys['half_open_attempts']);
if ($hoa !== null) {
if ($hoa >= $conf['half_open_max_attempts']) {
throw new CircuitOpenException($name);
}
// Autorise cet essai half-open (contrôle de concurrence)
$this->store->increment($keys['half_open_attempts']);
}
// En closed => rien à faire, l'appel est autorisé.
});
}
public function state(string $name): string
{
$keys = $this->keys($name);
$this->initializeIfNeeded($keys);
$openedAt = $this->store->get($keys['opened_at']);
$hoa = $this->store->get($keys['half_open_attempts']);
if ($openedAt !== null) {
$conf = $this->configFor($name);
$now = $this->now()->timestamp;
if ($now < ($openedAt + $conf['open_seconds'])) {
return 'open';
}
}
if ($hoa !== null) {
return 'half-open';
}
return 'closed';
}
private function recordFailure(string $name): void
{
$keys = $this->keys($name);
$conf = $this->configFor($name);
$this->initializeIfNeeded($keys);
$lock = $this->store->lock("{$keys['prefix']}:lock", 2);
$lock->block(2, function () use ($keys, $conf, $name) {
$state = $this->state($name);
if ($state === 'half-open') {
// En half-open, un échec rouvre immédiatement le circuit.
$this->open($name, $keys);
return;
}
$failures = (int) $this->store->increment($keys['failures']);
if ($failures >= $conf['failure_threshold']) {
$this->open($name, $keys);
}
});
}
private function recordSuccess(string $name): void
{
$keys = $this->keys($name);
$this->initializeIfNeeded($keys);
$lock = $this->store->lock("{$keys['prefix']}:lock", 2);
$lock->block(2, function () use ($name, $keys) {
$state = $this->state($name);
if ($state === 'half-open') {
// Succès de sonde -> referme le circuit.
$this->store->put($keys['failures'], 0);
$this->store->put($keys['opened_at'], null);
$this->store->put($keys['half_open_attempts'], null);
Event::dispatch(new \App\Events\CircuitClosed($name));
Log::info("Circuit '$name' closed (probe succeeded)");
return;
}
// Closed: reset des échecs pour repartir proprement.
$this->store->put($keys['failures'], 0);
});
}
private function open(string $name, array $keys): void
{
$now = $this->now()->timestamp;
$this->store->put($keys['opened_at'], $now);
$this->store->put($keys['half_open_attempts'], null);
$failures = (int) $this->store->get($keys['failures'], 0);
Event::dispatch(new \App\Events\CircuitOpened($name, $failures, $now));
Log::warning("Circuit '$name' opened after {$failures} failures");
}
private function initializeIfNeeded(array $keys): void
{
foreach (['failures' => 0, 'opened_at' => null, 'half_open_attempts' => null] as $k => $default) {
$key = $keys[$k];
if ($this->store->get($key) === null) {
$this->store->put($key, $default);
}
}
}
private function keys(string $name): array
{
$prefix = "cb:{$name}";
return [
'prefix' => $prefix,
'failures' => "{$prefix}:failures",
'opened_at' => "{$prefix}:opened_at",
'half_open_attempts' => "{$prefix}:half_open_attempts",
];
}
private function now(): CarbonImmutable
{
return CarbonImmutable::now();
}
private function configFor(string $name): array
{
$defaults = config('resilience.defaults', []);
$service = config("resilience.services.{$name}", []) ?: [];
return [
'failure_threshold' => $service['failure_threshold'] ?? $defaults['failure_threshold'],
'open_seconds' => $service['open_seconds'] ?? $defaults['open_seconds'],
'half_open_max_attempts' => $service['half_open_max_attempts'] ?? $defaults['half_open_max_attempts'],
'count_status_as_failure' => $service['count_status_as_failure'] ?? $defaults['count_status_as_failure'],
'ignore_status' => $service['ignore_status'] ?? $defaults['ignore_status'],
];
}
private function shouldCountAsFailure(string $name, Throwable $e): bool
{
$conf = $this->configFor($name);
if ($e instanceof ConnectionException) {
return true;
}
if ($e instanceof RequestException) {
$response = $e->response;
if (!$response) {
return true;
}
$code = $response->status();
if (in_array($code, (array) $conf['ignore_status'], true)) {
return false;
}
if (in_array($code, (array) $conf['count_status_as_failure'], true)) {
return true;
}
// Par défaut, les 4xx non listés sont considérés comme non techniques
return false;
}
// Autres exceptions applicatives: ne pas compter par défaut
return false;
}
}
Ajoute des événements simples pour l’observabilité:
app/Events/CircuitOpened.php
<?php
namespace App\Events;
class CircuitOpened
{
public function __construct(
public string $name,
public int $failures,
public int $openedAt
) {}
}
app/Events/CircuitClosed.php
<?php
namespace App\Events;
class CircuitClosed
{
public function __construct(
public string $name
) {}
}
app/Events/CircuitHalfOpened.php
<?php
namespace App\Events;
class CircuitHalfOpened
{
public function __construct(
public string $name,
public int $failures,
public ?int $openedAt
) {}
}
Enfin, enregistre un binding pour le breaker (ex: dans App\Providers\AppServiceProvider):
public function register(): void
{
$this->app->singleton(\App\Support\CircuitBreakerContract::class, function () {
return new \App\Support\CircuitBreaker();
});
}
Méthode run: exécuter protégé avec fallback
La méthode run encapsule l’exécution en appelant allow pour valider l’accès, puis en enregistrant un succès ou un échec. Si l’échec compte pour l’état du circuit et qu’un fallback est fourni, on l’exécute et on retourne sa valeur. Un fallback typique peut renvoyer une réponse mise en cache ou une structure dégradée.
Exemple d’usage:
$result = app(\App\Support\CircuitBreakerContract::class)
->run('github', function () {
return \Illuminate\Support\Facades\Http::baseUrl('https://api.github.com')
->timeout(5)
->retry(2, 200)
->withUserAgent('my-app/1.0')
->get('/rate_limit')
->throw()
->json();
}, function (\Throwable $e) {
// Fallback: réponse dégradée
return ['resources' => ['core' => ['remaining' => 0, 'reset' => null]]];
});
Intégration avec l’HTTP Client Laravel
On configure des timeouts et des retries côté Http:: pour amortir les petites fluctuations réseau, mais on enveloppe l’ensemble dans CircuitBreaker::run pour contrôler les rafales d’échecs. Comme critères d’échec, on considère les ConnectionException (DNS, refus de connexion), les RequestException dont le statut est 5xx ou 429, et les timeouts. L’appel à ->throw() normalise ces erreurs en exceptions catchables par le breaker, qui les classifie selon la configuration.
Un appel type:
$response = app(\App\Support\CircuitBreakerContract::class)->run('github', function () {
return \Illuminate\Support\Facades\Http::baseUrl('https://api.github.com')
->connectTimeout(3)
->timeout(5)
->retry(2, 200) // 2 retries avec 200ms de backoff
->withHeaders(['Accept' => 'application/vnd.github+json'])
->get('/rate_limit')
->throw()
->json();
});
Verrous pour éviter les courses
Pour empêcher plusieurs workers d’opérer des transitions simultanées, on utilise des locks Redis via Cache::lock sur la clé cb:{name}:lock. On protège ainsi l’entrée en half-open, l’ouverture du circuit, et l’incrément des tentatives half-open. Le timeout du lock reste court (1–2 secondes) afin d’éviter les blocages; en cas d’impossibilité d’acquérir le verrou, le code retente brièvement via block, et retombe ensuite sur une lecture à jour lors du prochain appel.
Observabilité minimale
Afin de diagnostiquer rapidement les comportements, on émet des événements CircuitOpened, CircuitClosed et CircuitHalfOpened avec le nom du circuit et quelques métriques. Ces événements peuvent être écoutés pour alimenter des métriques Prometheus ou une file d’audit. On logge également les transitions aux niveaux info/notice/warning. En complément, on peut fournir un middleware API ajoutant un en-tête X-Circuit-State.
Exemple de middleware app/Http/Middleware/ExposeCircuitState.php:
<?php
namespace App\Http\Middleware;
use App\Support\CircuitBreakerContract;
use Closure;
use Illuminate\Http\Request;
class ExposeCircuitState
{
public function __construct(private CircuitBreakerContract $cb) {}
public function handle(Request $request, Closure $next, string $service)
{
$response = $next($request);
$state = $this->cb->state($service);
return $response->header('X-Circuit-State-'.$service, $state);
}
}
Enregistre-le et utilise-le sur une route nécessitant une visibilité rapide.
Client dédié pour un service externe
On peut encapsuler la logique d’un service externe dans un client dédié. Ce client applique les bons headers, timeouts, et passe toujours par le breaker.
Crée app/Services/GitHubClient.php:
<?php
namespace App\Services;
use App\Support\CircuitBreakerContract;
use Illuminate\Support\Facades\Http;
class GitHubClient
{
public function __construct(private CircuitBreakerContract $cb) {}
public function rateLimit(): array
{
return $this->cb->run('github', function () {
return Http::baseUrl('https://api.github.com')
->connectTimeout(3)
->timeout(5)
->retry(2, 200)
->withUserAgent('my-app/1.0')
->get('/rate_limit')
->throw()
->json();
}, function () {
// Fallback: valeur dégradée mais structurée
return [
'resources' => [
'core' => ['limit' => 5000, 'remaining' => 0, 'reset' => null],
],
];
});
}
}
Tests automatisés
On valide le comportement avec Http::fake pour simuler des 5xx, timeouts et succès, Cache::fake pour isoler l’état, et Carbon::setTestNow pour manipuler le temps. On vérifie l’ouverture après N échecs, le refus immédiat en open, le passage en half-open après expiration, et la fermeture après un succès en half-open. On teste aussi que le fallback est utilisé et que les événements sont dispatchés.
Exemple de tests PHPUnit dans tests/Feature/CircuitBreakerTest.php:
<?php
namespace Tests\Feature;
use App\Events\CircuitClosed;
use App\Events\CircuitHalfOpened;
use App\Events\CircuitOpened;
use App\Services\GitHubClient;
use App\Support\CircuitBreaker;
use App\Support\CircuitOpenException;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CircuitBreakerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
Event::fake();
}
public function test_opens_after_threshold_failures()
{
config()->set('resilience.services.github.failure_threshold', 3);
$cb = new CircuitBreaker();
Http::fake([
'api.github.com/*' => Http::response([], 500),
]);
for ($i = 0; $i < 3; $i++) {
try {
$cb->run('github', fn () => Http::get('https://api.github.com/rate_limit')->throw());
} catch (\Throwable $e) {
// ignore
}
}
$this->assertSame('open', $cb->state('github'));
Event::assertDispatched(CircuitOpened::class);
}
public function test_refuses_while_open_and_half_open_flow()
{
config()->set('resilience.services.github.failure_threshold', 1);
config()->set('resilience.services.github.open_seconds', 60);
config()->set('resilience.services.github.half_open_max_attempts', 1);
$cb = new CircuitBreaker();
// 1) Ouvre
Http::fake(['api.github.com/*' => Http::response([], 500)]);
try {
$cb->run('github', fn () => Http::get('https://api.github.com/rate_limit')->throw());
} catch (\Throwable $e) {}
$this->assertSame('open', $cb->state('github'));
// 2) Tant que pas expiré -> refuse
$this->expectException(CircuitOpenException::class);
$cb->run('github', fn () => Http::get('https://api.github.com/rate_limit')->throw());
// 3) Expire -> half-open
$now = CarbonImmutable::now();
$future = $now->addSeconds(61);
\Carbon\Carbon::setTestNow($future);
$this->assertSame('half-open', $cb->state('github'));
Event::assertDispatched(CircuitHalfOpened::class);
// 4) Succès de la sonde -> closed
Http::fake(['api.github.com/*' => Http::response(['ok' => true], 200)]);
$cb->run('github', fn () => Http::get('https://api.github.com/rate_limit')->throw());
$this->assertSame('closed', $cb->state('github'));
Event::assertDispatched(CircuitClosed::class);
}
public function test_fallback_is_used_on_failure()
{
$cb = new CircuitBreaker();
Http::fake(['api.github.com/*' => Http::response([], 500)]);
$res = $cb->run('github', fn () => Http::get('https://api.github.com/rate_limit')->throw(), function () {
return ['fallback' => true];
});
$this->assertTrue($res['fallback']);
}
}
Essai manuel via une route de démo
Il est pratique de tester manuellement le breaker dans une route. On crée une route /demo-cb qui appelle GitHubClient->rateLimit() et expose l’état du circuit dans un en-tête pour inspection rapide. En local, on peut déclencher des erreurs en fonction d’un paramètre de requête.
Dans routes/web.php:
use App\Services\GitHubClient;
use App\Support\CircuitBreakerContract;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
Route::get('/demo-cb', function (GitHubClient $client, CircuitBreakerContract $cb) {
if (app()->environment('local') && request()->boolean('fail')) {
Http::fake([
'api.github.com/*' => Http::response(['error' => 'forced'], 500),
]);
} else {
Http::fake([
'api.github.com/*' => Http::response(['resources' => ['core' => ['remaining' => 42]]], 200),
]);
}
$data = $client->rateLimit();
$state = $cb->state('github');
return response()->json([
'state' => $state,
'data' => $data,
])->header('X-Circuit-State-github', $state);
});
Lance le serveur puis visite /demo-cb?fail=1 à répétition pour provoquer l’ouverture. Retire fail pour observer la transition half-open puis la fermeture. Les logs indiqueront les transitions.
Extensions utiles
La granularité des circuits peut être adaptée selon le besoin. On peut choisir une clé par hôte (api.github.com), par endpoint (api.github.com/rate_limit) ou par identifiant client (clé API). Pour des déploiements horizontaux, une persistance Redis partagée entre workers garantit une vision cohérente de l’état. Plutôt qu’un simple compteur cumulatif d’échecs, on peut utiliser une fenêtre glissante via le RateLimiter de Laravel pour compter les échecs dans une période donnée, ce qui est plus robuste aux pics transitoires. Les fallbacks peuvent être enrichis avec des caches chauds, des réponses partiellement pré-calculées ou des modèles de dégradation UX. Enfin, expose des métriques Prometheus (ou OpenTelemetry) sur le nombre d’ouvertures et l’état courant par service pour une supervision proactive.
Checklist
Avant de fusionner, relis le code et la configuration pour t’assurer que les seuils et codes d’erreur reflètent bien tes attentes métier. Exécute les tests automatisés et effectue quelques essais manuels en local ou sur un environnement de staging pour vérifier les transitions closed → open → half-open → closed. Valide aussi le comportement des fallbacks et la remontée d’événements et de logs. Une fois satisfait, publie et surveille tes métriques les premiers jours pour ajuster éventuellement les seuils.
Conclusion
Ce circuit breaker léger apporte une protection efficace et pragmatique autour de l’HTTP Client de Laravel. En combinant cache Redis, verrous atomiques, classification d’erreurs et un minimum d’observabilité, on évite les boucles d’échecs coûteuses et on accélère les réponses dégradées. La mise en place est compacte, testable, et extensible vers des mécanismes plus avancés (fenêtre glissante, quotas dynamiques, métriques exportées).
Ressources
- Laravel HTTP Client: https://laravel.com/docs/http-client
- Cache et verrous atomiques: https://laravel.com/docs/cache#atomic-locks
- Rate Limiting Laravel: https://laravel.com/docs/rate-limiting
- Circuit breaker pattern (Martin Fowler): https://martinfowler.com/bliki/CircuitBreaker.html
- OpenTelemetry Laravel (observabilité): https://opentelemetry.io/ and packages communautaires (ex: open-telemetry/opentelemetry-php)