Skip to content

[Bug]: UnexpectedValueException: Object not found in /app/vendor/symfony/http-client/Response/AsyncResponse.php #34

Description

@ToshY

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:

  1. 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.
  2. 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 DefaultHttpClientRetryableHttpClient → Traceway TraceableHttpClientCurlHttpClient
  • Triggering call: $client->request('GET', '...')->toArray()

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions