From e1dfd409ec63c7839f9741f9c7095c11136c6d85 Mon Sep 17 00:00:00 2001 From: Sebastian Michaelsen Date: Thu, 6 Aug 2026 09:50:06 +0200 Subject: [PATCH] fix(core): merge per-call restOptions with the client-level ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RequestWrapper::getRequestOptions() replaced the client-level restOptions with the per-call ones whenever a call supplied any, so transport settings configured once on the client — proxy, verify, cert — were silently dropped for those calls. Storage made this visible in v1.51.0: the X-Goog-Hash checksum header added in #8825 travels as a per-call restOptions entry, so every upload lost the client's proxy configuration. Downloads hit the same path through the on_headers callback used to detect transcoded objects. In proxy-only environments uploads and downloads stopped working while metadata and auth calls, which pass no per-call restOptions, kept succeeding. Per-call options now take precedence over the client-level ones on conflict, and nested `headers` arrays are merged on their own so a per-call header does not drop the client's default headers. The merge is otherwise shallow, which avoids array_merge_recursive turning colliding scalars such as `proxy` into arrays that Guzzle rejects. Root cause analysis and the merge strategy are from @salilg-eng in #9212. Fixes #9212 --- Core/src/RequestWrapper.php | 16 +++++- Core/tests/Unit/RequestWrapperTest.php | 67 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/Core/src/RequestWrapper.php b/Core/src/RequestWrapper.php index e0d4d729d864..3ba7ead28175 100644 --- a/Core/src/RequestWrapper.php +++ b/Core/src/RequestWrapper.php @@ -465,12 +465,26 @@ private function getExceptionMessage(\Exception $ex) /** * Gets a set of request options. * + * Per-call options are merged over the client-level ones rather than replacing + * them, so that transport settings configured once on the client (`proxy`, + * `verify`, `cert`, ...) still apply to calls that supply their own options. + * The merge is shallow, because Guzzle options are a flat list of mostly + * scalar values; `headers` is the one nested array worth merging on its own, + * so that a per-call header does not drop the client's default headers. + * * @param array $options * @return array */ private function getRequestOptions(array $options) { - $restOptions = $options['restOptions'] ?? $this->restOptions; + $perCallRestOptions = $options['restOptions'] ?? []; + $headers = ($perCallRestOptions['headers'] ?? []) + ($this->restOptions['headers'] ?? []); + $restOptions = $perCallRestOptions + $this->restOptions; + + if ($headers) { + $restOptions['headers'] = $headers; + } + $timeout = $options['requestTimeout'] ?? $this->requestTimeout; if ($timeout && !array_key_exists('timeout', $restOptions)) { diff --git a/Core/tests/Unit/RequestWrapperTest.php b/Core/tests/Unit/RequestWrapperTest.php index e7b891e97f2d..fa302411d02b 100644 --- a/Core/tests/Unit/RequestWrapperTest.php +++ b/Core/tests/Unit/RequestWrapperTest.php @@ -106,6 +106,73 @@ public function testSuccessfullySendsAsyncRequest() ); } + public function testClientLevelRestOptionsSurvivePerCallRestOptions() + { + $actualOptions = []; + $requestWrapper = new RequestWrapper([ + 'accessToken' => 'abc', + 'restOptions' => ['proxy' => 'http://proxy.example.com:8080', 'verify' => false], + 'httpHandler' => function ($request, $options = []) use (&$actualOptions) { + $actualOptions = $options; + return new Response(200); + } + ]); + + $requestWrapper->send( + new Request('GET', 'http://www.example.com'), + ['restOptions' => ['headers' => ['X-Goog-Hash' => 'crc32c=abc']]] + ); + + $this->assertEquals('http://proxy.example.com:8080', $actualOptions['proxy']); + $this->assertFalse($actualOptions['verify']); + $this->assertEquals(['X-Goog-Hash' => 'crc32c=abc'], $actualOptions['headers']); + } + + public function testPerCallRestOptionsTakePrecedenceOverClientLevelOnes() + { + $actualOptions = []; + $requestWrapper = new RequestWrapper([ + 'accessToken' => 'abc', + 'restOptions' => ['proxy' => 'http://proxy.example.com:8080', 'debug' => false], + 'httpHandler' => function ($request, $options = []) use (&$actualOptions) { + $actualOptions = $options; + return new Response(200); + } + ]); + + $requestWrapper->send( + new Request('GET', 'http://www.example.com'), + ['restOptions' => ['debug' => true]] + ); + + $this->assertTrue($actualOptions['debug']); + $this->assertEquals('http://proxy.example.com:8080', $actualOptions['proxy']); + } + + public function testRestOptionHeadersAreMergedRatherThanReplaced() + { + $actualOptions = []; + $requestWrapper = new RequestWrapper([ + 'accessToken' => 'abc', + 'restOptions' => ['headers' => ['X-Client' => 'default', 'X-Keep' => 'yes']], + 'httpHandler' => function ($request, $options = []) use (&$actualOptions) { + $actualOptions = $options; + return new Response(200); + } + ]); + + $requestWrapper->send( + new Request('GET', 'http://www.example.com'), + ['restOptions' => ['headers' => ['X-Client' => 'per-call', 'X-Goog-Hash' => 'crc32c=abc']]] + ); + + $this->assertEquals([ + 'X-Client' => 'per-call', + 'X-Goog-Hash' => 'crc32c=abc', + 'X-Keep' => 'yes', + ], $actualOptions['headers']); + } + public function testSendAsyncRetriesOnFailure() { $actualDelays = 0;