Skip to content

Handling errors

When something fails, Stewart throws an exception. You can let it go, or catch it where your app can do something better. This app counts doorbell rings, and starts again when the stored count is not a number:

apps/VisitCounter.php
<?php
declare(strict_types=1);
namespace App;
use Psr\Log\LoggerInterface;
use Stewart\Contracts\App;
use Stewart\Contracts\Automation;
use Stewart\Contracts\Exception\StoreError;
use Stewart\Contracts\Exception\StoreException;
use Stewart\Contracts\HaContext;
use Stewart\Contracts\Store\Store;
#[Automation(id: 'visit-counter')]
final readonly class VisitCounter implements App
{
public function __construct(
private HaContext $ha,
private Store $store,
private LoggerInterface $logger,
private string $doorbell = 'binary_sensor.doorbell',
) {}
public function initialize(): void
{
$this->ha->watchStateChanges($this->doorbell)->whenChangedTo('on')->subscribe(function (): void {
try {
$this->store->increment('visits');
} catch (StoreException $e) {
if ($e->reason !== StoreError::NotIncrementable) {
throw $e;
}
$this->logger->warning('The visit count was not a number; starting again', ['exception' => $e]);
$this->store->set('visits', 1);
}
});
}
public function dispose(): void {}
}
  • In a handler, Stewart logs App failed with the exception, counts the failure in make status, and the app keeps running. The next event runs the handler again.
  • In initialize(), the app does not start. Other apps keep running.

So catch an exception only when the app can do something useful, such as trying another way, sending a notification, or skipping one item.

Every exception Stewart throws extends Stewart\Contracts\Exception\StewartException. Each part of Stewart has its own class:

Class Thrown by
ServiceCallException callService(), callServiceForResponse() and the generated service methods
HistoryException getHistory()
EventFireException fireEvent()
StateException requireState() for an entity that does not exist, and invalid operator settings
StoreException Store and ReadableStore
ScheduleException, TimeException The Scheduler, and invalid times or durations
WaitException waitForState() and waitForNext(), when the stream ends or the app stops first
ExposureException, CommandException Exposed entities
RegistryEditException RegistryEditor
IdentifierException An invalid entity ID, or one the typed classes do not know
TopicException, MqttException, TriggerException, SunException Messages between apps, MQTT, triggers, the sun

$e->reason says what exactly failed. It is an enum named after the class: StoreException has StoreError, ServiceCallException has ServiceCallError, and so on. Compare the reason, as the example does, not the message: messages are written for people and may change, reasons do not. The error reference lists every reason.

$e->context holds what the error is about as an array, for example ['domain' => 'light', 'service' => 'turn_on', 'detail' => '…'].

Log the exception under the exception key, as the example does. Stewart then adds its class, reason and context to the log line. With log_format: json, that is the extra.error field:

{
"message": "App failed",
"context": {"app": "hall-light", "exception": "…"},
"extra": {
"error": {
"class": "Stewart\\Contracts\\Exception\\ServiceCallException",
"reason": "rejected",
"context": {"domain": "light", "service": "turn_on", "detail": "Entity not found"}
}
}
}

make status shows each app’s last failure with its reason.

A test sees an exception from a handler directly: the harness throws it out of the pushState(), pushEvent() or advanceTime() that ran the handler. To test a failing service, use stubServiceFailure(); see Calling services.

tests/VisitCounterTest.php
<?php
declare(strict_types=1);
namespace App\Tests;
use App\VisitCounter;
use PHPUnit\Framework\TestCase;
use Stewart\Contracts\App\AppId;
use Stewart\Testing\App\AppHarness;
final class VisitCounterTest extends TestCase
{
public function testACorruptCountStartsAgain(): void
{
$harness = new AppHarness();
$store = $harness->stores->openAppStore(new AppId('visit-counter'));
$store->set('visits', 'many');
$harness->ha->seedState('binary_sensor.doorbell', 'off');
$harness->startApp(VisitCounter::class);
$harness->ha->pushState('binary_sensor.doorbell', 'on');
self::assertSame(1, $store->getInt('visits'));
self::assertCount(1, $harness->logger->listMessagesAt('warning'));
}
}