From 91ab5c7101ea6550f362bd44c7818944acc44259 Mon Sep 17 00:00:00 2001 From: "matkomat-agent-2[bot]" Date: Tue, 31 Mar 2026 11:06:47 +0200 Subject: [PATCH] Add micro service lastError tracking --- src/Micro/Service.php | 32 +++++++-- src/Micro/ServiceInterface.php | 2 + tests/Integration/Micro/ServiceTest.php | 93 +++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/src/Micro/Service.php b/src/Micro/Service.php index ffaa03d..f8506a9 100644 --- a/src/Micro/Service.php +++ b/src/Micro/Service.php @@ -36,6 +36,7 @@ public static function controlSubject(ServiceVerb $verb, string $name = '', stri private readonly string $id; private readonly \DateTimeImmutable $started; private bool $stopped = false; + private ?\Throwable $lastError = null; /** @var array */ private array $endpoints = []; @@ -105,16 +106,12 @@ public function addEndpoint( $stats['last_error'] = $e->getMessage(); $this->endpoints[$name]['stats'] = $stats; - $errorHandler = $this->config->errorHandler; - if ($errorHandler !== null) { - $errorHandler($this, $e); - } + $this->recordError($e); - // Send error response if not already responded try { $request->error('500', $e->getMessage()); - } catch (\Throwable) { - // Ignore - response may already be sent + } catch (\Throwable $sendFailure) { + $this->recordError($sendFailure); } } }; @@ -244,6 +241,11 @@ public function reset(): void } } + public function lastError(): ?\Throwable + { + return $this->lastError; + } + public function stop(): void { if ($this->stopped) { @@ -288,6 +290,22 @@ public function version(): string return $this->config->version; } + private function recordError(\Throwable $e): void + { + $this->lastError = $e; + + $errorHandler = $this->config->errorHandler; + if ($errorHandler === null) { + return; + } + + try { + $errorHandler($this, $e); + } catch (\Throwable) { + // User error handlers must not break service runtime error tracking. + } + } + // --- Internal: control subjects --- private function setupControlSubjects(): void diff --git a/src/Micro/ServiceInterface.php b/src/Micro/ServiceInterface.php index 7540dd0..ed2ab81 100644 --- a/src/Micro/ServiceInterface.php +++ b/src/Micro/ServiceInterface.php @@ -18,6 +18,8 @@ public function info(): ServiceInfo; public function stats(): ServiceStats; + public function lastError(): ?\Throwable; + public function reset(): void; public function stop(): void; diff --git a/tests/Integration/Micro/ServiceTest.php b/tests/Integration/Micro/ServiceTest.php index c1aa8f6..2b5aaaf 100644 --- a/tests/Integration/Micro/ServiceTest.php +++ b/tests/Integration/Micro/ServiceTest.php @@ -8,6 +8,7 @@ use Sofascore\Nats\Micro\EndpointConfig; use Sofascore\Nats\Micro\HandlerInterface; use Sofascore\Nats\Micro\RequestInterface; +use Sofascore\Nats\NatsException; use Sofascore\Nats\Micro\Service; use Sofascore\Nats\Micro\ServiceConfig; use Sofascore\Nats\Tests\Integration\NatsUrlTrait; @@ -109,6 +110,98 @@ public function testErrorResponse(): void $svc->stop(); } + public function testHandlerFailureIsExposedThroughLastErrorAndStats(): void + { + $subject = 'svc.test.handler-failure.' . substr(uniqid(), -6); + $service = Service::create($this->conn, new ServiceConfig( + name: 'handler-failure-' . substr(uniqid(), -6), + version: '1.0.0', + )); + + $service->addEndpoint('fail', function (RequestInterface $req): void { + throw new \RuntimeException('handler boom'); + }, new EndpointConfig(subject: $subject)); + + $reply = $this->conn->request($subject, '', 2.0); + + self::assertTrue($reply->hasHeaders()); + self::assertSame('handler boom', $reply->headers?->get('Nats-Service-Error')); + self::assertSame('500', $reply->headers?->get('Nats-Service-Error-Code')); + + $stats = $service->stats(); + self::assertSame(1, $stats->endpoints[0]->numRequests); + self::assertSame(1, $stats->endpoints[0]->numErrors); + self::assertSame('handler boom', $stats->endpoints[0]->lastError); + + $lastError = $service->lastError(); + self::assertInstanceOf(\RuntimeException::class, $lastError); + self::assertSame('handler boom', $lastError?->getMessage()); + + $service->stop(); + } + + public function testThrowingErrorHandlerDoesNotBreakErrorFlow(): void + { + $subject = 'svc.test.throwing-error-handler.' . substr(uniqid(), -6); + $errorHandlerCalls = 0; + $service = Service::create($this->conn, new ServiceConfig( + name: 'throwing-error-handler-' . substr(uniqid(), -6), + version: '1.0.0', + errorHandler: function (Service $service, \Throwable $e) use (&$errorHandlerCalls): void { + $errorHandlerCalls++; + throw new \RuntimeException('error handler boom'); + }, + )); + + $service->addEndpoint('fail', function (RequestInterface $req): void { + throw new \LogicException('primary handler failure'); + }, new EndpointConfig(subject: $subject)); + + $reply = $this->conn->request($subject, '', 2.0); + + self::assertTrue($reply->hasHeaders()); + self::assertSame('primary handler failure', $reply->headers?->get('Nats-Service-Error')); + self::assertSame('500', $reply->headers?->get('Nats-Service-Error-Code')); + self::assertSame(1, $errorHandlerCalls); + + $stats = $service->stats(); + self::assertSame(1, $stats->endpoints[0]->numErrors); + self::assertSame('primary handler failure', $stats->endpoints[0]->lastError); + + $lastError = $service->lastError(); + self::assertInstanceOf(\LogicException::class, $lastError); + self::assertSame('primary handler failure', $lastError?->getMessage()); + + $service->stop(); + } + + public function testResponseSendFailureIsObservable(): void + { + $subject = 'svc.test.response-send-failure.' . substr(uniqid(), -6); + $service = Service::create($this->conn, new ServiceConfig( + name: 'response-send-failure-' . substr(uniqid(), -6), + version: '1.0.0', + )); + + $service->addEndpoint('fail', function (RequestInterface $req): void { + throw new \RuntimeException('handler boom'); + }, new EndpointConfig(subject: $subject)); + + $this->conn->publish($subject, ''); + $this->conn->flush(); + + $stats = $service->stats(); + self::assertSame(1, $stats->endpoints[0]->numRequests); + self::assertSame(1, $stats->endpoints[0]->numErrors); + self::assertSame('handler boom', $stats->endpoints[0]->lastError); + + $lastError = $service->lastError(); + self::assertInstanceOf(NatsException::class, $lastError); + self::assertSame('No reply subject', $lastError?->getMessage()); + + $service->stop(); + } + public function testServiceDiscoveryPing(): void { $svc = Service::create($this->conn, new ServiceConfig(