Generated the following bug report with Claude.
TLDR for fix
Update the TraceableHttpClient.php stream by mirroring how Symfony handles this with TraceableResponse.php
In short: TraceableHttpClient::stream() unwraps the inner TracedResponse wrappers it receives, calls $this->client->stream($underlying, $timeout), and returns the resulting ResponseStreamInterface directly — it never re-keys the yielded chunks back to the wrapper objects.
This violates the contract that Symfony\Component\HttpClient\Response\AsyncResponse (and any other caller that builds an SplObjectStorage lookup keyed on the response object it handed in) relies on, and produces:
UnexpectedValueException: Object not found
in vendor/symfony/http-client/Response/AsyncResponse.php:267
Symfony's own Symfony\Component\HttpClient\Response\TraceableResponse has the exact same wrapping pattern and does re-key — so the fix is a one-method, drop-in port of Symfony's existing logic.
TraceableHttpClient::stream() does not re-key chunks to wrapper responses, breaking AsyncResponse (and therefore RetryableHttpClient)
- Package:
traceway/opentelemetry-symfony
- Affected version:
1.6.1 (and likely earlier — same code shape since the decorator was introduced)
- Component:
Traceway\OpenTelemetryBundle\HttpClient\TraceableHttpClient
- Symfony:
symfony/http-client ≥ 5.2 (any version that ships AsyncResponse)
- PHP: any (the bug is logic, not language)
- Severity: High — any consumer that calls
->toArray() / ->getContent() on a response returned through TraceableHttpClient while RetryableHttpClient (or any other decorator that uses AsyncResponse internally) sits above TraceableHttpClient in the decorator chain will throw on every request.
Summary
TraceableHttpClient::stream() unwraps the inner TracedResponse wrappers it receives, calls $this->client->stream($underlying, $timeout), and returns the resulting ResponseStreamInterface directly — it never re-keys the yielded chunks back to the wrapper objects.
This violates the contract that Symfony\Component\HttpClient\Response\AsyncResponse (and any other caller that builds an SplObjectStorage lookup keyed on the response object it handed in) relies on, and produces:
UnexpectedValueException: Object not found
in vendor/symfony/http-client/Response/AsyncResponse.php:267
Symfony's own Symfony\Component\HttpClient\Response\TraceableResponse has the exact same wrapping pattern and does re-key — so the fix is a one-method, drop-in port of Symfony's existing logic.
Reproducer (no Symfony app required)
<?php
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Traceway\OpenTelemetryBundle\HttpClient\TraceableHttpClient;
$base = HttpClient::create();
$traced = new TraceableHttpClient($base, 'repro');
$retryable = new RetryableHttpClient($traced, maxRetries: 3);
$response = $retryable->request('GET', 'https://httpbin.org/json');
// Throws: UnexpectedValueException "Object not found"
// at vendor/symfony/http-client/Response/AsyncResponse.php:267
$data = $response->toArray();
Decorator stack at the point of failure (top = outermost):
RetryableHttpClient ← Symfony, builds AsyncResponse internally
└→ TraceableHttpClient ← Traceway, wraps result in TracedResponse
└→ CurlHttpClient ← Symfony native
RetryableHttpClient::request() builds an AsyncResponse whose passthru calls back into $this->client->stream([$wrapper]). Because $this->client is the Traceway decorator and its stream() does not re-key, AsyncResponse::initialize() cannot find its own wrapper in $asyncMap and throws.
Real-world stack trace
Captured in a Symfony 7.x application during a kernel.response event (CORS preflight OPTIONS request):
UnexpectedValueException: Object not found
in /app/vendor/symfony/http-client/Response/AsyncResponse.php:267
#0 /app/vendor/symfony/http-client/Response/AsyncResponse.php(67):
Symfony\Component\HttpClient\Response\AsyncResponse::stream(Array, -0.0)
#1 /app/vendor/symfony/http-client/Response/CommonResponseTrait.php(144):
Symfony\Component\HttpClient\Response\AsyncResponse::{closure}(...)
#2 /app/vendor/symfony/http-client/Response/CommonResponseTrait.php(43):
Symfony\Component\HttpClient\Response\AsyncResponse::initialize(...)
#3 /app/vendor/symfony/http-client/Response/CommonResponseTrait.php(79):
Symfony\Component\HttpClient\Response\AsyncResponse->getContent(true)
#4 /app/src/Service/Http/ProxyProvider.php(175):
Symfony\Component\HttpClient\Response\AsyncResponse->toArray()
↑ application code: $client->request('GET', $url)->toArray();
where $client is RetryableHttpClient → TraceableHttpClient → CurlHttpClient
Root cause
vendor/traceway/opentelemetry-symfony/src/HttpClient/TraceableHttpClient.php, lines 114–130 (v1.6.1):
public function stream(
ResponseInterface|iterable $responses,
?float $timeout = null,
): ResponseStreamInterface {
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
}
$underlyingResponses = [];
foreach ($responses as $response) {
$underlyingResponses[] = $response instanceof TracedResponse
? $response->getInnerResponse()
: $response;
}
return $this->client->stream($underlyingResponses, $timeout); // ← bug
}
The returned stream yields chunks keyed on the inner response object. The caller — typically AsyncResponse::initialize() — does:
// vendor/symfony/http-client/Response/AsyncResponse.php
foreach ($client->stream($wrappedResponses, $timeout) as $response => $chunk) {
$r = $asyncMap[$response]; // SplObjectStorage offset miss → UnexpectedValueException
// ...
}
$asyncMap was populated using the wrapper (TracedResponse) as the key, so the inner response is never found.
Reference: how Symfony solves the same problem
vendor/symfony/http-client/Response/TraceableResponse.php (≈ line 198):
public static function stream(HttpClientInterface $client, iterable $responses, ?float $timeout): \Generator
{
$traceableMap = new \SplObjectStorage();
$wrappedResponses = [];
foreach ($responses as $r) {
if (!$r instanceof self) {
throw new \TypeError(...);
}
$traceableMap[$r->response] = $r;
$wrappedResponses[] = $r->response;
}
foreach ($client->stream($wrappedResponses, $timeout) as $r => $chunk) {
// ... error/timing bookkeeping ...
yield $traceableMap[$r] => $chunk; // ← re-keys back to the wrapper
}
}
Any decorator that wraps responses in a custom type must re-key in stream(), otherwise it breaks every caller that builds a wrapper-keyed lookup map (AsyncResponse, user code that mirrors the same pattern, etc.).
Suggested fix
Mirror Symfony\Component\HttpClient\Response\TraceableResponse::stream():
public function stream(
ResponseInterface|iterable $responses,
?float $timeout = null,
): ResponseStreamInterface {
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
}
$tracedMap = new \SplObjectStorage();
$underlying = [];
foreach ($responses as $r) {
if ($r instanceof TracedResponse) {
$tracedMap[$r->getInnerResponse()] = $r;
$underlying[] = $r->getInnerResponse();
} else {
// pass-through for already-unwrapped responses
$tracedMap[$r] = $r;
$underlying[] = $r;
}
}
return new ResponseStream((function () use ($tracedMap, $underlying, $timeout) {
foreach ($this->client->stream($underlying, $timeout) as $r => $chunk) {
yield $tracedMap[$r] => $chunk;
}
})());
}
(The ResponseStream wrapper is Symfony\Component\HttpClient\Response\ResponseStream, same one Symfony uses internally.)
This preserves all current span/instrumentation behaviour because the chunks themselves are unchanged; only the key identity is restored.
Why the bug isn't always visible
Two conditions are required to trip it:
- The user calls a "buffering" terminal method on the response (
->toArray(), ->getContent(), ->getHeaders() on a stream where AsyncResponse is involved). A pure ->getStatusCode() happens to work because it short-circuits before the stream is consumed.
- Some decorator above
TraceableHttpClient constructs an AsyncResponse — most commonly Symfony's own RetryableHttpClient, but also EventSourceHttpClient, MockHttpClient in some configurations, or any user decorator that uses AsyncResponse to compose retries / fallbacks.
Without RetryableHttpClient (or equivalent) in front, TracedResponse is the outermost type the user touches and there's no wrapper-keyed SplObjectStorage to break. This is probably why the bug has gone unnoticed.
Workarounds for affected users
In rough order of cost:
A. Disable HTTP-client instrumentation — quickest, fully reversible, loses outbound HTTP spans only:
# config/packages/open_telemetry.yaml
traceway_open_telemetry:
instrumentation:
http_client_enabled: false
B. Reorder the decorator chain so TraceableHttpClient is outside RetryableHttpClient instead of inside it:
TraceableHttpClient ← outermost
└→ RetryableHttpClient
└→ CurlHttpClient
AsyncResponse then never sees a TracedResponse. Side effect: one span per logical request instead of one per retry attempt — usually the desired semantics anyway. Implementation requires a compiler pass to override Traceway's auto-decoration order.
C. Patch the vendor file locally via cweagans/composer-patches using the suggested fix above.
Environment that surfaced the bug
- PHP 8.4
- Symfony 7.x
symfony/http-client 7.x
traceway/opentelemetry-symfony 1.6.1
open-telemetry/sdk 1.x
- Decorator chain: application
DefaultHttpClient → RetryableHttpClient → Traceway TraceableHttpClient → CurlHttpClient
- Triggering call:
$client->request('GET', '...')->toArray()
Generated the following bug report with Claude.
TLDR for fix
Update the
TraceableHttpClient.phpstream by mirroring how Symfony handles this withTraceableResponse.phpIn short:
TraceableHttpClient::stream()unwraps the inner TracedResponse wrappers it receives, calls$this->client->stream($underlying, $timeout), and returns the resultingResponseStreamInterfacedirectly — it never re-keys the yielded chunks back to the wrapper objects.This violates the contract that
Symfony\Component\HttpClient\Response\AsyncResponse(and any other caller that builds an SplObjectStorage lookup keyed on the response object it handed in) relies on, and produces:Symfony's own
Symfony\Component\HttpClient\Response\TraceableResponsehas the exact same wrapping pattern and does re-key — so the fix is a one-method, drop-in port of Symfony's existing logic.TraceableHttpClient::stream()does not re-key chunks to wrapper responses, breakingAsyncResponse(and thereforeRetryableHttpClient)traceway/opentelemetry-symfony1.6.1(and likely earlier — same code shape since the decorator was introduced)Traceway\OpenTelemetryBundle\HttpClient\TraceableHttpClientsymfony/http-client≥ 5.2 (any version that shipsAsyncResponse)->toArray()/->getContent()on a response returned throughTraceableHttpClientwhileRetryableHttpClient(or any other decorator that usesAsyncResponseinternally) sits aboveTraceableHttpClientin the decorator chain will throw on every request.Summary
TraceableHttpClient::stream()unwraps the innerTracedResponsewrappers it receives, calls$this->client->stream($underlying, $timeout), and returns the resultingResponseStreamInterfacedirectly — it never re-keys the yielded chunks back to the wrapper objects.This violates the contract that
Symfony\Component\HttpClient\Response\AsyncResponse(and any other caller that builds anSplObjectStoragelookup keyed on the response object it handed in) relies on, and produces:Symfony's own
Symfony\Component\HttpClient\Response\TraceableResponsehas the exact same wrapping pattern and does re-key — so the fix is a one-method, drop-in port of Symfony's existing logic.Reproducer (no Symfony app required)
Decorator stack at the point of failure (top = outermost):
RetryableHttpClient::request()builds anAsyncResponsewhose passthru calls back into$this->client->stream([$wrapper]). Because$this->clientis the Traceway decorator and itsstream()does not re-key,AsyncResponse::initialize()cannot find its own wrapper in$asyncMapand throws.Real-world stack trace
Captured in a Symfony 7.x application during a
kernel.responseevent (CORS preflightOPTIONSrequest):Root cause
vendor/traceway/opentelemetry-symfony/src/HttpClient/TraceableHttpClient.php, lines 114–130 (v1.6.1):The returned stream yields chunks keyed on the inner response object. The caller — typically
AsyncResponse::initialize()— does:$asyncMapwas populated using the wrapper (TracedResponse) as the key, so the inner response is never found.Reference: how Symfony solves the same problem
vendor/symfony/http-client/Response/TraceableResponse.php(≈ line 198):Any decorator that wraps responses in a custom type must re-key in
stream(), otherwise it breaks every caller that builds a wrapper-keyed lookup map (AsyncResponse, user code that mirrors the same pattern, etc.).Suggested fix
Mirror
Symfony\Component\HttpClient\Response\TraceableResponse::stream():(The
ResponseStreamwrapper isSymfony\Component\HttpClient\Response\ResponseStream, same one Symfony uses internally.)This preserves all current span/instrumentation behaviour because the chunks themselves are unchanged; only the key identity is restored.
Why the bug isn't always visible
Two conditions are required to trip it:
->toArray(),->getContent(),->getHeaders()on a stream whereAsyncResponseis involved). A pure->getStatusCode()happens to work because it short-circuits before the stream is consumed.TraceableHttpClientconstructs anAsyncResponse— most commonly Symfony's ownRetryableHttpClient, but alsoEventSourceHttpClient,MockHttpClientin some configurations, or any user decorator that usesAsyncResponseto compose retries / fallbacks.Without
RetryableHttpClient(or equivalent) in front,TracedResponseis the outermost type the user touches and there's no wrapper-keyedSplObjectStorageto break. This is probably why the bug has gone unnoticed.Workarounds for affected users
In rough order of cost:
A. Disable HTTP-client instrumentation — quickest, fully reversible, loses outbound HTTP spans only:
B. Reorder the decorator chain so
TraceableHttpClientis outsideRetryableHttpClientinstead of inside it:AsyncResponsethen never sees aTracedResponse. Side effect: one span per logical request instead of one per retry attempt — usually the desired semantics anyway. Implementation requires a compiler pass to override Traceway's auto-decoration order.C. Patch the vendor file locally via
cweagans/composer-patchesusing the suggested fix above.Environment that surfaced the bug
symfony/http-client7.xtraceway/opentelemetry-symfony1.6.1open-telemetry/sdk1.xDefaultHttpClient→RetryableHttpClient→ TracewayTraceableHttpClient→CurlHttpClient$client->request('GET', '...')->toArray()