shoxcie/batch-http-client

HTTP request batch executor with individual retries, built on Symfony HttpClient.

Maintainers

Package info

github.com/Shoxcie/batch-http-client

pkg:composer/shoxcie/batch-http-client

Statistics

Installs: 18

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 1

v4.0.0 2026-04-29 19:52 UTC

README

HTTP request batch executor with individual retries, built on Symfony HttpClient.

Fire multiple HTTP requests in parallel. Each request retries independently without blocking others.

Requirements

  • PHP 8.4+
  • Symfony HttpClient 8.0+

Installation

composer require shoxcie/batch-http-client

Usage

use Shoxcie\BatchHttpClient\BatchHttpClient;
use Shoxcie\BatchHttpClient\RequestConfig;

$client = new BatchHttpClient();

$results = $client
    ->request([
        'users' => new RequestConfig('GET', 'https://api.example.com/users'),
        'orders' => new RequestConfig('POST', 'https://api.example.com/orders', options: [
            'json' => ['item' => 'widget', 'qty' => 3],
        ]),
    ])
    ->fetch();

$results['users'];  // decoded JSON array
$results['orders']; // decoded JSON array

Retries

$results = $client
    ->request([
        'flaky' => new RequestConfig('GET', 'https://api.example.com/flaky',
            maxRetries: 3,
        ),
    ])
    ->fetch();

Any non-2xx response or transport error (connection timeout, DNS failure) triggers a retry. Transport exception retries can be disabled per request with retryOnTransportException: false.

Retry options

Override options on retry with a static array:

new RequestConfig('GET', 'https://api.example.com/resource',
    maxRetries: 2,
    retryOptions: ['timeout' => 30],
)

Or dynamically with a Closure:

new RequestConfig('GET', 'https://api.example.com/resource',
    maxRetries: 3,
    retryOptions: function (string $key, int $retries, ExceptionInterface|InvalidResponseException $e): array {
        return ['timeout' => 10 * $retries];
    },
)

Retry options are merged onto the original options via array_replace_recursive().

Callbacks

$results = $client
    ->request([...])
    ->onSuccess(function (string $key, int $retries, mixed $result, ResponseInterface $response) {
        // called for each 2xx response, after parseResponse if configured
        // $retries = 0 on first-attempt success, N on success after N retries
    })
    ->onRetry(function (string $key, int $retries, ResponseInterface $failedResponse, ExceptionInterface|InvalidResponseException $e, ResponseInterface $retryResponse) {
        // called when a retry fires; $retries is the retry count after this retry fired (always >= 1)
    })
    ->onExhausted(function (string $key, int $retries, ResponseInterface $response, ExceptionInterface|InvalidResponseException $e) {
        // called when a single request exhausts all retries
        // $retries equals maxRetries normally; less when a transport error short-circuits
    })
    ->onAbort(function (string $key, int $retries, ResponseInterface $response, Throwable $e) {
        // called when an unexpected exception (e.g. throwing user callback) cancels the whole batch
        // $retries is the retry count for the request being processed when the abort fired
    })
    ->fetch();

Error handling

By default, if a request exhausts all retries, the last exception is rethrown and all in-flight requests are cancelled:

new RequestConfig('GET', 'https://api.example.com/critical',
    maxRetries: 3,
    throwOnExhausted: true, // default
)

Set throwOnExhausted: false for optional requests. Failed optional requests return null in the results array:

new RequestConfig('GET', 'https://api.example.com/optional',
    throwOnExhausted: false,
)

Response decoding

By default, responses are decoded as JSON (toArray()). Set decodeJson: false to get raw content (getContent()):

new RequestConfig('GET', 'https://example.com/file.csv',
    decodeJson: false,
)

Response parsing / validation

parseResponse runs after the body is decoded and before onSuccess, only on a 2xx response. The return value replaces the entry in the results array, so it doubles as a custom parser:

new RequestConfig('GET', 'https://api.example.com/users',
    parseResponse: fn(string $key, mixed $result, ResponseInterface $response): mixed
        => $result['data'],
)

Throw InvalidResponseException from the parser to reject a semantically invalid 2xx response and trigger a retry (counts against maxRetries, fires onRetry, and on exhaustion fires onExhausted plus rethrows if throwOnExhausted: true):

use Shoxcie\BatchHttpClient\InvalidResponseException;

new RequestConfig('GET', 'https://api.example.com/job-status',
    maxRetries: 5,
    parseResponse: function (string $key, mixed $result): mixed {
        if ($result['status'] === 'pending') {
            throw new InvalidResponseException('job not finished');
        }

        return $result;
    },
)

Any other Throwable from the parser is treated as an unexpected error and routes to onAbort, cancelling the whole batch.

Custom HTTP client

Pass any HttpClientInterface implementation:

use Symfony\Component\HttpClient\HttpClient;

$httpClient = HttpClient::create([
    'timeout' => 10,
    'max_duration' => 30,
]);

$client = new BatchHttpClient($httpClient);

RequestConfig reference

Parameter Type Default Description
method string (required) HTTP method
url string (required) Request URL
options array [] Symfony HttpClient options
retryOptions array|Closure [] Options merged on retry, or Closure receiving (string $key, int $retries, ExceptionInterface|InvalidResponseException $e)
throwOnExhausted bool true Rethrow exception after retries exhausted
decodeJson bool true Decode response as JSON
maxRetries int 0 Maximum retry attempts
retryOnTransportException bool true Retry on transport errors (timeouts, DNS)
parseResponse Closure|null null Runs on 2xx responses before onSuccess. Receives (string $key, int $retries, mixed $result, ResponseInterface $response); return value replaces the result. Throw InvalidResponseException to retry.

Important

The user_data option is reserved for internal key correlation — passing it in options or retryOptions throws InvalidArgumentException.

Upgrading

See the upgrade/ folder for migration guides between major versions.

License

MIT — see LICENSE for details.