From 339a5fd04e4bf1d7b612148c30d7394913237b59 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 9 Apr 2026 16:35:31 +0200 Subject: [PATCH 1/7] feat(agent): add AgentClient --- src/Agent/Transport/AgentClient.php | 101 +++++++++++ tests/HttpClient/AgentClientTest.php | 91 ++++++++++ tests/HttpClient/TestAgent.php | 239 +++++++++++++++++++++++++++ tests/HttpClient/TestServer.php | 6 +- tests/HttpClient/agent-server.php | 82 +++++++++ 5 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 src/Agent/Transport/AgentClient.php create mode 100644 tests/HttpClient/AgentClientTest.php create mode 100644 tests/HttpClient/TestAgent.php create mode 100644 tests/HttpClient/agent-server.php diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php new file mode 100644 index 0000000000..a97047e1cb --- /dev/null +++ b/src/Agent/Transport/AgentClient.php @@ -0,0 +1,101 @@ +host = $host; + $this->port = $port; + } + + public function __destruct() + { + $this->disconnect(); + } + + /** + * @phpstan-assert-if-true resource $this->socket + */ + private function connect(): bool + { + if ($this->socket !== null) { + return true; + } + + // We set the timeout to 10ms to avoid blocking the request for too long if the agent is not running + // @TODO: 10ms should be low enough? Do we want to go lower and/or make this configurable? Only applies to initial connection. + $socket = fsockopen($this->host, $this->port, $errorNo, $errorMsg, 0.01); + + // @TODO: Error handling? See $errorNo and $errorMsg + if ($socket === false) { + return false; + } + + // @TODO: Set a timeout for the socket to prevent blocking (?) if the socket connection stops working after the connection (e.g. the agent is stopped) if needed + $this->socket = $socket; + + return true; + } + + private function disconnect(): void + { + if ($this->socket === null) { + return; + } + + fclose($this->socket); + + $this->socket = null; + } + + private function send(string $message): void + { + if (!$this->connect()) { + return; + } + + // @TODO: Make sure we don't send more than 2^32 - 1 bytes + $contentLength = pack('N', \strlen($message) + 4); + + // @TODO: Error handling? + fwrite($this->socket, $contentLength . $message); + } + + public function sendRequest(Request $request, Options $options): Response + { + $body = $request->getStringBody(); + + if (empty($body)) { + return new Response(400, [], 'Request body is empty'); + } + + $this->send($body); + + // Since we are sending async there is no feedback so we always return an empty response + return new Response(202, [], ''); + } +} diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php new file mode 100644 index 0000000000..fcb87bf9a7 --- /dev/null +++ b/tests/HttpClient/AgentClientTest.php @@ -0,0 +1,91 @@ +agentProcess !== null) { + $this->stopTestAgent(); + } + } + + public function testClientHandsOffEnvelopeToLocalAgent(): void + { + $this->startTestAgent(); + + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from agent client test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $client = new AgentClient('127.0.0.1', $this->agentPort); + $response = $client->sendRequest($request, new \Sentry\Options()); + + $this->waitForEnvelopeCount(1); + $agentOutput = $this->stopTestAgent(); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertCount(1, $agentOutput['messages']); + $this->assertStringContainsString('Hello from agent client test!', $agentOutput['messages'][0]); + $this->assertStringContainsString('"type":"event"', $agentOutput['messages'][0]); + } + + public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from unavailable agent test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $client = new AgentClient('127.0.0.1', 65001); + + set_error_handler(static function (): bool { + return true; + }); + + try { + $response = $client->sendRequest($request, new \Sentry\Options()); + } finally { + restore_error_handler(); + } + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + } + + public function testClientReturnsErrorWhenBodyIsEmpty(): void + { + $client = new AgentClient(); + $response = $client->sendRequest(new Request(), new \Sentry\Options()); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Request body is empty', $response->getError()); + } + + private function createEnvelope(string $dsn, string $message): string + { + $options = new Options(['dsn' => $dsn]); + + $event = Event::createEvent(); + $event->setMessage($message); + + $serializer = new PayloadSerializer($options); + + return $serializer->serialize($event); + } +} diff --git a/tests/HttpClient/TestAgent.php b/tests/HttpClient/TestAgent.php new file mode 100644 index 0000000000..836defe23e --- /dev/null +++ b/tests/HttpClient/TestAgent.php @@ -0,0 +1,239 @@ +startTestAgent()` to start the agent. + * After you are done, call `$this->stopTestAgent()` to stop the agent and get + * the captured envelopes. + */ +trait TestAgent +{ + /** + * @var resource|null the agent process handle + */ + protected $agentProcess; + + /** + * @var resource|null the agent stderr handle + */ + protected $agentStderr; + + /** + * @var string|null the path to the output file + */ + protected $agentOutputFile; + + /** + * @var int the port on which the agent is listening, this default value was randomly chosen + */ + protected $agentPort = 45848; + + /** + * Start the test agent. + * + * @return string the address the agent is listening on + */ + public function startTestAgent(): string + { + if ($this->agentProcess !== null) { + throw new \RuntimeException('There is already a test agent instance running.'); + } + + $outputFile = tempnam(sys_get_temp_dir(), 'sentry-agent-client-output-'); + + if ($outputFile === false) { + throw new \RuntimeException('Failed to create the output file for the test agent.'); + } + + $this->agentOutputFile = $outputFile; + + $pipes = []; + + $this->agentProcess = proc_open( + $command = \sprintf( + 'php %s %d %s', + escapeshellarg((string) realpath(__DIR__ . '/agent-server.php')), + $this->agentPort, + escapeshellarg($this->agentOutputFile) + ), + [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ], + $pipes + ); + + $this->agentStderr = $pipes[2]; + + $pid = proc_get_status($this->agentProcess)['pid']; + + if (!\is_resource($this->agentProcess)) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + $address = "127.0.0.1:{$this->agentPort}"; + + // Wait for the agent to be ready to accept connections + $startTime = microtime(true); + $timeout = 5; // 5 seconds timeout + + while (true) { + $socket = @stream_socket_client("tcp://{$address}", $errno, $errstr, 1); + + if ($socket !== false) { + fclose($socket); + break; + } + + if (microtime(true) - $startTime > $timeout) { + $this->stopTestAgent(); + throw new \RuntimeException("Timeout waiting for test agent to start on {$address}"); + } + + usleep(10000); + } + + // Ensure the process is still running + if (!proc_get_status($this->agentProcess)['running']) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + return $address; + } + + /** + * Wait for the test agent to receive the expected number of envelopes. + * + * @return array{ + * messages: string[], + * connections: int, + * } + */ + public function waitForEnvelopeCount(int $expectedCount, float $timeout = 5.0): array + { + if ($this->agentProcess === null) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $startTime = microtime(true); + + while (true) { + $output = $this->readAgentOutput(); + + if (\count($output['messages']) >= $expectedCount) { + return $output; + } + + if (microtime(true) - $startTime > $timeout) { + throw new \RuntimeException( + \sprintf( + 'Timeout waiting for %d envelope(s), got %d.', + $expectedCount, + \count($output['messages']) + ) + ); + } + + usleep(10000); + } + } + + /** + * Stop the test agent and return the captured envelopes. + * + * @return array{ + * messages: string[], + * connections: int, + * } + */ + public function stopTestAgent(): array + { + if (!$this->agentProcess) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $output = $this->readAgentOutput(); + + for ($i = 0; $i < 20; ++$i) { + $status = proc_get_status($this->agentProcess); + + if (!$status['running']) { + break; + } + + $this->killAgentProcess($status['pid']); + + usleep(10000); + } + + if ($status['running']) { + throw new \RuntimeException('Could not kill test agent'); + } + + proc_close($this->agentProcess); + + if ($this->agentOutputFile !== null && file_exists($this->agentOutputFile)) { + unlink($this->agentOutputFile); + } + + $this->agentProcess = null; + $this->agentStderr = null; + $this->agentOutputFile = null; + + return $output; + } + + /** + * @return array{ + * messages: string[], + * connections: int, + * } + */ + private function readAgentOutput(): array + { + if ($this->agentOutputFile === null || !file_exists($this->agentOutputFile)) { + return ['messages' => [], 'connections' => 0]; + } + + $output = file_get_contents($this->agentOutputFile); + + if ($output === false || $output === '') { + return ['messages' => [], 'connections' => 0]; + } + + $decoded = json_decode($output, true); + + if (!\is_array($decoded)) { + return ['messages' => [], 'connections' => 0]; + } + + return [ + 'messages' => $decoded['messages'] ?? [], + 'connections' => $decoded['connections'] ?? 0, + ]; + } + + private function killAgentProcess(int $pid): void + { + if (\PHP_OS_FAMILY === 'Windows') { + exec("taskkill /pid {$pid} /f /t"); + } else { + // Kills any child processes + exec("pkill -P {$pid}"); + + // Kill the parent process + exec("kill {$pid}"); + } + + proc_terminate($this->agentProcess, 9); + } +} diff --git a/tests/HttpClient/TestServer.php b/tests/HttpClient/TestServer.php index d915187ef2..8b4a1593ac 100644 --- a/tests/HttpClient/TestServer.php +++ b/tests/HttpClient/TestServer.php @@ -34,7 +34,7 @@ trait TestServer /** * @var int the port on which the server is listening, this default value was randomly chosen */ - protected $serverPort = 44884; + protected $serverPort = 45884; public function startTestServer(): string { @@ -50,9 +50,9 @@ public function startTestServer(): string $this->serverProcess = proc_open( $command = \sprintf( - 'php -S localhost:%d -t %s', + 'php -S localhost:%d %s', $this->serverPort, - realpath(__DIR__ . '/../testserver') + realpath(__DIR__ . '/../testserver/index.php') ), [2 => ['pipe', 'w']], $pipes diff --git a/tests/HttpClient/agent-server.php b/tests/HttpClient/agent-server.php new file mode 100644 index 0000000000..6a1c660f06 --- /dev/null +++ b/tests/HttpClient/agent-server.php @@ -0,0 +1,82 @@ + \n"); + + exit(1); +} + +$port = (int) $argv[1]; +$outputFile = $argv[2]; + +$server = @stream_socket_server("tcp://127.0.0.1:{$port}", $errorNo, $errorMessage); + +if ($server === false) { + fwrite(\STDERR, sprintf("Failed to start test agent server: [%d] %s\n", $errorNo, $errorMessage)); + + exit(1); +} + +$messages = []; +$connections = 0; + +$writeOutput = static function () use (&$messages, &$connections, $outputFile): void { + file_put_contents($outputFile, json_encode([ + 'messages' => $messages, + 'connections' => $connections, + ])); +}; + +$writeOutput(); + +while ($connection = @stream_socket_accept($server, -1)) { + ++$connections; + $writeOutput(); + + $buffer = ''; + $messageLength = 0; + + while (!feof($connection)) { + $chunk = fread($connection, 8192); + + if ($chunk === false) { + break; + } + + if ($chunk === '') { + continue; + } + + $buffer .= $chunk; + + while (\strlen($buffer) >= 4) { + if ($messageLength === 0) { + $unpackedHeader = unpack('N', substr($buffer, 0, 4)); + + if ($unpackedHeader === false) { + break 2; + } + + $messageLength = $unpackedHeader[1]; + } + + if (\strlen($buffer) < $messageLength) { + break; + } + + $messages[] = substr($buffer, 4, $messageLength - 4); + $buffer = (string) substr($buffer, $messageLength); + $messageLength = 0; + + $writeOutput(); + } + } + + fclose($connection); +} From 3aab6cdac22c5df7843e6fdfb892eae705c881b3 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 9 Apr 2026 16:39:42 +0200 Subject: [PATCH 2/7] CS --- tests/HttpClient/AgentClientTest.php | 6 +++--- tests/HttpClient/TestAgent.php | 8 +------- tests/HttpClient/agent-server.php | 4 ++-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php index fcb87bf9a7..1887c05446 100644 --- a/tests/HttpClient/AgentClientTest.php +++ b/tests/HttpClient/AgentClientTest.php @@ -32,7 +32,7 @@ public function testClientHandsOffEnvelopeToLocalAgent(): void $request->setStringBody($envelope); $client = new AgentClient('127.0.0.1', $this->agentPort); - $response = $client->sendRequest($request, new \Sentry\Options()); + $response = $client->sendRequest($request, new Options()); $this->waitForEnvelopeCount(1); $agentOutput = $this->stopTestAgent(); @@ -58,7 +58,7 @@ public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void }); try { - $response = $client->sendRequest($request, new \Sentry\Options()); + $response = $client->sendRequest($request, new Options()); } finally { restore_error_handler(); } @@ -70,7 +70,7 @@ public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void public function testClientReturnsErrorWhenBodyIsEmpty(): void { $client = new AgentClient(); - $response = $client->sendRequest(new Request(), new \Sentry\Options()); + $response = $client->sendRequest(new Request(), new Options()); $this->assertSame(400, $response->getStatusCode()); $this->assertTrue($response->hasError()); diff --git a/tests/HttpClient/TestAgent.php b/tests/HttpClient/TestAgent.php index 836defe23e..9e29063c47 100644 --- a/tests/HttpClient/TestAgent.php +++ b/tests/HttpClient/TestAgent.php @@ -134,13 +134,7 @@ public function waitForEnvelopeCount(int $expectedCount, float $timeout = 5.0): } if (microtime(true) - $startTime > $timeout) { - throw new \RuntimeException( - \sprintf( - 'Timeout waiting for %d envelope(s), got %d.', - $expectedCount, - \count($output['messages']) - ) - ); + throw new \RuntimeException(\sprintf('Timeout waiting for %d envelope(s), got %d.', $expectedCount, \count($output['messages']))); } usleep(10000); diff --git a/tests/HttpClient/agent-server.php b/tests/HttpClient/agent-server.php index 6a1c660f06..d81c981b5c 100644 --- a/tests/HttpClient/agent-server.php +++ b/tests/HttpClient/agent-server.php @@ -55,7 +55,7 @@ $buffer .= $chunk; - while (\strlen($buffer) >= 4) { + while (strlen($buffer) >= 4) { if ($messageLength === 0) { $unpackedHeader = unpack('N', substr($buffer, 0, 4)); @@ -66,7 +66,7 @@ $messageLength = $unpackedHeader[1]; } - if (\strlen($buffer) < $messageLength) { + if (strlen($buffer) < $messageLength) { break; } From f4638ad2fc9ef5f3be8abab7fb39492f42f0b294 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 27 Apr 2026 15:06:12 +0200 Subject: [PATCH 3/7] feat(agent): add fallback client to AgentClient --- src/Agent/Transport/AgentClient.php | 163 +++++++++++++-- src/Agent/Transport/AgentClientBuilder.php | 127 ++++++++++++ tests/HttpClient/AgentClientBuilderTest.php | 171 ++++++++++++++++ tests/HttpClient/AgentClientTest.php | 209 ++++++++++++++++++-- 4 files changed, 643 insertions(+), 27 deletions(-) create mode 100644 src/Agent/Transport/AgentClientBuilder.php create mode 100644 tests/HttpClient/AgentClientBuilderTest.php diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php index a97047e1cb..c6cb838fc8 100644 --- a/src/Agent/Transport/AgentClient.php +++ b/src/Agent/Transport/AgentClient.php @@ -26,10 +26,40 @@ class AgentClient implements HttpClientInterface */ private $socket; - public function __construct(string $host = '127.0.0.1', int $port = 5148) + /** + * @var HttpClientInterface|null + */ + private $fallbackClient; + + /** + * @var (callable(): HttpClientInterface)|null + */ + private $fallbackClientFactory; + + /** + * @var string|null + */ + private $fallbackClientError; + + /** + * @var string + */ + private $lastSendError = ''; + + /** + * @param callable|null $fallbackClientFactory A factory that returns an HttpClientInterface to use + * when the local agent is unavailable. + * Pass null to disable fallback delivery; agent handoff + * failures then return a 502 response. + * Use AgentClientBuilder for default fallback behavior. + * + * @phpstan-param (callable(): HttpClientInterface)|null $fallbackClientFactory + */ + public function __construct(string $host = '127.0.0.1', int $port = 5148, ?callable $fallbackClientFactory = null) { $this->host = $host; $this->port = $port; + $this->fallbackClientFactory = $fallbackClientFactory; } public function __destruct() @@ -46,16 +76,26 @@ private function connect(): bool return true; } - // We set the timeout to 10ms to avoid blocking the request for too long if the agent is not running - // @TODO: 10ms should be low enough? Do we want to go lower and/or make this configurable? Only applies to initial connection. - $socket = fsockopen($this->host, $this->port, $errorNo, $errorMsg, 0.01); + // 10ms connect timeout to avoid blocking the request if the agent is not running + $errorNo = 0; + $errorMsg = ''; + $socket = @fsockopen($this->host, $this->port, $errorNo, $errorMsg, 0.01); - // @TODO: Error handling? See $errorNo and $errorMsg if ($socket === false) { + $this->lastSendError = \sprintf( + 'Failed to connect to the local Sentry agent at %s:%d. [%d] %s', + $this->host, + $this->port, + $errorNo, + $errorMsg + ); + return false; } - // @TODO: Set a timeout for the socket to prevent blocking (?) if the socket connection stops working after the connection (e.g. the agent is stopped) if needed + // Cap read/write timeout to 10ms so a hung agent does not block the caller indefinitely + stream_set_timeout($socket, 0, 10000); + $this->socket = $socket; return true; @@ -72,17 +112,84 @@ private function disconnect(): void $this->socket = null; } - private function send(string $message): void + private function send(string $message): bool { - if (!$this->connect()) { - return; + $this->lastSendError = ''; + + // Length prefix is a 32-bit unsigned int (4 GB limit). Relay already enforces + // a much smaller envelope size limit (~1 MB), so this is not a practical concern. + $payload = pack('N', \strlen($message) + 4) . $message; + $payloadLength = \strlen($payload); + + // Attempt to send the payload, retrying once on write failure to handle + // stale sockets (e.g. agent restarts in long-running workers). + for ($attempt = 0; $attempt < 2; ++$attempt) { + if (!$this->connect()) { + return false; + } + + $totalWrittenBytes = 0; + $writeFailed = false; + + while ($totalWrittenBytes < $payloadLength) { + $bytesWritten = @fwrite($this->socket, (string) substr($payload, $totalWrittenBytes)); + + if ($bytesWritten === false || $bytesWritten === 0) { + $writeFailed = true; + break; + } + + $totalWrittenBytes += $bytesWritten; + } + + if (!$writeFailed) { + return true; + } + + $this->disconnect(); } - // @TODO: Make sure we don't send more than 2^32 - 1 bytes - $contentLength = pack('N', \strlen($message) + 4); + $this->lastSendError = \sprintf( + 'Failed to write envelope to the local Sentry agent at %s:%d.', + $this->host, + $this->port + ); - // @TODO: Error handling? - fwrite($this->socket, $contentLength . $message); + return false; + } + + private function getFallbackClient(): ?HttpClientInterface + { + if ($this->fallbackClient !== null) { + return $this->fallbackClient; + } + + if ($this->fallbackClientFactory === null) { + return null; + } + + try { + $fallbackClient = ($this->fallbackClientFactory)(); + } catch (\Throwable $exception) { + $this->fallbackClientFactory = null; + $this->fallbackClientError = \sprintf( + 'Failed to initialize fallback HTTP client. Reason: "%s". Fallback delivery has been disabled.', + $exception->getMessage() + ); + + return null; + } + + if (!$fallbackClient instanceof HttpClientInterface) { + $this->fallbackClientFactory = null; + $this->fallbackClientError = 'The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.'; + + return null; + } + + $this->fallbackClient = $fallbackClient; + + return $this->fallbackClient; } public function sendRequest(Request $request, Options $options): Response @@ -93,9 +200,33 @@ public function sendRequest(Request $request, Options $options): Response return new Response(400, [], 'Request body is empty'); } - $this->send($body); + if ($this->send($body)) { + // Since we are sending async there is no feedback so we always return an empty response + return new Response(202, [], ''); + } + + $logContext = [ + 'agent_host' => $this->host, + 'agent_port' => $this->port, + ]; + + if ($this->lastSendError !== '') { + $logContext['error'] = $this->lastSendError; + } + + $options->getLoggerOrNullLogger()->debug('Failed to hand off envelope to local Sentry agent.', $logContext); + + $fallbackClient = $this->getFallbackClient(); + if ($fallbackClient !== null) { + $options->getLoggerOrNullLogger()->debug('Using fallback HTTP client because local Sentry agent handoff failed.', $logContext); + + return $fallbackClient->sendRequest($request, $options); + } + + if ($this->fallbackClientError !== null) { + $options->getLoggerOrNullLogger()->debug($this->fallbackClientError, $logContext); + } - // Since we are sending async there is no feedback so we always return an empty response - return new Response(202, [], ''); + return new Response(502, [], 'Failed to send envelope to the local Sentry agent and no fallback client is available.'); } } diff --git a/src/Agent/Transport/AgentClientBuilder.php b/src/Agent/Transport/AgentClientBuilder.php new file mode 100644 index 0000000000..dcdd6bb2ae --- /dev/null +++ b/src/Agent/Transport/AgentClientBuilder.php @@ -0,0 +1,127 @@ +host = $host; + + return $this; + } + + public function setPort(int $port): self + { + $this->port = $port; + + return $this; + } + + public function disableFallbackClient(): self + { + $this->isFallbackClientDisabled = true; + $this->fallbackClientFactory = null; + + return $this; + } + + public function setFallbackClient(HttpClientInterface $fallbackClient): self + { + return $this->setFallbackClientFactory(static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + } + + /** + * @phpstan-param callable(): HttpClientInterface $fallbackClientFactory + */ + public function setFallbackClientFactory(callable $fallbackClientFactory): self + { + $this->isFallbackClientDisabled = false; + $this->fallbackClientFactory = $fallbackClientFactory; + + return $this; + } + + public function setSdkIdentifier(string $sdkIdentifier): self + { + $this->sdkIdentifier = $sdkIdentifier; + + return $this; + } + + public function setSdkVersion(string $sdkVersion): self + { + $this->sdkVersion = $sdkVersion; + + return $this; + } + + public function getClient(): AgentClient + { + if ($this->isFallbackClientDisabled) { + return new AgentClient($this->host, $this->port, null); + } + + if ($this->fallbackClientFactory !== null) { + return new AgentClient($this->host, $this->port, $this->fallbackClientFactory); + } + + return new AgentClient($this->host, $this->port, $this->createDefaultFallbackClientFactory()); + } + + /** + * @return callable(): HttpClientInterface + */ + private function createDefaultFallbackClientFactory(): callable + { + $sdkIdentifier = $this->sdkIdentifier; + $sdkVersion = $this->sdkVersion; + + return static function () use ($sdkIdentifier, $sdkVersion): HttpClientInterface { + return new HttpClient($sdkIdentifier, $sdkVersion); + }; + } +} diff --git a/tests/HttpClient/AgentClientBuilderTest.php b/tests/HttpClient/AgentClientBuilderTest.php new file mode 100644 index 0000000000..ffcd72f0ec --- /dev/null +++ b/tests/HttpClient/AgentClientBuilderTest.php @@ -0,0 +1,171 @@ +serverProcess !== null) { + $this->stopTestServer(); + } + + StubLogger::$logs = []; + } + + public function testBuilderUsesFallbackClientByDefaultWhenLocalAgentIsUnavailable(): void + { + $testServer = $this->startTestServer(); + $dsn = "http://publicKey@{$testServer}/200"; + + $envelope = $this->createEnvelope($dsn, 'Hello from builder default fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => $dsn]); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->getClient(); + $response = $client->sendRequest($request, $options); + + $serverOutput = $this->stopTestServer(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertStringContainsString('Hello from builder default fallback test!', $serverOutput['body']); + } + + public function testBuilderCanDisableFallbackClient(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from disabled fallback builder test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->disableFallbackClient() + ->getClient(); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope to the local Sentry agent and no fallback client is available.', $response->getError()); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + $this->assertFalse($this->hasLogMessage('Using fallback HTTP client because local Sentry agent handoff failed.')); + } + + public function testBuilderUsesCustomFallbackClientWhenConfigured(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from custom fallback builder test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => 'http://public@example.com/1']); + $fallbackResponse = new Response(201, [], ''); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->setFallbackClient($fallbackClient) + ->getClient(); + $response = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $response); + } + + public function testBuilderCreatesDefaultFallbackClientWithConfiguredSdkIdentifierAndVersion(): void + { + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->setSdkIdentifier('sentry.test') + ->setSdkVersion('1.2.3-test') + ->getClient(); + + $fallbackClientFactoryProperty = new \ReflectionProperty($client, 'fallbackClientFactory'); + if (\PHP_VERSION_ID < 80100) { + $fallbackClientFactoryProperty->setAccessible(true); + } + + /** @var mixed $fallbackClientFactory */ + $fallbackClientFactory = $fallbackClientFactoryProperty->getValue($client); + + $this->assertIsCallable($fallbackClientFactory); + $fallbackClient = $fallbackClientFactory(); + $this->assertInstanceOf(HttpClient::class, $fallbackClient); + + $sdkIdentifierProperty = new \ReflectionProperty($fallbackClient, 'sdkIdentifier'); + $sdkVersionProperty = new \ReflectionProperty($fallbackClient, 'sdkVersion'); + if (\PHP_VERSION_ID < 80100) { + $sdkIdentifierProperty->setAccessible(true); + $sdkVersionProperty->setAccessible(true); + } + + $this->assertSame('sentry.test', $sdkIdentifierProperty->getValue($fallbackClient)); + $this->assertSame('1.2.3-test', $sdkVersionProperty->getValue($fallbackClient)); + } + + private function createEnvelope(string $dsn, string $message): string + { + $options = new Options(['dsn' => $dsn]); + + $event = Event::createEvent(); + $event->setMessage($message); + + $serializer = new PayloadSerializer($options); + + return $serializer->serialize($event); + } + + private function hasLogMessage(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if ($log['message'] === $message) { + return true; + } + } + + return false; + } +} diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php index 1887c05446..9e91d457a8 100644 --- a/tests/HttpClient/AgentClientTest.php +++ b/tests/HttpClient/AgentClientTest.php @@ -7,19 +7,35 @@ use PHPUnit\Framework\TestCase; use Sentry\Agent\Transport\AgentClient; use Sentry\Event; +use Sentry\HttpClient\HttpClientInterface; use Sentry\HttpClient\Request; +use Sentry\HttpClient\Response; use Sentry\Options; use Sentry\Serializer\PayloadSerializer; +use Sentry\Tests\StubLogger; final class AgentClientTest extends TestCase { use TestAgent; + // Reserved address TEST-NET-1, which should not be bound for anything + private const UNAVAILABLE_AGENT_HOST = '192.0.2.1'; + private const UNAVAILABLE_AGENT_PORT = 5148; + + protected function setUp(): void + { + parent::setUp(); + + StubLogger::$logs = []; + } + protected function tearDown(): void { if ($this->agentProcess !== null) { $this->stopTestAgent(); } + + StubLogger::$logs = []; } public function testClientHandsOffEnvelopeToLocalAgent(): void @@ -31,7 +47,14 @@ public function testClientHandsOffEnvelopeToLocalAgent(): void $request = new Request(); $request->setStringBody($envelope); - $client = new AgentClient('127.0.0.1', $this->agentPort); + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->never()) + ->method('sendRequest'); + + $client = new AgentClient('127.0.0.1', $this->agentPort, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); $response = $client->sendRequest($request, new Options()); $this->waitForEnvelopeCount(1); @@ -44,27 +67,121 @@ public function testClientHandsOffEnvelopeToLocalAgent(): void $this->assertStringContainsString('"type":"event"', $agentOutput['messages'][0]); } - public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void + public function testClientReturnsErrorAndLogsDebugWhenLocalAgentIsUnavailableWithoutFallback(): void { $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from unavailable agent test!'); $request = new Request(); $request->setStringBody($envelope); - $client = new AgentClient('127.0.0.1', 65001); + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, null); + $response = $client->sendRequest($request, $options); - set_error_handler(static function (): bool { - return true; - }); + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope to the local Sentry agent and no fallback client is available.', $response->getError()); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + } - try { - $response = $client->sendRequest($request, new Options()); - } finally { - restore_error_handler(); - } + public function testClientLazilyInitializesFallbackFactoryOnlyWhenNeeded(): void + { + $this->startTestAgent(); + + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from lazy fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $factoryCallCount = 0; + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->never()) + ->method('sendRequest'); + + $client = new AgentClient( + '127.0.0.1', + $this->agentPort, + static function () use (&$factoryCallCount, $fallbackClient): HttpClientInterface { + ++$factoryCallCount; + + return $fallbackClient; + } + ); + $response = $client->sendRequest($request, new Options()); + + $this->waitForEnvelopeCount(1); + $this->stopTestAgent(); $this->assertSame(202, $response->getStatusCode()); $this->assertSame('', $response->getError()); + $this->assertSame(0, $factoryCallCount); + } + + public function testClientUsesFallbackClientWhenLocalAgentIsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options([ + 'dsn' => 'http://public@example.com/1', + 'logger' => $logger, + ]); + + $fallbackResponse = new Response(200, [], ''); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $response); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + $this->assertTrue($this->hasLogMessage('Using fallback HTTP client because local Sentry agent handoff failed.')); + } + + public function testClientReusesFallbackClientWhenLocalAgentRemainsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from cached fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => 'http://public@example.com/1']); + $fallbackResponse = new Response(200, [], ''); + $factoryCallCount = 0; + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->exactly(2)) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use (&$factoryCallCount, $fallbackClient): HttpClientInterface { + ++$factoryCallCount; + + return $fallbackClient; + }); + + $firstResponse = $client->sendRequest($request, $options); + $secondResponse = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $firstResponse); + $this->assertSame($fallbackResponse, $secondResponse); + $this->assertSame(1, $factoryCallCount); } public function testClientReturnsErrorWhenBodyIsEmpty(): void @@ -77,6 +194,54 @@ public function testClientReturnsErrorWhenBodyIsEmpty(): void $this->assertSame('Request body is empty', $response->getError()); } + public function testClientDoesNotThrowWhenFallbackFactoryThrows(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from throwing fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function (): HttpClientInterface { + throw new \RuntimeException('factory boom'); + } + ); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertTrue($this->hasLogMessageContaining('Failed to initialize fallback HTTP client.')); + } + + public function testClientDoesNotThrowWhenFallbackFactoryReturnsUnexpectedValue(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from invalid fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function () { + return new \stdClass(); + } + ); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertTrue($this->hasLogMessage('The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.')); + } + private function createEnvelope(string $dsn, string $message): string { $options = new Options(['dsn' => $dsn]); @@ -88,4 +253,26 @@ private function createEnvelope(string $dsn, string $message): string return $serializer->serialize($event); } + + private function hasLogMessage(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if ($log['message'] === $message) { + return true; + } + } + + return false; + } + + private function hasLogMessageContaining(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if (strpos($log['message'], $message) !== false) { + return true; + } + } + + return false; + } } From 696c87aa72b74c3cf247614fad565bb4df938c13 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 27 Apr 2026 15:27:37 +0200 Subject: [PATCH 4/7] docs --- src/Agent/Transport/AgentClient.php | 42 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php index c6cb838fc8..0a8b133e65 100644 --- a/src/Agent/Transport/AgentClient.php +++ b/src/Agent/Transport/AgentClient.php @@ -116,10 +116,7 @@ private function send(string $message): bool { $this->lastSendError = ''; - // Length prefix is a 32-bit unsigned int (4 GB limit). Relay already enforces - // a much smaller envelope size limit (~1 MB), so this is not a practical concern. $payload = pack('N', \strlen($message) + 4) . $message; - $payloadLength = \strlen($payload); // Attempt to send the payload, retrying once on write failure to handle // stale sockets (e.g. agent restarts in long-running workers). @@ -128,21 +125,7 @@ private function send(string $message): bool return false; } - $totalWrittenBytes = 0; - $writeFailed = false; - - while ($totalWrittenBytes < $payloadLength) { - $bytesWritten = @fwrite($this->socket, (string) substr($payload, $totalWrittenBytes)); - - if ($bytesWritten === false || $bytesWritten === 0) { - $writeFailed = true; - break; - } - - $totalWrittenBytes += $bytesWritten; - } - - if (!$writeFailed) { + if ($this->writePayload($payload)) { return true; } @@ -158,6 +141,29 @@ private function send(string $message): bool return false; } + private function writePayload(string $payload): bool + { + if ($this->socket === null) { + return false; + } + + $socket = $this->socket; + $payloadLength = \strlen($payload); + $totalWrittenBytes = 0; + + while ($totalWrittenBytes < $payloadLength) { + $bytesWritten = @fwrite($socket, (string) substr($payload, $totalWrittenBytes)); + + if ($bytesWritten === false || $bytesWritten === 0) { + return false; + } + + $totalWrittenBytes += $bytesWritten; + } + + return true; + } + private function getFallbackClient(): ?HttpClientInterface { if ($this->fallbackClient !== null) { From 89eba8994ee5c60eaca1d36d66039811b5c6e101 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 27 Apr 2026 15:37:16 +0200 Subject: [PATCH 5/7] docs --- src/Agent/Transport/AgentClient.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php index 0a8b133e65..33f5dd13e6 100644 --- a/src/Agent/Transport/AgentClient.php +++ b/src/Agent/Transport/AgentClient.php @@ -47,12 +47,6 @@ class AgentClient implements HttpClientInterface private $lastSendError = ''; /** - * @param callable|null $fallbackClientFactory A factory that returns an HttpClientInterface to use - * when the local agent is unavailable. - * Pass null to disable fallback delivery; agent handoff - * failures then return a 502 response. - * Use AgentClientBuilder for default fallback behavior. - * * @phpstan-param (callable(): HttpClientInterface)|null $fallbackClientFactory */ public function __construct(string $host = '127.0.0.1', int $port = 5148, ?callable $fallbackClientFactory = null) From 3438cd6e25c6001f9caff322f958d874caa8d6ed Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 27 Apr 2026 16:46:24 +0200 Subject: [PATCH 6/7] make socket non blocking with stream_select --- src/Agent/Transport/AgentClient.php | 52 +++++++++++++++++++++++++--- tests/HttpClient/AgentClientTest.php | 28 +++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php index 33f5dd13e6..94af6615f8 100644 --- a/src/Agent/Transport/AgentClient.php +++ b/src/Agent/Transport/AgentClient.php @@ -11,6 +11,8 @@ class AgentClient implements HttpClientInterface { + private const SOCKET_TIMEOUT_SECONDS = 0.01; + /** * @var string */ @@ -73,7 +75,7 @@ private function connect(): bool // 10ms connect timeout to avoid blocking the request if the agent is not running $errorNo = 0; $errorMsg = ''; - $socket = @fsockopen($this->host, $this->port, $errorNo, $errorMsg, 0.01); + $socket = @fsockopen($this->host, $this->port, $errorNo, $errorMsg, self::SOCKET_TIMEOUT_SECONDS); if ($socket === false) { $this->lastSendError = \sprintf( @@ -87,8 +89,8 @@ private function connect(): bool return false; } - // Cap read/write timeout to 10ms so a hung agent does not block the caller indefinitely - stream_set_timeout($socket, 0, 10000); + // Use non-blocking writes with stream_select() so a hung agent cannot block the caller indefinitely. + stream_set_blocking($socket, false); $this->socket = $socket; @@ -144,8 +146,13 @@ private function writePayload(string $payload): bool $socket = $this->socket; $payloadLength = \strlen($payload); $totalWrittenBytes = 0; + $writeDeadline = microtime(true) + self::SOCKET_TIMEOUT_SECONDS; while ($totalWrittenBytes < $payloadLength) { + if (!$this->waitUntilSocketIsWritable($socket, $writeDeadline)) { + return false; + } + $bytesWritten = @fwrite($socket, (string) substr($payload, $totalWrittenBytes)); if ($bytesWritten === false || $bytesWritten === 0) { @@ -158,6 +165,31 @@ private function writePayload(string $payload): bool return true; } + /** + * @param resource $socket + */ + private function waitUntilSocketIsWritable($socket, float $deadline): bool + { + $remainingSeconds = $deadline - microtime(true); + + if ($remainingSeconds <= 0) { + return false; + } + + $readSockets = null; + $writeSockets = [$socket]; + $exceptSockets = null; + $selectedSockets = @stream_select( + $readSockets, + $writeSockets, + $exceptSockets, + 0, + (int) ceil($remainingSeconds * 1000000) + ); + + return $selectedSockets !== false && $selectedSockets > 0; + } + private function getFallbackClient(): ?HttpClientInterface { if ($this->fallbackClient !== null) { @@ -220,7 +252,19 @@ public function sendRequest(Request $request, Options $options): Response if ($fallbackClient !== null) { $options->getLoggerOrNullLogger()->debug('Using fallback HTTP client because local Sentry agent handoff failed.', $logContext); - return $fallbackClient->sendRequest($request, $options); + try { + return $fallbackClient->sendRequest($request, $options); + } catch (\Throwable $exception) { + $options->getLoggerOrNullLogger()->debug( + 'Fallback HTTP client failed while sending envelope.', + array_merge($logContext, ['exception' => $exception]) + ); + + return new Response(502, [], \sprintf( + 'Failed to send envelope using fallback HTTP client. Reason: "%s".', + $exception->getMessage() + )); + } } if ($this->fallbackClientError !== null) { diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php index 9e91d457a8..c5c646e4a8 100644 --- a/tests/HttpClient/AgentClientTest.php +++ b/tests/HttpClient/AgentClientTest.php @@ -184,6 +184,34 @@ public function testClientReusesFallbackClientWhenLocalAgentRemainsUnavailable() $this->assertSame(1, $factoryCallCount); } + public function testClientDoesNotThrowWhenFallbackClientThrows(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from throwing fallback client test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willThrowException(new \RuntimeException('fallback boom')); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope using fallback HTTP client. Reason: "fallback boom".', $response->getError()); + $this->assertTrue($this->hasLogMessage('Fallback HTTP client failed while sending envelope.')); + } + public function testClientReturnsErrorWhenBodyIsEmpty(): void { $client = new AgentClient(); From 7f241e0539924baa24ae7598fb82f05756b3ac9c Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 27 Apr 2026 17:48:10 +0200 Subject: [PATCH 7/7] fix --- src/Agent/Transport/AgentClient.php | 3 +- tests/HttpClient/AgentClientTest.php | 41 +++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php index 94af6615f8..fdb17aba9e 100644 --- a/src/Agent/Transport/AgentClient.php +++ b/src/Agent/Transport/AgentClient.php @@ -155,7 +155,7 @@ private function writePayload(string $payload): bool $bytesWritten = @fwrite($socket, (string) substr($payload, $totalWrittenBytes)); - if ($bytesWritten === false || $bytesWritten === 0) { + if ($bytesWritten === false) { return false; } @@ -269,6 +269,7 @@ public function sendRequest(Request $request, Options $options): Response if ($this->fallbackClientError !== null) { $options->getLoggerOrNullLogger()->debug($this->fallbackClientError, $logContext); + $this->fallbackClientError = null; } return new Response(502, [], 'Failed to send envelope to the local Sentry agent and no fallback client is available.'); diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php index c5c646e4a8..4d1db39fc8 100644 --- a/tests/HttpClient/AgentClientTest.php +++ b/tests/HttpClient/AgentClientTest.php @@ -246,6 +246,30 @@ static function (): HttpClientInterface { $this->assertTrue($this->hasLogMessageContaining('Failed to initialize fallback HTTP client.')); } + public function testClientLogsFallbackFactoryErrorOnlyOnce(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from repeated throwing fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function (): HttpClientInterface { + throw new \RuntimeException('factory boom'); + } + ); + + $client->sendRequest($request, $options); + $client->sendRequest($request, $options); + + $this->assertSame(1, $this->countLogMessagesContaining('Failed to initialize fallback HTTP client.')); + } + public function testClientDoesNotThrowWhenFallbackFactoryReturnsUnexpectedValue(): void { $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from invalid fallback factory test!'); @@ -293,14 +317,17 @@ private function hasLogMessage(string $message): bool return false; } - private function hasLogMessageContaining(string $message): bool + private function countLogMessagesContaining(string $message): int { - foreach (StubLogger::$logs as $log) { - if (strpos($log['message'], $message) !== false) { - return true; - } - } + $result = array_filter(StubLogger::$logs, static function (array $log) use ($message): bool { + return strpos($log['message'], $message) !== false; + }); - return false; + return \count($result); + } + + private function hasLogMessageContaining(string $message): bool + { + return $this->countLogMessagesContaining($message) > 0; } }