feat(http): add middleware handlers and fix async pipeline#85
feat(http): add middleware handlers and fix async pipeline#85SNO7E-G wants to merge 1 commit intomicrosoft:mainfrom
Conversation
@microsoft-github-policy-service agree |
There was a problem hiding this comment.
Pull request overview
Adds new observability-related middleware to the Guzzle HTTP adapter (BodyInspection + Sunset), updates the default handler stack ordering, and adjusts the request adapter to route requests through Guzzle’s async pipeline while preserving the existing synchronous adapter interface.
Changes:
- Added
BodyInspectionHandler/BodyInspectionOptionandSunsetHandler/SunsetOption, plus unit tests for both. - Added
UrlReplaceHandler::HANDLER_NAMEand inserted UrlReplace + Sunset intoKiotaClientFactory::getDefaultHandlerStack(). - Switched adapter execution from
send()tosendAsync()->wait()to ensure async middleware pipeline is exercised.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/http/guzzle/src/Middleware/BodyInspectionHandler.php | New middleware to copy request/response bodies for inspection and set OTel attributes. |
| packages/http/guzzle/src/Middleware/Options/BodyInspectionOption.php | New request option to configure body inspection and hold copied streams. |
| packages/http/guzzle/src/Middleware/SunsetHandler.php | New middleware to detect Sunset/Link headers and emit an OTel event. |
| packages/http/guzzle/src/Middleware/Options/SunsetOption.php | New request option to enable/disable Sunset middleware per request. |
| packages/http/guzzle/src/Middleware/KiotaMiddleware.php | Adds factory methods to register the new middleware in stacks. |
| packages/http/guzzle/src/Middleware/UrlReplaceHandler.php | Adds handler name constant for stack registration consistency. |
| packages/http/guzzle/src/KiotaClientFactory.php | Inserts UrlReplace and Sunset into the default handler stack. |
| packages/http/guzzle/src/GuzzleRequestAdapter.php | Routes requests through sendAsync() to exercise async middleware path. |
| packages/http/guzzle/tests/Middleware/BodyInspectionHandlerTest.php | New tests for body inspection behavior and per-request override. |
| packages/http/guzzle/tests/Middleware/SunsetHandlerTest.php | New tests for Sunset header parsing and per-request override. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| if (array_key_exists(SunsetOption::class, $options) && | ||
| $options[SunsetOption::class] instanceof SunsetOption) { | ||
| $this->sunsetOption = $options[SunsetOption::class]; | ||
| } | ||
|
|
||
| $span->setAttribute(self::HANDLER_ENABLED_KEY, $this->sunsetOption->getEnabled()); | ||
|
|
||
| $fn = $this->nextHandler; | ||
| $promise = $fn($request, $options)->then( | ||
| function (?ResponseInterface $response) use ($span) { | ||
| if (!$response) { | ||
| return $response; | ||
| } | ||
|
|
||
| if ($this->sunsetOption->getEnabled() && $response->hasHeader('Sunset')) { | ||
| $sunsetDate = $response->getHeaderLine('Sunset'); | ||
| $attributes = [ | ||
| 'sunset_date' => $sunsetDate | ||
| ]; | ||
|
|
||
| if ($response->hasHeader('Link')) { | ||
| $links = $response->getHeader('Link'); | ||
| foreach ($links as $link) { | ||
| if (stripos($link, 'rel="sunset"') !== false || stripos($link, "rel='sunset'") !== false) { | ||
| if (preg_match('/<([^>]+)>/', $link, $matches)) { | ||
| $attributes['sunset_link'] = $matches[1]; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| $span->addEvent(self::EVENT_NAME, $attributes); | ||
| } | ||
| return $response; | ||
| } | ||
| ); | ||
| } finally { | ||
| $scope->detach(); | ||
| $span->end(); | ||
| } |
There was a problem hiding this comment.
The span is ended in the finally block immediately after registering the then(...) callback, which means the span will typically end before the async handler chain resolves and before addEvent(...) runs. This can cause the event to be dropped or attached to an already-ended span. Prefer ending the span (and detaching scope) in a promise finalization callback (e.g., ensure it runs after success/failure of the downstream promise), and return the chained promise.
| try { | |
| if (array_key_exists(SunsetOption::class, $options) && | |
| $options[SunsetOption::class] instanceof SunsetOption) { | |
| $this->sunsetOption = $options[SunsetOption::class]; | |
| } | |
| $span->setAttribute(self::HANDLER_ENABLED_KEY, $this->sunsetOption->getEnabled()); | |
| $fn = $this->nextHandler; | |
| $promise = $fn($request, $options)->then( | |
| function (?ResponseInterface $response) use ($span) { | |
| if (!$response) { | |
| return $response; | |
| } | |
| if ($this->sunsetOption->getEnabled() && $response->hasHeader('Sunset')) { | |
| $sunsetDate = $response->getHeaderLine('Sunset'); | |
| $attributes = [ | |
| 'sunset_date' => $sunsetDate | |
| ]; | |
| if ($response->hasHeader('Link')) { | |
| $links = $response->getHeader('Link'); | |
| foreach ($links as $link) { | |
| if (stripos($link, 'rel="sunset"') !== false || stripos($link, "rel='sunset'") !== false) { | |
| if (preg_match('/<([^>]+)>/', $link, $matches)) { | |
| $attributes['sunset_link'] = $matches[1]; | |
| } | |
| } | |
| } | |
| } | |
| $span->addEvent(self::EVENT_NAME, $attributes); | |
| } | |
| return $response; | |
| } | |
| ); | |
| } finally { | |
| $scope->detach(); | |
| $span->end(); | |
| } | |
| if (array_key_exists(SunsetOption::class, $options) && | |
| $options[SunsetOption::class] instanceof SunsetOption) { | |
| $this->sunsetOption = $options[SunsetOption::class]; | |
| } | |
| $span->setAttribute(self::HANDLER_ENABLED_KEY, $this->sunsetOption->getEnabled()); | |
| $fn = $this->nextHandler; | |
| $promise = $fn($request, $options)->then( | |
| function (?ResponseInterface $response) use ($span, $scope) { | |
| if (!$response) { | |
| $scope->detach(); | |
| $span->end(); | |
| return $response; | |
| } | |
| if ($this->sunsetOption->getEnabled() && $response->hasHeader('Sunset')) { | |
| $sunsetDate = $response->getHeaderLine('Sunset'); | |
| $attributes = [ | |
| 'sunset_date' => $sunsetDate | |
| ]; | |
| if ($response->hasHeader('Link')) { | |
| $links = $response->getHeader('Link'); | |
| foreach ($links as $link) { | |
| if (stripos($link, 'rel="sunset"') !== false || stripos($link, "rel='sunset'") !== false) { | |
| if (preg_match('/<([^>]+)>/', $link, $matches)) { | |
| $attributes['sunset_link'] = $matches[1]; | |
| } | |
| } | |
| } | |
| } | |
| $span->addEvent(self::EVENT_NAME, $attributes); | |
| } | |
| $scope->detach(); | |
| $span->end(); | |
| return $response; | |
| }, | |
| function ($reason) use ($span, $scope) { | |
| $scope->detach(); | |
| $span->end(); | |
| return \GuzzleHttp\Promise\rejection_for($reason); | |
| } | |
| ); |
| if (array_key_exists(SunsetOption::class, $options) && | ||
| $options[SunsetOption::class] instanceof SunsetOption) { | ||
| $this->sunsetOption = $options[SunsetOption::class]; | ||
| } | ||
|
|
||
| $span->setAttribute(self::HANDLER_ENABLED_KEY, $this->sunsetOption->getEnabled()); | ||
|
|
||
| $fn = $this->nextHandler; | ||
| $promise = $fn($request, $options)->then( | ||
| function (?ResponseInterface $response) use ($span) { | ||
| if (!$response) { | ||
| return $response; | ||
| } | ||
|
|
||
| if ($this->sunsetOption->getEnabled() && $response->hasHeader('Sunset')) { |
There was a problem hiding this comment.
Mutating $this->sunsetOption inside __invoke makes the middleware instance state request-dependent. Because a single handler instance can be reused across multiple in-flight async requests, this can lead to cross-request option leakage/races (one request overwriting another request’s option). Use a local $sunsetOption resolved from $options (falling back to the constructor-provided default) and capture that local value in the then(...) closure instead of updating the property.
| if (array_key_exists(SunsetOption::class, $options) && | |
| $options[SunsetOption::class] instanceof SunsetOption) { | |
| $this->sunsetOption = $options[SunsetOption::class]; | |
| } | |
| $span->setAttribute(self::HANDLER_ENABLED_KEY, $this->sunsetOption->getEnabled()); | |
| $fn = $this->nextHandler; | |
| $promise = $fn($request, $options)->then( | |
| function (?ResponseInterface $response) use ($span) { | |
| if (!$response) { | |
| return $response; | |
| } | |
| if ($this->sunsetOption->getEnabled() && $response->hasHeader('Sunset')) { | |
| $sunsetOption = $this->sunsetOption; | |
| if (array_key_exists(SunsetOption::class, $options) && | |
| $options[SunsetOption::class] instanceof SunsetOption) { | |
| $sunsetOption = $options[SunsetOption::class]; | |
| } | |
| $span->setAttribute(self::HANDLER_ENABLED_KEY, $sunsetOption->getEnabled()); | |
| $fn = $this->nextHandler; | |
| $promise = $fn($request, $options)->then( | |
| function (?ResponseInterface $response) use ($span, $sunsetOption) { | |
| if (!$response) { | |
| return $response; | |
| } | |
| if ($sunsetOption->getEnabled() && $response->hasHeader('Sunset')) { |
| public function __invoke(RequestInterface $request, array $options): PromiseInterface | ||
| { | ||
| $span = $this->tracer->spanBuilder(self::SPAN_NAME)->startSpan(); | ||
| $scope = $span->activate(); | ||
|
|
||
| try { | ||
| if (array_key_exists(BodyInspectionOption::class, $options) && | ||
| $options[BodyInspectionOption::class] instanceof BodyInspectionOption) { | ||
| $this->inspectionOption = $options[BodyInspectionOption::class]; | ||
| } | ||
|
|
||
| $span->setAttribute(self::HANDLER_ENABLED_KEY, $this->inspectionOption->getInspectRequestBody() || $this->inspectionOption->getInspectResponseBody()); | ||
|
|
||
| if ($this->inspectionOption->getInspectRequestBody()) { | ||
| $request = $this->inspectStream($request, true); | ||
| } | ||
|
|
||
| $fn = $this->nextHandler; | ||
| return $fn($request, $options)->then( | ||
| function (?ResponseInterface $response) { | ||
| if (!$response) { | ||
| return $response; | ||
| } | ||
| if ($this->inspectionOption->getInspectResponseBody()) { | ||
| $response = $this->inspectStream($response, false); | ||
| } | ||
| return $response; | ||
| } | ||
| ); | ||
| } finally { | ||
| $scope->detach(); | ||
| $span->end(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Two related issues here for async execution: (1) $this->inspectionOption is overwritten per request, which can leak state across concurrent in-flight requests (same risk as SunsetHandler); resolve the option into a local variable and capture it in closures instead. (2) The span is ended in finally before the downstream promise resolves, so response inspection and any attributes/events recorded during the then(...) step can happen after the span has ended; move scope detach + span end into a promise finalization step that runs after resolution/rejection.
| if ($response->hasHeader('Link')) { | ||
| $links = $response->getHeader('Link'); | ||
| foreach ($links as $link) { | ||
| if (stripos($link, 'rel="sunset"') !== false || stripos($link, "rel='sunset'") !== false) { | ||
| if (preg_match('/<([^>]+)>/', $link, $matches)) { | ||
| $attributes['sunset_link'] = $matches[1]; | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This Link parsing will miss valid cases where a single Link header line contains multiple comma-separated link-values (common per RFC 8288); your loop iterates header lines but doesn’t split multiple values within one line, and the regex will capture only the first <...> even if the rel=\"sunset\" link is later in the same line. Consider parsing each header line into individual link-values (splitting on commas outside of quotes/angle brackets) and then extracting the URI for the one whose parameters include rel=\"sunset\".
| $this->assertNull($globalOption->getResponseBody()); | ||
| } | ||
|
|
||
| private function executeMockRequest(array $mockResponses, ?BodyInspectionOption $bodyInspectionOption = null, ?array $requestOptions = []): ResponseInterface |
There was a problem hiding this comment.
The helper declares $requestOptions as ?array but uses it as an array (e.g., passed into array_merge(...)). If null is ever passed, the test will fatal. Make this parameter a non-nullable array $requestOptions = [] (same applies to the analogous helper in SunsetHandlerTest).
| private function executeMockRequest(array $mockResponses, ?BodyInspectionOption $bodyInspectionOption = null, ?array $requestOptions = []): ResponseInterface | |
| private function executeMockRequest(array $mockResponses, ?BodyInspectionOption $bodyInspectionOption = null, array $requestOptions = []): ResponseInterface |
baywet
left a comment
There was a problem hiding this comment.
Thanks for the great contribution! As you can see the CI is failing on the request adapter. Would you mind having a look before we can merge this please?
0adf9be to
a62f827
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } finally { | ||
| $scope->detach(); | ||
| $span->end(); | ||
| } |
There was a problem hiding this comment.
The span/scope are detached/ended in finally before the returned promise resolves. Since the Sunset event is added in the then(...) callback, it will likely run after the span has ended (and can be dropped / have incorrect timing). End/detach the span when the downstream promise settles (both fulfillment and rejection), not immediately after returning the promise.
There was a problem hiding this comment.
I actually tried this exact approach — moving detach()/end() into the then() and rejection callbacks. Unfortunately it broke the KiotaClientFactoryTest because OpenTelemetry's DebugScope enforces strict LIFO order for scope detachment. When multiple middleware handlers are stacked (Retry → Sunset → BodyInspection → HeadersInspection), detaching scopes inside promise callbacks happens in an unpredictable order, which violates the LIFO contract and throws a runtime error.
The current try/finally pattern is actually the same approach used by the existing HeadersInspectionHandler and RetryHandler in this repo, so I kept it consistent. A proper fix would likely require rethinking scope management across the entire middleware stack, which feels out of scope for this PR.
| } finally { | ||
| $scope->detach(); | ||
| $span->end(); | ||
| } |
There was a problem hiding this comment.
The span/scope are detached/ended in finally before the returned promise resolves, so the span lifetime won’t cover the downstream handler execution. If this span is intended to measure the interception around request/response, move detach()/end() into callbacks that run when the downstream promise fulfills/rejects.
a62f827 to
cfebf55
Compare
I've just pushed a new commit to fix the CI failure, and I also took the time to do a deep-dive review of the architectural feedback that Copilot left. The initial CI issue in GuzzleRequestAdapter was just PHPStan Level 9 being extra strict. By switching from send() to sendAsync()->wait(), Guzzle's promise technically returns While I was at it, Copilot actually caught some incredibly solid edge cases regarding the Guzzle HandlerStack and RFC specifications, so I went ahead and fixed those to make this PR bulletproof: 1. Mutating State inside Singletons (Fixed) 2. Stream Cursor Preservation (Fixed) 3. RFC 8288 Link Header Parsing (Fixed) 4. Span Timing in OpenTelemetry (Intentionally Ignored) 5. Minor Polish: Fixed a nullable array type-hint in the test helpers that All 90 tests (182 assertions) are passing locally, and PHPStan is reporting zero errors on the modified files. The CI should go green shortly! Let me know if you need any other tweaks. 🚀 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| foreach ($linkValues as $linkValue) { | ||
| $linkValue = trim($linkValue); | ||
| if (stripos($linkValue, 'rel="sunset"') !== false || stripos($linkValue, "rel='sunset'") !== false) { | ||
| if (preg_match('/<([^>]+)>/', $linkValue, $matches)) { | ||
| $attributes['sunset_link'] = $matches[1]; | ||
| } | ||
| } |
There was a problem hiding this comment.
The Link header parsing checks for rel="sunset" by substring search across the entire link-value. This can produce false positives (e.g., if the URI inside <...> contains rel="sunset") and also misses valid RFC 8288 cases like rel=sunset (unquoted) or rel="successor-version sunset" (multiple relation-types). Parse the link parameters (after the closing >) and specifically extract the rel parameter, splitting relation-types on whitespace, before deciding whether to set sunset_link.
| public function testSunsetHandlerEnabledByDefaultWithSunsetHeader(): void | ||
| { | ||
| $option = new SunsetOption(); | ||
| $this->assertTrue($option->getEnabled()); | ||
|
|
||
| $mockResponse = [ | ||
| new Response(200, ['Sunset' => 'Sun, 11 Nov 2024 08:49:37 GMT']) | ||
| ]; | ||
| $response = $this->executeMockRequest($mockResponse, $option); | ||
| $this->assertTrue($response->hasHeader('Sunset')); | ||
| } | ||
|
|
||
| public function testSunsetHandlerWithLinkHeader(): void | ||
| { | ||
| $option = new SunsetOption(); | ||
| $mockResponse = [ | ||
| new Response(200, [ | ||
| 'Sunset' => 'Sun, 11 Nov 2024 08:49:37 GMT', | ||
| 'Link' => '<https://api.github.com/foo/bar>; rel="sunset"' | ||
| ]) | ||
| ]; | ||
| $response = $this->executeMockRequest($mockResponse, $option); | ||
| $this->assertTrue($response->hasHeader('Sunset')); | ||
| $this->assertTrue($response->hasHeader('Link')); | ||
| } |
There was a problem hiding this comment.
These tests mostly assert that the mocked response still contains the Sunset/Link headers, which would pass even if the Sunset middleware were removed entirely. To actually validate SunsetHandler behavior (especially Link parsing and per-request overrides), consider injecting a test tracer via ObservabilityOption::setTracer(...) and asserting that the com.microsoft.kiota.sunset_header_received event is emitted with the expected sunset_date/sunset_link attributes (and not emitted when disabled or when no Sunset header is present).
| function () use ($requestInfo, &$httpResponseSpan) { | ||
| $psrRequest = $this->getPsrRequestFromRequestInformation($requestInfo, $httpResponseSpan); | ||
| $httpResponseSpan->setStatus(StatusCode::STATUS_OK, 'Request Information Success'); | ||
| return $this->guzzleClient->send($psrRequest, $requestInfo->getRequestOptions()); | ||
| /** @var \Psr\Http\Message\ResponseInterface $response */ | ||
| $response = $this->guzzleClient->sendAsync($psrRequest, $requestInfo->getRequestOptions())->wait(); | ||
| return $response; |
There was a problem hiding this comment.
The switch from send() to sendAsync()->wait() is the key behavioral change for async middleware bridging, but current adapter tests use a real Client with MockHandler and don’t assert which client method is invoked. Consider adding a unit test that injects a mocked ClientInterface and asserts sendAsync() is called (and send() is not), returning a fulfilled Guzzle promise so the adapter promise resolves as expected.
|
Thank you for making the changes! |
Hey team!
This PR implements several outstanding enhancements and bug fixes for the Guzzle HTTP adapter. I noticed a few related issues around observational middleware and async promise bridging, so I bundled them here since they all affect the same middleware stack.
Below is a detailed breakdown of the implementations, their alignment with the official Microsoft specifications, and exactly what this PR resolves.
1. Implementation of BodyInspectionHandler (Closes #33)
Implemented the BodyInspectionHandler and BodyInspectionOption strictly following the requirements in the msgraph-sdk-design spec.
Utils::streamFor()ifisSeekable()is true, and rewinds the original streams to avoid exhausting the payloads.BodyInspectionHandler_interceptspan and sets thecom.microsoft.kiota.handler.bodyInspection.enableattribute.2. Implementation of SunsetHandler (Closes #35)
Added the SunsetHandler to proactively warn applications when APIs are slated for deprecation, per the msgraph-sdk-design spec.
rel="sunset"per RFC 8594.com.microsoft.kiota.sunset_header_receivedOpenTelemetry event.sunset_dateandsunset_linkdirectly to the event, while ensuring no sensitive URI tags leak into the span.3. UrlReplaceHandler added to defaults (Closes #34)
Added
public const HANDLER_NAME = 'kiotaUrlReplaceHandler'to the UrlReplaceHandler.KiotaClientFactory::getDefaultHandlerStack().ParametersNameDecodingHandler, respecting the official middleware execution pipeline.4. Fix for Synchronous Blocking (Closes #32)
The GuzzleRequestAdapter was previously bypassing async behavior entirely by using the synchronous
$this->guzzleClient->send().CurlMultiHandler, fixing the synchronous blocking within the Guzzle pipeline itself.kiota-abstractions(asphp-http/promisedoesn't natively flatten nested promises as Guzzle does). However, this is the correct, non-breaking first step for the HTTP module.php-http/promiseversion bump proposed in Require php-http/promise 1.3.0 to be compatible with PHP 8.4 #53 (works with both ~1.2.0 and 1.3.0).5. Verified strictly against PHPStan 2.0 (Relates to #10)
Issue #10 spans the entire monorepo, covering serialization, auth, abstractions, and http. This PR cleanly resolves the
area:httprequirements for PHPStan 2.0.vendor/bin/phpstan analysepipeline onpackages/http/guzzle.Testing & Verification
BodyInspectionHandlerTestandSunsetHandlerTestagainst tricky edge cases, including:LinkHeaders ensuringrel="sunset"is correctly isolated and extracted parsing.microsoftgraph/Observabilityspecifications.89tests and180assertions pass.Let me know if you need any adjustments or if you'd like me to split any of these out, but they seem to fit together nicely in the middleware stack! Thanks!