Skip to content
Merged
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
1 change: 1 addition & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PossiblyUnusedMethod>
<code><![CDATA[bigSegmentsEvaluationStatus]]></code>
<code><![CDATA[getErrorKind]]></code>
<code><![CDATA[getException]]></code>
<code><![CDATA[getPrerequisiteKey]]></code>
<code><![CDATA[isInExperiment]]></code>
</PossiblyUnusedMethod>
Expand Down
57 changes: 54 additions & 3 deletions src/LaunchDarkly/EvaluationReason.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -159,14 +160,26 @@ 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,
?int $ruleIndex = null,
?string $ruleId = null,
?string $prerequisiteKey = null,
bool $inExperiment = false,
?BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus = null
?BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus = null,
?\Throwable $exception = null
) {
$this->_kind = $kind;
$this->_errorKind = $errorKind;
Expand All @@ -175,6 +188,7 @@ private function __construct(
$this->_prerequisiteKey = $prerequisiteKey;
$this->_inExperiment = $inExperiment;
$this->_bigSegmentsEvaluationStatus = $bigSegmentsEvaluationStatus;
$this->_exception = $exception;
}

/**
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/LaunchDarkly/Impl/Evaluation/Evaluator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
6 changes: 4 additions & 2 deletions src/LaunchDarkly/LDClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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];
}
Expand Down
91 changes: 91 additions & 0 deletions tests/EvaluationReasonTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace LaunchDarkly\Tests;

use LaunchDarkly\BigSegmentsEvaluationStatus;
use LaunchDarkly\EvaluationDetail;
use LaunchDarkly\EvaluationReason;

class EvaluationReasonTest extends \PHPUnit\Framework\TestCase
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions tests/Impl/Evaluation/EvaluatorFlagTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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')
Expand Down
55 changes: 55 additions & 0 deletions tests/LDClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
Loading