deeep / service-client-bundle
Symfony bundle: HTTP client with retry, multi-host, circuit breaker, and async support via Fibers
Package info
github.com/deeep-com/service-client-bundle
Type:symfony-bundle
pkg:composer/deeep/service-client-bundle
Requires
- php: >=8.4
- guzzlehttp/guzzle: ^7.9
- guzzlehttp/promises: ^2.0
- psr/log: ^3.0
- revolt/event-loop: ^1.0
- symfony/config: ^7.4|^8.0
- symfony/dependency-injection: ^7.4|^8.0
- symfony/http-kernel: ^7.4|^8.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^12.0
This package is auto-updated.
Last update: 2026-03-10 17:34:40 UTC
README
π Language: English | Π ΡΡΡΠΊΠΈΠΉ
Symfony bundle: HTTP client with retry, multi-host failover, circuit breaker, and async support via PHP Fibers.
Features
- Retry with exponential backoff β configurable delay, jitter, and status codes
- Multi-host strategies β failover, round-robin, parallel
- Circuit breaker β automatic failure detection and recovery
- Async via Fibers β non-blocking requests with Revolt event loop
- Middleware pipeline β extensible request/response processing
- Multiple auth methods β Bearer, API Key, Basic
- Typed requests β ISP-based interfaces with response mapping
Requirements
- PHP 8.4+
- Symfony 7.4+ | 8.0+
- Guzzle 7.9+
Installation
composer require deeep/service-client-bundle
Register the bundle in config/bundles.php:
return [ // ... Deeep\ServiceClient\ServiceClientBundle::class => ['all' => true], ];
Configuration
<?php // config/packages/deeep_service_client.php declare(strict_types=1); namespace Symfony\Component\DependencyInjection\Loader\Configurator; return App::config([ 'deeep_service_client' => [ 'defaults' => [ 'timeout' => [ 'connect' => 5.0, 'request' => 30.0, ], 'retry' => [ 'enabled' => true, 'max_attempts' => 3, 'delay' => 1000, 'multiplier' => 2.0, 'jitter' => 0.1, ], ], 'services' => [ 'my_api' => [ 'host' => '%env(resolve:MY_API_HOST)%', 'auth' => [ 'type' => 'bearer', 'credentials' => [ 'token' => '%env(resolve:MY_API_TOKEN)%', ], ], ], 'resilient_api' => [ 'hosts' => [ 'strategy' => 'failover', 'list' => [ ['url' => '%env(resolve:API_PRIMARY)%'], ['url' => '%env(resolve:API_BACKUP)%'], ], ], ], ], ], ]);
Usage
Typed Request (Recommended)
use Deeep\ServiceClient\Contract\AuthRequiredInterface; use Deeep\ServiceClient\Contract\MappableRequestInterface; use Deeep\ServiceClient\Contract\ResponseInterface; use Deeep\ServiceClient\Enum\HttpMethod; use Deeep\ServiceClient\Request\AbstractRequest; /** * @implements MappableRequestInterface<User> */ final readonly class GetUserRequest extends AbstractRequest implements AuthRequiredInterface, MappableRequestInterface { public function __construct( private int $userId, ) {} public function getService(): string { return 'my_api'; } public function getMethod(): HttpMethod { return HttpMethod::GET; } public function getUri(): string { return "/users/{$this->userId}"; } /** * @param ResponseInterface<mixed> $response */ public function mapResponse(ResponseInterface $response): User { $data = $response->toArray(); return new User($data['id'], $data['name'], $data['email']); } } // Usage $request = new GetUserRequest(123); $response = $httpClient->send($request); $user = $response->getData(); // User
POST Request with Body
use Deeep\ServiceClient\Contract\AuthRequiredInterface; use Deeep\ServiceClient\Contract\MappableRequestInterface; use Deeep\ServiceClient\Contract\ResponseInterface; use Deeep\ServiceClient\Enum\HttpMethod; use Deeep\ServiceClient\Request\AbstractRequest; /** * @implements MappableRequestInterface<User> */ final readonly class CreateUserRequest extends AbstractRequest implements AuthRequiredInterface, MappableRequestInterface { public function __construct( private string $name, private string $email, ) {} public function getService(): string { return 'my_api'; } public function getMethod(): HttpMethod { return HttpMethod::POST; } public function getUri(): string { return '/users'; } /** * @return array{name: string, email: string} */ public function getBody(): array { return [ 'name' => $this->name, 'email' => $this->email, ]; } /** * @param ResponseInterface<mixed> $response */ public function mapResponse(ResponseInterface $response): User { $data = $response->toArray(); return new User($data['id'], $data['name'], $data['email']); } }
Request Builder (Quick Requests)
$request = $requestBuilder ->service('my_api') ->get('/users') ->query(['limit' => 10]) ->build(); $response = $httpClient->send($request); $users = $response->toArray();
The builder is immutable β each method returns a new instance:
$base = $requestBuilder->service('api')->header('X-Tenant', 'acme'); $request1 = $base->get('/users')->build(); $request2 = $base->get('/orders')->build(); // Both have X-Tenant header
Async Requests
// Launch multiple requests $results = []; foreach ($userIds as $id) { $results[$id] = $httpClient->sendAsync(new GetUserRequest($id)); } // Await all $responses = $httpClient->await(...$results); foreach ($responses as $id => $response) { $users[$id] = $response->getData(); }
Callbacks
$httpClient->sendAsync( $request, onResponse: fn($response) => $this->process($response), onError: fn($e) => $this->logger->error($e->getMessage()), );
Error Response Callbacks
Use onErrorResponse() for handling 4xx/5xx responses separately from network errors:
$result = $httpClient->sendAsync($request); $result ->then(fn($response) => $this->handleSuccess($response)) ->onErrorResponse(fn($response) => $this->handleApiError($response)) ->then(null, fn($e) => $this->handleNetworkError($e)); $response = $result->await();
then($onSuccess)β called for 2xx responsesonErrorResponse($callback)β called for 4xx/5xx responses with bodythen(null, $onError)β called for network errors (timeout, connection failed)
Request Interfaces (ISP)
| Interface | Purpose |
|---|---|
RequestInterface |
Base request (service, method, uri, headers, query, body) |
AuthRequiredInterface |
Marker: request requires authentication |
ConfigurableRequestInterface |
Override timeout/retry for specific request |
MappableRequestInterface<T> |
Custom response mapping to type T |
ErrorMappableRequestInterface<E> |
Custom error response (4xx/5xx) mapping to type E |
Also available: BaseRequest β base class with default implementations for getHeaders(), getQuery(), getBody().
Handling Error Responses
Implement ErrorMappableRequestInterface for custom mapping of error responses (4xx/5xx):
use Deeep\ServiceClient\Contract\ErrorMappableRequestInterface; use Deeep\ServiceClient\Contract\MappableRequestInterface; use Deeep\ServiceClient\Contract\ResponseInterface; use Deeep\ServiceClient\Request\AbstractRequest; /** * @implements MappableRequestInterface<User> * @implements ErrorMappableRequestInterface<ApiError> */ final readonly class GetUserRequest extends AbstractRequest implements MappableRequestInterface, ErrorMappableRequestInterface { // ... getService(), getMethod(), getUri() /** * @param ResponseInterface<mixed> $response */ public function mapResponse(ResponseInterface $response): User { $data = $response->toArray(); return new User($data['id'], $data['name'], $data['email']); } /** * @param ResponseInterface<mixed> $response */ public function mapErrorResponse(ResponseInterface $response): ApiError { $data = $response->toArray(); return new ApiError($data['code'], $data['message']); } }
Multi-Host Strategies
Failover
Tries hosts in order, switches on 5xx or network error:
'services' => [ 'api' => [ 'hosts' => [ 'strategy' => 'failover', 'list' => [ ['url' => 'https://primary.example.com'], ['url' => 'https://backup.example.com'], ], ], ], ],
Round-Robin
Distributes load across hosts:
'services' => [ 'api' => [ 'hosts' => [ 'strategy' => 'round_robin', 'list' => [ ['url' => 'https://node1.example.com'], ['url' => 'https://node2.example.com'], ['url' => 'https://node3.example.com'], ], ], ], ],
Parallel
Sends to all hosts, returns first successful response:
'services' => [ 'api' => [ 'hosts' => [ 'strategy' => 'parallel', 'list' => [ ['url' => 'https://fast.example.com'], ['url' => 'https://slow.example.com'], ], ], ], ],
Retry Configuration
'defaults' => [ 'retry' => [ 'enabled' => true, 'max_attempts' => 3, // Total attempts (1 initial + 2 retries) 'delay' => 1000, // Initial delay in ms 'multiplier' => 2.0, // Exponential multiplier 'max_delay' => 30000, // Maximum delay in ms 'jitter' => 0.1, // Random jitter (0-1) 'retry_on' => [429, 502, 503, 504], 'retry_on_methods' => ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'], 'respect_retry_after' => true, ], ],
Authentication Types
Bearer Token
'services' => [ 'api' => [ 'host' => '%env(resolve:API_HOST)%', 'auth' => [ 'type' => 'bearer', 'credentials' => [ 'token' => '%env(resolve:API_TOKEN)%', ], ], ], ],
API Key
'services' => [ 'api' => [ 'host' => '%env(resolve:API_HOST)%', 'auth' => [ 'type' => 'api_key', 'credentials' => [ 'header' => 'X-Api-Key', // Default: Api-Key 'token' => '%env(resolve:API_KEY)%', ], ], ], ],
Basic Auth
'services' => [ 'api' => [ 'host' => '%env(resolve:API_HOST)%', 'auth' => [ 'type' => 'basic', 'credentials' => [ 'username' => '%env(resolve:API_USER)%', 'password' => '%env(resolve:API_PASS)%', ], ], ], ],
Middleware Pipeline
Requests pass through middleware chain in priority order:
Request β Auth(100) β Retry(90) β Logging(80) β CircuitBreaker(60) β MultiHost(10) β Transport
| Priority | Middleware | Purpose |
|---|---|---|
| 100 | AuthMiddleware | Adds Authorization header |
| 90 | RetryMiddleware | Retries with exponential backoff |
| 80 | LoggingMiddleware | Logs requests/responses |
| 60 | CircuitBreakerMiddleware | Cascade failure protection |
| 10 | MultiHostMiddleware | Failover/round-robin/parallel |
More details: docs/en/middleware.md
Documentation
License
BSD-3-Clause