diff --git a/src/Illuminate/Http/Client/Batch.php b/src/Illuminate/Http/Client/Batch.php index 5a6baf51aa31..6640e9c0b945 100644 --- a/src/Illuminate/Http/Client/Batch.php +++ b/src/Illuminate/Http/Client/Batch.php @@ -147,7 +147,7 @@ public function as(string $key) $this->incrementPendingRequests(); - return $this->requests[$key] = $this->asyncRequest(); + return $this->factory->setHandler($this->handler)->async()->setDeferred($this->requests, $key); } /** @@ -252,18 +252,19 @@ public function send(): array } $results = []; - $promises = []; - foreach ($this->requests as $key => $item) { - $promise = match (true) { - $item instanceof PendingRequest => $item->getPromise(), - default => $item, + if (! empty($this->requests)) { + // Create a generator that yields promises on-demand for proper concurrency control + $promiseGenerator = function () { + foreach ($this->requests as $key => $item) { + yield $key => match (true) { + $item instanceof Closure => $item(), + $item instanceof PendingRequest => $item->getPromise(), + default => $item, + }; + } }; - $promises[$key] = $promise; - } - - if (! empty($promises)) { $eachPromiseOptions = [ 'fulfilled' => function ($result, $key) use (&$results) { $results[$key] = $result; @@ -311,7 +312,7 @@ public function send(): array $eachPromiseOptions['concurrency'] = $this->concurrencyLimit; } - (new EachPromise($promises, $eachPromiseOptions))->promise()->wait(); + (new EachPromise($promiseGenerator(), $eachPromiseOptions))->promise()->wait(); } // Before returning the results, we must ensure that the results are sorted @@ -422,7 +423,7 @@ public function getRequests(): array * * @param string $method * @param array $parameters - * @return \Illuminate\Http\Client\PendingRequest|\GuzzleHttp\Promise\Promise + * @return \Illuminate\Http\Client\PendingRequest */ public function __call(string $method, array $parameters) { @@ -432,6 +433,10 @@ public function __call(string $method, array $parameters) $this->incrementPendingRequests(); - return $this->requests[] = $this->asyncRequest()->$method(...$parameters); + // Get the next numeric index + $key = count($this->requests); + + // Create a deferred PendingRequest and call the method to start chaining + return $this->factory->setHandler($this->handler)->async()->setDeferred($this->requests, $key)->$method(...$parameters); } } diff --git a/src/Illuminate/Http/Client/DeferredPromise.php b/src/Illuminate/Http/Client/DeferredPromise.php new file mode 100644 index 000000000000..baeadbff7b62 --- /dev/null +++ b/src/Illuminate/Http/Client/DeferredPromise.php @@ -0,0 +1,99 @@ + + */ + protected $transformations = []; + + /** + * @param array &$requests Reference to the pool/batch requests array + * @param string|int $key The key for this request + * @param callable $promiseFactory Closure that returns a promise + */ + public function __construct(array &$requests, $key, callable $promiseFactory) + { + $this->requests = &$requests; + $this->key = $key; + $this->promiseFactory = $promiseFactory; + + // Store the factory in the requests array + $this->updateRequestsClosure(); + } + + /** + * Add a promise transformation (like ->then()). + * + * @param callable|null $onFulfilled + * @param callable|null $onRejected + * @return $this + */ + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) + { + $this->transformations[] = ['method' => 'then', 'args' => [$onFulfilled, $onRejected]]; + $this->updateRequestsClosure(); + + return $this; + } + + /** + * Add an otherwise transformation. + * + * @param callable $onRejected + * @return $this + */ + public function otherwise(callable $onRejected) + { + $this->transformations[] = ['method' => 'otherwise', 'args' => [$onRejected]]; + $this->updateRequestsClosure(); + + return $this; + } + + /** + * Update the closure stored in the requests array to include all transformations. + * + * @return void + */ + protected function updateRequestsClosure() + { + $promiseFactory = $this->promiseFactory; + $transformations = $this->transformations; + + $this->requests[$this->key] = function () use ($promiseFactory, $transformations) { + $promise = $promiseFactory(); + + foreach ($transformations as $transformation) { + $promise = $promise->{$transformation['method']}(...$transformation['args']); + } + + return $promise; + }; + } +} diff --git a/src/Illuminate/Http/Client/PendingRequest.php b/src/Illuminate/Http/Client/PendingRequest.php index dcab5cb387c1..4f385f2bd4ae 100644 --- a/src/Illuminate/Http/Client/PendingRequest.php +++ b/src/Illuminate/Http/Client/PendingRequest.php @@ -230,6 +230,20 @@ class PendingRequest */ protected $truncateExceptionsAt = null; + /** + * Reference to the pool/batch requests array for deferred execution. + * + * @var array|null + */ + protected $deferredRequests = null; + + /** + * The key for this request in the pool/batch for deferred execution. + * + * @var string|int|null + */ + protected $deferredKey = null; + /** * Create a new HTTP Client instance. * @@ -786,17 +800,36 @@ public function dd() }); } + /** + * Enable deferred execution mode for pool/batch requests. + * + * @param array &$requests Reference to the pool/batch requests array + * @param string|int $key The key for this request + * @return $this + */ + public function setDeferred(array &$requests, $key) + { + $this->deferredRequests = &$requests; + $this->deferredKey = $key; + + return $this; + } + /** * Issue a GET request to the given URL. * * @param string $url * @param array|string|null $query - * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface + * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface|$this * * @throws \Illuminate\Http\Client\ConnectionException */ public function get(string $url, $query = null) { + if ($this->deferredRequests !== null) { + return $this->deferRequest('get', func_get_args()); + } + return $this->send('GET', $url, func_num_args() === 1 ? [] : [ 'query' => $query, ]); @@ -807,12 +840,16 @@ public function get(string $url, $query = null) * * @param string $url * @param array|string|null $query - * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface + * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface|$this * * @throws \Illuminate\Http\Client\ConnectionException */ public function head(string $url, $query = null) { + if ($this->deferredRequests !== null) { + return $this->deferRequest('head', func_get_args()); + } + return $this->send('HEAD', $url, func_num_args() === 1 ? [] : [ 'query' => $query, ]); @@ -823,12 +860,16 @@ public function head(string $url, $query = null) * * @param string $url * @param array|\JsonSerializable|\Illuminate\Contracts\Support\Arrayable $data - * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface + * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface|$this * * @throws \Illuminate\Http\Client\ConnectionException */ public function post(string $url, $data = []) { + if ($this->deferredRequests !== null) { + return $this->deferRequest('post', func_get_args()); + } + return $this->send('POST', $url, [ $this->bodyFormat => $data, ]); @@ -839,12 +880,16 @@ public function post(string $url, $data = []) * * @param string $url * @param array|\JsonSerializable|\Illuminate\Contracts\Support\Arrayable $data - * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface + * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface|$this * * @throws \Illuminate\Http\Client\ConnectionException */ public function patch(string $url, $data = []) { + if ($this->deferredRequests !== null) { + return $this->deferRequest('patch', func_get_args()); + } + return $this->send('PATCH', $url, [ $this->bodyFormat => $data, ]); @@ -855,12 +900,16 @@ public function patch(string $url, $data = []) * * @param string $url * @param array|\JsonSerializable|\Illuminate\Contracts\Support\Arrayable $data - * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface + * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface|$this * * @throws \Illuminate\Http\Client\ConnectionException */ public function put(string $url, $data = []) { + if ($this->deferredRequests !== null) { + return $this->deferRequest('put', func_get_args()); + } + return $this->send('PUT', $url, [ $this->bodyFormat => $data, ]); @@ -871,12 +920,16 @@ public function put(string $url, $data = []) * * @param string $url * @param array|\JsonSerializable|\Illuminate\Contracts\Support\Arrayable $data - * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface + * @return \Illuminate\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface|$this * * @throws \Illuminate\Http\Client\ConnectionException */ public function delete(string $url, $data = []) { + if ($this->deferredRequests !== null) { + return $this->deferRequest('delete', func_get_args()); + } + return $this->send('DELETE', $url, empty($data) ? [] : [ $this->bodyFormat => $data, ]); @@ -896,20 +949,34 @@ public function pool(callable $callback, ?int $concurrency = null) $requests = tap(new Pool($this->factory), $callback)->getRequests(); if ($concurrency === null) { + $promises = []; foreach ($requests as $key => $item) { - $results[$key] = $item instanceof static ? $item->getPromise()->wait() : $item->wait(); + $promises[$key] = match (true) { + $item instanceof Closure => $item(), + $item instanceof static => $item->getPromise(), + default => $item, + }; + } + + foreach ($promises as $key => $promise) { + $results[$key] = $promise->wait(); } return $results; } - $promises = []; - - foreach ($requests as $key => $item) { - $promises[$key] = $item instanceof static ? $item->getPromise() : $item; - } + // Use a generator to create promises on-demand for proper concurrency control + $promiseGenerator = function () use ($requests) { + foreach ($requests as $key => $item) { + yield $key => match (true) { + $item instanceof Closure => $item(), + $item instanceof static => $item->getPromise(), + default => $item, + }; + } + }; - (new EachPromise($promises, [ + (new EachPromise($promiseGenerator(), [ 'fulfilled' => function ($result, $key) use (&$results) { $results[$key] = $result; }, @@ -969,34 +1036,32 @@ public function send(string $method, string $url, array $options = []) $this->dispatchResponseReceivedEvent($response); - if ($response->successful()) { - return; - } - - try { - $shouldRetry = $this->retryWhenCallback ? call_user_func($this->retryWhenCallback, $response->toException(), $this, $this->request->toPsrRequest()->getMethod()) : true; - } catch (Exception $exception) { - $shouldRetry = false; + if (! $response->successful()) { + try { + $shouldRetry = $this->retryWhenCallback ? call_user_func($this->retryWhenCallback, $response->toException(), $this, $this->request->toPsrRequest()->getMethod()) : true; + } catch (Exception $exception) { + $shouldRetry = false; - throw $exception; - } + throw $exception; + } - if ($this->throwCallback && - ($this->throwIfCallback === null || - call_user_func($this->throwIfCallback, $response))) { - $response->throw($this->throwCallback); - } + if ($this->throwCallback && + ($this->throwIfCallback === null || + call_user_func($this->throwIfCallback, $response))) { + $response->throw($this->throwCallback); + } - $potentialTries = is_array($this->tries) - ? count($this->tries) + 1 - : $this->tries; + $potentialTries = is_array($this->tries) + ? count($this->tries) + 1 + : $this->tries; - if ($attempt < $potentialTries && $shouldRetry) { - $response->throw(); - } + if ($attempt < $potentialTries && $shouldRetry) { + $response->throw(); + } - if ($potentialTries > 1 && $this->retryThrow) { - $response->throw(); + if ($potentialTries > 1 && $this->retryThrow) { + $response->throw(); + } } }); } catch (TransferException $e) { @@ -1759,6 +1824,32 @@ public function setHandler($handler) return $this; } + /** + * Defer a request execution for pool/batch processing. + * + * @param string $method + * @param array $parameters + * @return \Illuminate\Http\Client\DeferredPromise + */ + protected function deferRequest(string $method, array $parameters) + { + // Clone the current request state to capture all configuration + $clonedRequest = clone $this; + + // Clear deferred mode on the clone to prevent infinite recursion + // We need to break the reference completely + unset($clonedRequest->deferredRequests, $clonedRequest->deferredKey); + $clonedRequest->deferredRequests = null; + $clonedRequest->deferredKey = null; + + // Create a DeferredPromise that wraps the closure + return new DeferredPromise( + $this->deferredRequests, + $this->deferredKey, + fn () => $clonedRequest->$method(...$parameters) + ); + } + /** * Get the pending request options. * diff --git a/src/Illuminate/Http/Client/Pool.php b/src/Illuminate/Http/Client/Pool.php index e9716be08571..5cdcc353070c 100644 --- a/src/Illuminate/Http/Client/Pool.php +++ b/src/Illuminate/Http/Client/Pool.php @@ -49,7 +49,7 @@ public function __construct(?Factory $factory = null) */ public function as(string $key) { - return $this->pool[$key] = $this->asyncRequest(); + return $this->factory->setHandler($this->handler)->async()->setDeferred($this->pool, $key); } /** @@ -77,10 +77,14 @@ public function getRequests() * * @param string $method * @param array $parameters - * @return \Illuminate\Http\Client\PendingRequest|\GuzzleHttp\Promise\Promise + * @return \Illuminate\Http\Client\PendingRequest */ public function __call($method, $parameters) { - return $this->pool[] = $this->asyncRequest()->$method(...$parameters); + // Get the next numeric index + $key = count($this->pool); + + // Create a deferred PendingRequest and call the method to start chaining + return $this->factory->setHandler($this->handler)->async()->setDeferred($this->pool, $key)->$method(...$parameters); } } diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index febc488c3a91..8b2601a9e4e2 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -1939,6 +1939,33 @@ public function testMiddlewareRunsInPool() $this->assertSame(['hyped-for' => 'laravel-movie'], json_decode(tap($history[0]['request']->getBody())->rewind()->getContents(), true)); } + public function testMiddlewareRunsInBatch() + { + $this->factory->fake(function (Request $request) { + return $this->factory->response('Fake'); + }); + + $history = []; + + $middleware = Middleware::history($history); + + $batch = $this->factory->batch(fn (Batch $batch) => [ + $batch->withMiddleware($middleware)->post('https://example.com', ['hyped-for' => 'laravel-movie']), + ]); + + $responses = $batch->send(); + + $response = $responses[0]; + + $this->assertSame('Fake', $response->body()); + + $this->assertCount(1, $history); + + $this->assertSame('Fake', tap($history[0]['response']->getBody())->rewind()->getContents()); + + $this->assertSame(['hyped-for' => 'laravel-movie'], json_decode(tap($history[0]['request']->getBody())->rewind()->getContents(), true)); + } + public function testPoolConcurrency() { $this->factory->fake([