Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions src/Micro/Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{handler: \Closure|HandlerInterface, config: EndpointConfig, sub: Subscription, stats: array{requests: int, errors: int, processing_time: float, last_error: ?string}}> */
private array $endpoints = [];
Expand Down Expand Up @@ -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);
}
}
};
Expand Down Expand Up @@ -244,6 +241,11 @@ public function reset(): void
}
}

public function lastError(): ?\Throwable
{
return $this->lastError;
}

public function stop(): void
{
if ($this->stopped) {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/Micro/ServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public function info(): ServiceInfo;

public function stats(): ServiceStats;

public function lastError(): ?\Throwable;

public function reset(): void;

public function stop(): void;
Expand Down
93 changes: 93 additions & 0 deletions tests/Integration/Micro/ServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down