skip to content

How do you send several requests concurrently with Guzzle using requestAsync() promises or a Pool, and how do you collect partial failures?

level: seniorimportance: should knowfreq 36%

answer

  1. promises instead of responses
  2. transfers progress while you wait
  3. unwrap throws, settle collects
  4. Pool concurrency defaults to 25
  5. fulfilled and rejected callbacks by index

basics

~20 s

getAsync() or requestAsync() return promises; the transfers run concurrently while you wait. Promise\Utils::settle() collects fulfilled and rejected results without throwing, unlike unwrap(). For many or unbounded requests, Pool limits in-flight requests (concurrency, default 25) and reports each via fulfilled/rejected callbacks.

solid answer

~40 s

`requestAsync()` and the shortcuts such as `getAsync()` return a `PromiseInterface` instead of a response. With the default handler they go through cURL's multi interface, so several transfers overlap, and they progress while you `wait()`. For a fixed set, such as three exchange-rate providers, I key the promises by provider. `Promise\Utils::unwrap($promises)` returns the responses but throws on the first rejection. `Promise\Utils::settle($promises)->wait()` returns every result as `['state' => 'fulfilled', 'value' => ...]` or `['state' => 'rejected', 'reason' => ...]`, which is what I want for partial failure. For large or open-ended batches I use `GuzzleHttp\Pool` with a generator of requests: `concurrency` caps requests in flight (default 25), and the `fulfilled` and `rejected` callbacks receive each result with its index. Each request still needs its own timeout, because the batch waits for the slowest one.

code

php · 26 lines
php
<?php
declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils;

$client = new Client(['connect_timeout' => 1, 'timeout' => 3]);
$providers = [
    'fx-a' => 'https://fx-a.example.test/rates',
    'fx-b' => 'https://fx-b.example.test/v1/latest',
    'fx-c' => 'https://fx-c.example.test/eur',
];

$promises = [];
foreach ($providers as $name => $url) {
    $promises[$name] = $client->getAsync($url, ['query' => ['base' => 'EUR']]);
}

$rates = [];
foreach (Utils::settle($promises)->wait() as $name => $result) {
    if ($result['state'] === 'fulfilled') {
        $rates[$name] = json_decode((string) $result['value']->getBody(), true);
    } else {
        error_log("$name failed: " . $result['reason']->getMessage());
    }
}

go deeper

for a junior

Know that the *Async methods return promises, that wait() gets the result, and that concurrent calls overlap their waiting.

for a middle

Compare unwrap and settle for partial failure, and explain Pool's concurrency, options, fulfilled and rejected settings.

for a senior

Bound fan-out with Pool and generators, keep per-request timeouts, map results by key or index, and understand that the batch is as slow as its slowest member.

for a principal

Decide when concurrent fan-out inside a web request is acceptable, versus precomputing or caching upstream data, given worker capacity and upstream rate limits.

## Promises instead of responses Guzzle's synchronous `request()` is really `requestAsync(...)->wait()`. Calling the asynchronous form yourself gives you a **promise**: an object that will later be fulfilled with a response or rejected with an exception. Every verb has a shortcut: `getAsync()`, `postAsync()` and so on, all calling `requestAsync()`. For a prepared PSR-7 request, `sendAsync($request, $options)` does the same. With the default handler, asynchronous requests run on Guzzle's cURL multi-handle handler, so their network waits overlap in one PHP process. They do not run in parallel threads, and they advance when the promise queue is driven, which happens when you call `wait()`. The total time approaches the **slowest** request rather than the sum. ## A fixed set: three providers For a known, small set, build an array of promises keyed by name and combine them with helpers from `GuzzleHttp\Promise\Utils`: | Helper | Returns | On a rejection | |---|---|---| | `Utils::unwrap($promises)` | array of responses, same keys | throws the rejection reason | | `Utils::settle($promises)->wait()` | array of `['state', 'value']` or `['state', 'reason']` | records it and carries on | For exchange rates from three providers, `settle()` is usually right. If one provider times out (a `ConnectException` in `reason`), the other two still yield rates, and the code picks the freshest or averages them. `unwrap()` suits calls where every response is required, and one failure should abort the whole operation. ## An open-ended batch: Pool Firing a thousand `getAsync()` calls at once opens a thousand transfers. `GuzzleHttp\Pool` takes an iterable of requests and keeps only a limited number in flight: - `concurrency`: maximum requests in flight; the source defaults it to **25**; - `options`: request options applied to every request; - `fulfilled`: `function (ResponseInterface $response, $index)`, called per success; - `rejected`: `function ($reason, $index)`, called per failure. The iterable may yield `RequestInterface` objects, or callables that receive the options and return a promise, which lets each item use its own URI and options. A **generator** keeps memory flat, because requests are created only when a slot frees up. `$pool->promise()->wait()` runs the batch. `Pool::batch($client, $requests, $options)` is a convenience that returns every response or exception in request order. Its docblock warns that it keeps all requests and responses in memory, so it is unsuited to large or unbounded batches. ## What goes wrong 1. **Forgetting to wait.** Creating promises and never calling `wait()` (or a helper that waits) leaves the transfers unfinished; the PHP request may end first. 2. **Using unwrap() for optional data.** One slow provider turns into an exception for the whole page. 3. **No per-request timeout.** The batch cannot finish before its slowest member, so an unbounded request holds the whole batch. 4. **Unbounded fan-out.** A promise per item for a large list overloads both your process and the upstream; use `Pool` with a sensible `concurrency`. 5. **Losing the mapping.** Key promises by provider, or use the `$index` passed to Pool callbacks, so each result is matched to its source. ## Choosing the tool | Situation | Tool | |---|---| | two or three known calls, all required | promises plus `Utils::unwrap()` | | a few known calls, partial results acceptable | promises plus `Utils::settle()` | | a long or generated list | `Pool` with a generator and a `concurrency` limit | | every result needed in order, small list | `Pool::batch()` | Whichever you choose, the same client configuration applies to each request: `base_uri`, default headers, timeouts and any middleware on the stack. Per-request options passed to `getAsync()` or through Pool's `options` override those defaults, as they would for synchronous calls. ## Retries and concurrency together A retry middleware on the client's stack also applies to asynchronous requests. With the multi-handle handler, a retry's delay is scheduled without blocking the other transfers, so one provider backing off does not stall the others.

  • When would you choose Utils::unwrap() over Utils::settle()?
    When every response is required and one failure should abort the whole operation, for example assembling a document from parts that are all mandatory. `unwrap()` throws the first rejection it meets, so the caller handles one exception. For optional or redundant sources, such as several rate providers, `settle()` keeps the successes.
  • Why pass a generator to Pool rather than an array of requests?
    A generator produces each request only when the pool has a free slot, so memory stays flat however long the list is, and the list may even be unbounded. An array builds every request object up front. The `concurrency` option, 25 by default, still bounds how many run at once.

saying these in an interview costs you the question

  • getAsync() runs each request in a separate PHP thread.
  • Utils::unwrap() returns partial results when one promise is rejected.
  • Pool sends every request at once unless you add a sleep.
  • Async requests finish on their own even if nothing calls wait().
  • One shared timeout on the Pool bounds each request.