From 2eb7a0ec2d268ab5d641829d2f78f1733e9b5a14 Mon Sep 17 00:00:00 2001
From: hidingmyname <140602238+hidingmyname@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:43:40 +0800
Subject: [PATCH 1/4] feat: expose exceptions on evaluation reasons
Retain the original Throwable in evaluator and client failures, including unrecoverable HTTP 401/403 responses, without changing reason JSON or fallback behavior.
---
CHANGELOG.md | 6 ++
psalm-baseline.xml | 1 +
src/LaunchDarkly/EvaluationReason.php | 32 ++++++++++-
.../Impl/Evaluation/Evaluator.php | 2 +-
src/LaunchDarkly/LDClient.php | 6 +-
tests/EvaluationReasonTest.php | 45 +++++++++++++++
tests/Impl/Evaluation/EvaluatorFlagTest.php | 27 +++++++++
tests/LDClientTest.php | 55 +++++++++++++++++++
8 files changed, 168 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14d8e6784..2362d201f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
All notable changes to the LaunchDarkly PHP SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
+## Unreleased
+
+### Features
+
+* Expose the original exception on `EXCEPTION` evaluation reasons through `EvaluationReason::getException()`, including HTTP 401/403 failures. Exception details are excluded from JSON and analytics events.
+
## [6.11.0](https://github.com/launchdarkly/php-server-sdk/compare/6.10.0...6.11.0) (2026-10-21)
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 6269abaad..f91a02f3f 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 ac2676531..8d2a30658 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 is 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;
/**
* 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,22 @@ 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 any.
+ * The exception is available for diagnostics but is not included in JSON serialization.
+ *
+ * @return \Throwable|null
+ */
+ public function getException(): ?\Throwable
+ {
+ return $this->_exception;
+ }
+
/**
* 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 a518a370b..19551ee62 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 906b43130..7b33ae809 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 34fd198b1..31da3ef04 100644
--- a/tests/EvaluationReasonTest.php
+++ b/tests/EvaluationReasonTest.php
@@ -2,6 +2,7 @@
namespace LaunchDarkly\Tests;
+use LaunchDarkly\BigSegmentsEvaluationStatus;
use LaunchDarkly\EvaluationReason;
class EvaluationReasonTest extends \PHPUnit\Framework\TestCase
@@ -90,6 +91,50 @@ 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 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 9b7593160..dd58d7c07 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 025309e7a..391a2f969 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');
From a7a1a9df83e6466c96f1c71f6c169d1426a9216d Mon Sep 17 00:00:00 2001
From: Matthew Keeler
Date: Mon, 9 Nov 2026 11:39:44 -0500
Subject: [PATCH 2/4] fix: Leave the evaluation exception out of serialize()
output
---
src/LaunchDarkly/EvaluationReason.php | 24 +++++++++++++++++++++++-
tests/EvaluationReasonTest.php | 27 +++++++++++++++++++++++++++
2 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/src/LaunchDarkly/EvaluationReason.php b/src/LaunchDarkly/EvaluationReason.php
index 8d2a30658..a6832f474 100644
--- a/src/LaunchDarkly/EvaluationReason.php
+++ b/src/LaunchDarkly/EvaluationReason.php
@@ -97,7 +97,7 @@ class EvaluationReason implements \JsonSerializable
private ?string $_prerequisiteKey;
private bool $_inExperiment;
private ?BigSegmentsEvaluationStatus $_bigSegmentsEvaluationStatus;
- private ?\Throwable $_exception;
+ private ?\Throwable $_exception = null;
/**
* Creates a new instance of the OFF reason.
@@ -224,6 +224,28 @@ 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/tests/EvaluationReasonTest.php b/tests/EvaluationReasonTest.php
index 31da3ef04..b5d50cc55 100644
--- a/tests/EvaluationReasonTest.php
+++ b/tests/EvaluationReasonTest.php
@@ -104,6 +104,33 @@ public function testErrorReasonRetainsExceptionWithoutSerializingIt()
$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 testSerializeKeepsReasonFields()
+ {
+ $reason = EvaluationReason::ruleMatch(1, 'id', true);
+
+ $this->assertEquals($reason, unserialize(serialize($reason)));
+ }
+
public function testBigSegmentsStatusPreservesException()
{
$exception = new \RuntimeException('store unavailable');
From 9e1b9eef8a0f49019e776c47b3b407fd5b0e9ab5 Mon Sep 17 00:00:00 2001
From: Matthew Keeler
Date: Mon, 9 Nov 2026 11:39:44 -0500
Subject: [PATCH 3/4] chore: Remove the hand-written changelog entry
---
CHANGELOG.md | 6 ------
1 file changed, 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2362d201f..14d8e6784 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,6 @@
All notable changes to the LaunchDarkly PHP SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
-## Unreleased
-
-### Features
-
-* Expose the original exception on `EXCEPTION` evaluation reasons through `EvaluationReason::getException()`, including HTTP 401/403 failures. Exception details are excluded from JSON and analytics events.
-
## [6.11.0](https://github.com/launchdarkly/php-server-sdk/compare/6.10.0...6.11.0) (2026-10-21)
From e814356f7a014bcee3727982824768a1366dddb1 Mon Sep 17 00:00:00 2001
From: Matthew Keeler
Date: Mon, 9 Nov 2026 11:46:56 -0500
Subject: [PATCH 4/4] docs: Clarify when getException() returns the evaluation
exception
---
src/LaunchDarkly/EvaluationReason.php | 9 ++++++---
tests/EvaluationReasonTest.php | 19 +++++++++++++++++++
2 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/src/LaunchDarkly/EvaluationReason.php b/src/LaunchDarkly/EvaluationReason.php
index a6832f474..80c2c13e5 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. The exception is available from getException().
+ * evaluation. The exception may be available from getException().
* @var string
*/
const EXCEPTION_ERROR = 'EXCEPTION';
@@ -214,8 +214,11 @@ public function withBigSegmentsEvaluationStatus(BigSegmentsEvaluationStatus $big
}
/**
- * Returns the original exception that caused the evaluation to fail, if any.
- * The exception is available for diagnostics but is not included in JSON serialization.
+ * 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
*/
diff --git a/tests/EvaluationReasonTest.php b/tests/EvaluationReasonTest.php
index b5d50cc55..f10225a03 100644
--- a/tests/EvaluationReasonTest.php
+++ b/tests/EvaluationReasonTest.php
@@ -3,6 +3,7 @@
namespace LaunchDarkly\Tests;
use LaunchDarkly\BigSegmentsEvaluationStatus;
+use LaunchDarkly\EvaluationDetail;
use LaunchDarkly\EvaluationReason;
class EvaluationReasonTest extends \PHPUnit\Framework\TestCase
@@ -124,6 +125,24 @@ public function testSerializeOmitsException()
$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);