Saloon
The Saloon adapter is a trait on the connector and two pieces of middleware on every request. It works without Laravel. Install both packages:
composer require milon/fuse saloonphp/saloon
A connector
resolveCircuitBreakerStore() is abstract. The other methods have defaults: the circuit name is the connector's short class name, the config is CircuitBreakerConfig::defaults(), and there is no app segment.
use Milon\Fuse\CircuitBreakerConfig;
use Milon\Fuse\Contracts\CircuitBreakerStore;
use Milon\Fuse\Saloon\Traits\HasCircuitBreaker;
use Milon\Fuse\Stores\Psr16Store;
use Saloon\Http\Connector;
class BillingConnector extends Connector
{
use HasCircuitBreaker;
public function __construct(private Psr\SimpleCache\CacheInterface $cache) {}
public function resolveBaseUrl(): string
{
return 'https://billing.example.com';
}
protected function resolveCircuitBreakerStore(): CircuitBreakerStore
{
return new Psr16Store($this->cache);
}
protected function resolveCircuitBreakerName(): string
{
return 'billing-sdk';
}
protected function resolveCircuitBreakerConfig(): CircuitBreakerConfig
{
return new CircuitBreakerConfig(failureThreshold: 5, openSeconds: 15);
}
protected function resolveCircuitBreakerApp(): ?string
{
return 'punt';
}
}
ArrayStore inside resolveCircuitBreakerStore() is only useful in a test or a long-running process. A new connector in the next web request would have a new empty store. Pass a cache that outlives the request.
What the middleware does
On each send():
- The request middleware calls
allowRequest(). If the circuit is open it throwsCircuitOpenExceptionand Saloon does not send. - If the circuit allows the call, Saloon sends it.
- The response middleware records a failure when the status is in
counted_http_statuses(and 500, whencount_http_500is true). Other statuses record a success. - If sending throws, the fatal middleware records a failure for timeouts and connection errors, and releases the probe for anything else. The original exception is still thrown.
The middleware names are fuse_circuit_breaker_request, fuse_circuit_breaker_response, and fuse_circuit_breaker_fatal.
An open circuit
use Milon\Fuse\CircuitOpenException;
try {
$response = $connector->send(new ChargeRequest);
} catch (CircuitOpenException $exception) {
// Nothing was sent. $exception->circuitName is the connector name.
}
A separate circuit per request
Implement HasCircuitBreakerOperation on the request. The connector does not need to change.
use Milon\Fuse\Saloon\Contracts\HasCircuitBreakerOperation;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class ChargeRequest extends Request implements HasCircuitBreakerOperation
{
protected Method $method = Method::POST;
public function resolveEndpoint(): string
{
return '/charge';
}
public function resolveCircuitBreakerOperation(): string
{
return 'charge';
}
}
ChargeRequest uses fuse:punt:billing-sdk:charge. A RefundRequest that returns refund uses a different key. A request that does not implement the interface uses the connector circuit, with no operation segment.
Counted responses
A 503 records a failure and still returns the Saloon response. Your code can read the body. The circuit moves. A 422 records a success, because it is not in the default status list: the server answered, and the call was a client error.
A thrown connect timeout records a failure through the fatal middleware and then rethrows.
Tests and mocks
Saloon runs its mock middleware before Fuse's request middleware. If you mock a sequence of responses and the last send is the one that should be rejected, leave a spare MockResponse on the connector. Otherwise Saloon throws NoMockResponseFoundException before Fuse can throw CircuitOpenException.