diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 6269abaa..f91a02f3 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -15,6 +15,7 @@ + diff --git a/src/LaunchDarkly/EvaluationReason.php b/src/LaunchDarkly/EvaluationReason.php index ac267653..80c2c13e 100644 --- a/src/LaunchDarkly/EvaluationReason.php +++ b/src/LaunchDarkly/EvaluationReason.php @@ -78,7 +78,7 @@ class EvaluationReason implements \JsonSerializable /** * A possible value for getErrorKind(): indicates that an unexpected exception stopped flag - * evaluation. + * evaluation. The exception may be available from getException(). * @var string */ const EXCEPTION_ERROR = 'EXCEPTION'; @@ -97,6 +97,7 @@ class EvaluationReason implements \JsonSerializable private ?string $_prerequisiteKey; private bool $_inExperiment; private ?BigSegmentsEvaluationStatus $_bigSegmentsEvaluationStatus; + private ?\Throwable $_exception = null; /** * Creates a new instance of the OFF reason. @@ -159,6 +160,17 @@ public static function error(string $errorKind): EvaluationReason return new EvaluationReason(self::ERROR, $errorKind); } + /** + * Creates an ERROR reason with error kind EXCEPTION_ERROR, retaining its cause. + * + * @param \Throwable $exception the exception that stopped flag evaluation + * @return EvaluationReason + */ + public static function exception(\Throwable $exception): EvaluationReason + { + return new EvaluationReason(self::ERROR, self::EXCEPTION_ERROR, exception: $exception); + } + private function __construct( string $kind, ?string $errorKind = null, @@ -166,7 +178,8 @@ private function __construct( ?string $ruleId = null, ?string $prerequisiteKey = null, bool $inExperiment = false, - ?BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus = null + ?BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus = null, + ?\Throwable $exception = null ) { $this->_kind = $kind; $this->_errorKind = $errorKind; @@ -175,6 +188,7 @@ private function __construct( $this->_prerequisiteKey = $prerequisiteKey; $this->_inExperiment = $inExperiment; $this->_bigSegmentsEvaluationStatus = $bigSegmentsEvaluationStatus; + $this->_exception = $exception; } /** @@ -194,10 +208,47 @@ public function withBigSegmentsEvaluationStatus(BigSegmentsEvaluationStatus $big $this->_ruleId, $this->_prerequisiteKey, $this->_inExperiment, - $bigSegmentsEvaluationStatus + $bigSegmentsEvaluationStatus, + $this->_exception ); } + /** + * Returns the original exception that caused the evaluation to fail, if it is known. + * + * The value is non-null only when getKind() is ERROR and getErrorKind() is EXCEPTION_ERROR. + * It is null for a reason made with error(EXCEPTION_ERROR), and after unserialize(). The + * exception is for diagnostics only. It is not included in JSON or serialize() output. + * + * @return \Throwable|null + */ + public function getException(): ?\Throwable + { + return $this->_exception; + } + + /** + * Returns the properties that serialize() stores. + * + * The exception is not stored. Its stack trace can contain closures, which serialize() does + * not accept, and objects that hold diagnostic data. After unserialize(), getException() + * returns null. + * + * @return string[] + */ + public function __sleep(): array + { + return [ + '_kind', + '_errorKind', + '_ruleIndex', + '_ruleId', + '_prerequisiteKey', + '_inExperiment', + '_bigSegmentsEvaluationStatus', + ]; + } + /** * Returns a constant indicating the general category of the reason, such as OFF. * @return string diff --git a/src/LaunchDarkly/Impl/Evaluation/Evaluator.php b/src/LaunchDarkly/Impl/Evaluation/Evaluator.php index a518a370..19551ee6 100644 --- a/src/LaunchDarkly/Impl/Evaluation/Evaluator.php +++ b/src/LaunchDarkly/Impl/Evaluation/Evaluator.php @@ -73,7 +73,7 @@ public function evaluate(FeatureFlag $flag, LDContext $context, ?callable $prere return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error($e->getErrorKind())), false, $state); } catch (\Throwable $e) { Util::logExceptionAtErrorLevel($this->_logger, $e, 'Unexpected error when evaluating flag ' . $flag->getKey()); - return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::EXCEPTION_ERROR)), false, $state); + return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::exception($e)), false, $state); } } diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index 906b4313..7b33ae80 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -291,6 +291,8 @@ public function variation(string $key, LDContext $context, mixed $defaultValue = * * The "reason" property in the result will also be included in analytics events, if you are capturing * detailed event data for this flag. + * If evaluation fails with an unexpected exception, the original Throwable is available through + * {@see EvaluationReason::getException()}. It is not included in analytics events or reason JSON. * * @param string $key the unique key for the feature flag * @param LDContext $context the evaluation context @@ -434,7 +436,7 @@ private function evaluateInternal(string $key, LDContext $context, mixed $defaul $flag = $this->_featureRequester->getFeature($key); } catch (UnrecoverableHTTPStatusException $e) { $this->handleUnrecoverableError(); - return ['detail' => $errorDetail(EvaluationReason::EXCEPTION_ERROR), 'flag' => null]; + return ['detail' => new EvaluationDetail($default, null, EvaluationReason::exception($e)), 'flag' => null]; } if (is_null($flag)) { @@ -465,7 +467,7 @@ function (PrerequisiteEvaluationRecord $pe) use ($context, $eventFactory) { return ['detail' => $detail, 'flag' => $flag]; } catch (\Throwable $e) { Util::logExceptionAtErrorLevel($this->_logger, $e, "Unexpected error evaluating flag $key"); - $result = $errorDetail(EvaluationReason::EXCEPTION_ERROR); + $result = new EvaluationDetail($default, null, EvaluationReason::exception($e)); $sendEvent(new EvalResult($result, false), null); return ['detail' => $result, 'flag' => $flag]; } diff --git a/tests/EvaluationReasonTest.php b/tests/EvaluationReasonTest.php index 34fd198b..f10225a0 100644 --- a/tests/EvaluationReasonTest.php +++ b/tests/EvaluationReasonTest.php @@ -2,6 +2,8 @@ namespace LaunchDarkly\Tests; +use LaunchDarkly\BigSegmentsEvaluationStatus; +use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; class EvaluationReasonTest extends \PHPUnit\Framework\TestCase @@ -90,6 +92,95 @@ public function testPrerequisiteFailedReasonSerialization() $this->assertEquals('PREREQUISITE_FAILED(key)', (string)$reason); } + public function testErrorReasonRetainsExceptionWithoutSerializingIt() + { + $exception = new \RuntimeException('sensitive diagnostic details'); + $reason = EvaluationReason::exception($exception); + + $this->assertSame($exception, $reason->getException()); + $this->assertEquals( + ['kind' => 'ERROR', 'errorKind' => 'EXCEPTION'], + json_decode(json_encode($reason), true) + ); + $this->assertEquals('ERROR(EXCEPTION)', (string)$reason); + } + + public function testSerializeOmitsException() + { + $previous = ini_set('zend.exception_ignore_args', '0'); + try { + // The closure argument is kept in the stack trace of the exception. + $exception = (fn (callable $callback) => new \RuntimeException('store unavailable'))(fn () => null); + } finally { + ini_set('zend.exception_ignore_args', $previous); + } + $reason = EvaluationReason::exception($exception) + ->withBigSegmentsEvaluationStatus(BigSegmentsEvaluationStatus::STALE); + + $restored = unserialize(serialize($reason)); + + $this->assertNull($restored->getException()); + $this->assertEquals(EvaluationReason::ERROR, $restored->getKind()); + $this->assertEquals(EvaluationReason::EXCEPTION_ERROR, $restored->getErrorKind()); + $this->assertSame(BigSegmentsEvaluationStatus::STALE, $restored->bigSegmentsEvaluationStatus()); + } + + public function testSerializeDetailOmitsException() + { + $detail = new EvaluationDetail( + 'default', + null, + EvaluationReason::exception(new \RuntimeException('store unavailable')) + ); + + $serialized = serialize($detail); + $restored = unserialize($serialized); + + $this->assertStringNotContainsString('store unavailable', $serialized); + $this->assertEquals('default', $restored->getValue()); + $this->assertNull($restored->getVariationIndex()); + $this->assertEquals(EvaluationReason::EXCEPTION_ERROR, $restored->getReason()->getErrorKind()); + $this->assertNull($restored->getReason()->getException()); + } + + public function testSerializeKeepsReasonFields() + { + $reason = EvaluationReason::ruleMatch(1, 'id', true); + + $this->assertEquals($reason, unserialize(serialize($reason))); + } + + public function testBigSegmentsStatusPreservesException() + { + $exception = new \RuntimeException('store unavailable'); + $reason = EvaluationReason::exception($exception) + ->withBigSegmentsEvaluationStatus(BigSegmentsEvaluationStatus::STALE); + + $this->assertSame($exception, $reason->getException()); + $this->assertSame(BigSegmentsEvaluationStatus::STALE, $reason->bigSegmentsEvaluationStatus()); + $this->assertEquals( + ['kind' => 'ERROR', 'errorKind' => 'EXCEPTION', 'bigSegmentsStatus' => 'STALE'], + json_decode(json_encode($reason), true) + ); + } + + public function testReasonsWithoutExceptionsReturnNull() + { + $reasons = [ + EvaluationReason::off(), + EvaluationReason::fallthrough(), + EvaluationReason::targetMatch(), + EvaluationReason::ruleMatch(0, 'rule'), + EvaluationReason::prerequisiteFailed('prerequisite'), + EvaluationReason::error(EvaluationReason::FLAG_NOT_FOUND_ERROR), + EvaluationReason::error(EvaluationReason::EXCEPTION_ERROR), + ]; + + foreach ($reasons as $reason) { + $this->assertNull($reason->getException()); + } + } + public function testErrorReasonSerialization() { $reason = EvaluationReason::error(EvaluationReason::EXCEPTION_ERROR); diff --git a/tests/Impl/Evaluation/EvaluatorFlagTest.php b/tests/Impl/Evaluation/EvaluatorFlagTest.php index 9b759316..dd58d7c0 100644 --- a/tests/Impl/Evaluation/EvaluatorFlagTest.php +++ b/tests/Impl/Evaluation/EvaluatorFlagTest.php @@ -4,11 +4,14 @@ use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; +use LaunchDarkly\Impl\BigSegments\StoreManager; use LaunchDarkly\Impl\Evaluation\Evaluator; use LaunchDarkly\Impl\Evaluation\EvaluatorBucketing; use LaunchDarkly\Impl\Model\Rollout; use LaunchDarkly\LDContext; +use LaunchDarkly\Tests\MockFeatureRequester; use LaunchDarkly\Tests\ModelBuilders; +use LaunchDarkly\Types\BigSegmentsConfig; use PHPUnit\Framework\TestCase; const RULE_ID = 'ruleid'; @@ -34,6 +37,30 @@ public static function setUpBeforeClass(): void static::$basicEvaluator = EvaluatorTestUtil::basicEvaluator(); } + public function testPrerequisiteLookupExceptionIsRetained(): void + { + $exception = new \RuntimeException('prerequisite store unavailable'); + $requester = $this->createMock(MockFeatureRequester::class); + $requester->method('getFeature')->willThrowException($exception); + $logger = EvaluatorTestUtil::testLogger(); + $evaluator = new Evaluator( + $requester, + new StoreManager(config: new BigSegmentsConfig(store: null), logger: $logger), + $logger + ); + $flag = ModelBuilders::flagBuilder('feature')->on(true)->variations(false, true) + ->fallthroughVariation(0)->prerequisite('prerequisite', 0)->build(); + + $detail = $evaluator->evaluate($flag, LDContext::create('user'), null)->getDetail(); + $reason = $detail->getReason(); + + self::assertNull($detail->getValue()); + self::assertTrue($detail->isDefaultValue()); + self::assertSame(EvaluationReason::ERROR, $reason->getKind()); + self::assertSame(EvaluationReason::EXCEPTION_ERROR, $reason->getErrorKind()); + self::assertSame($exception, $reason->getException()); + } + public function testFlagReturnsOffVariationIfFlagIsOff() { $flag = ModelBuilders::flagBuilder('feature')->variations('fall', 'off', 'on') diff --git a/tests/LDClientTest.php b/tests/LDClientTest.php index 025309e7..391a2f96 100644 --- a/tests/LDClientTest.php +++ b/tests/LDClientTest.php @@ -7,6 +7,7 @@ use LaunchDarkly\EvaluationDetail; use LaunchDarkly\EvaluationReason; use LaunchDarkly\Impl\Model\FeatureFlag; +use LaunchDarkly\Impl\UnrecoverableHTTPStatusException; use LaunchDarkly\LDClient; use LaunchDarkly\LDContext; use LaunchDarkly\Migrations\Operation; @@ -98,6 +99,60 @@ public function testVariationDetailReturnsFlagValue() $this->assertEquals(EvaluationReason::off(), $detail->getReason()); } + public function evaluationExceptions(): array + { + return [ + 'unexpected exception' => [new \RuntimeException('store unavailable')], + 'PHP error' => [new \Error('requester failed')], + 'unauthorized' => [new UnrecoverableHTTPStatusException(401)], + 'forbidden' => [new UnrecoverableHTTPStatusException(403)], + ]; + } + + /** @dataProvider evaluationExceptions */ + public function testVariationDetailRetainsExceptionOnFallback(\Throwable $exception): void + { + $requester = $this->createMock(MockFeatureRequester::class); + $requester->method('getFeature')->willThrowException($exception); + $client = $this->makeClient(['feature_requester' => $requester]); + + $detail = $client->variationDetail('feature', LDContext::create('userkey'), 'default'); + $reason = $detail->getReason(); + + $this->assertSame('default', $detail->getValue()); + $this->assertTrue($detail->isDefaultValue()); + $this->assertSame(EvaluationReason::ERROR, $reason->getKind()); + $this->assertSame(EvaluationReason::EXCEPTION_ERROR, $reason->getErrorKind()); + $this->assertSame($exception, $reason->getException()); + $this->assertEquals( + ['kind' => 'ERROR', 'errorKind' => 'EXCEPTION'], + json_decode(json_encode($reason), true) + ); + } + + public function unrecoverableHTTPStatuses(): array + { + return [[401], [403]]; + } + + /** @dataProvider unrecoverableHTTPStatuses */ + public function testUnrecoverableHTTPErrorStillTakesClientOffline(int $status): void + { + $exception = new UnrecoverableHTTPStatusException($status); + $requester = $this->createMock(MockFeatureRequester::class); + $requester->expects($this->once())->method('getFeature')->willThrowException($exception); + $client = $this->makeClient(['feature_requester' => $requester]); + $context = LDContext::create('userkey'); + + $first = $client->variationDetail('feature', $context, 'first default'); + $this->assertSame($exception, $first->getReason()->getException()); + + $second = $client->variationDetail('feature', $context, 'second default'); + $this->assertSame('second default', $second->getValue()); + $this->assertSame(EvaluationReason::CLIENT_NOT_READY_ERROR, $second->getReason()->getErrorKind()); + $this->assertNull($second->getReason()->getException()); + } + public function testVariationReturnsDefaultIfFlagEvaluatesToNull() { $flag = $this->makeFlagThatEvaluatesToNull('feature');