deeep/service-client-bundle

Symfony bundle: HTTP client with retry, multi-host, circuit breaker, and async support via Fibers

Maintainers

Package info

github.com/deeep-com/service-client-bundle

Type:symfony-bundle

pkg:composer/deeep/service-client-bundle

Statistics

Installs: 16

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

1.0.0 2026-02-10 16:51 UTC

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 responses
  • onErrorResponse($callback) β€” called for 4xx/5xx responses with body
  • then(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