Skip to content

Commit 04e4df0

Browse files
authored
feat: Set $feature_flag_error on $feature_flag_called (#94)
* feat: Set `$feature_flag_error` on `$feature_flag_called` * chore: phpcs lint warnings * chore: Ignore side effect warning It's due to us requiring error_log_mock
1 parent 1725145 commit 04e4df0

File tree

7 files changed

+702
-21
lines changed

7 files changed

+702
-21
lines changed

lib/Client.php

Lines changed: 118 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ public function getFeatureFlag(
260260
$groupProperties
261261
);
262262
$result = null;
263+
$featureFlagError = null;
263264

264265
foreach ($this->featureFlags as $flag) {
265266
if ($flag["key"] == $key) {
@@ -290,17 +291,48 @@ public function getFeatureFlag(
290291
if (!$flagWasEvaluatedLocally && !$onlyEvaluateLocally) {
291292
try {
292293
$response = $this->fetchFlagsResponse($distinctId, $groups, $personProperties, $groupProperties);
294+
$errors = [];
295+
296+
if (isset($response['errorsWhileComputingFlags']) && $response['errorsWhileComputingFlags']) {
297+
$errors[] = FeatureFlagError::ERRORS_WHILE_COMPUTING_FLAGS;
298+
}
299+
293300
$requestId = isset($response['requestId']) ? $response['requestId'] : null;
294301
$evaluatedAt = isset($response['evaluatedAt']) ? $response['evaluatedAt'] : null;
295302
$flagDetail = isset($response['flags'][$key]) ? $response['flags'][$key] : null;
296303
$featureFlags = $response['featureFlags'] ?? [];
297304
if (array_key_exists($key, $featureFlags)) {
298305
$result = $featureFlags[$key];
299306
} else {
307+
$errors[] = FeatureFlagError::FLAG_MISSING;
300308
$result = null;
301309
}
310+
311+
if (!empty($errors)) {
312+
$featureFlagError = implode(',', $errors);
313+
}
314+
} catch (HttpException $e) {
315+
error_log("[PostHog][Client] Unable to get feature variants: " . $e->getMessage());
316+
switch ($e->getErrorType()) {
317+
case HttpException::QUOTA_LIMITED:
318+
$featureFlagError = FeatureFlagError::QUOTA_LIMITED;
319+
break;
320+
case HttpException::TIMEOUT:
321+
$featureFlagError = FeatureFlagError::TIMEOUT;
322+
break;
323+
case HttpException::CONNECTION_ERROR:
324+
$featureFlagError = FeatureFlagError::CONNECTION_ERROR;
325+
break;
326+
case HttpException::API_ERROR:
327+
$featureFlagError = FeatureFlagError::apiError($e->getStatusCode());
328+
break;
329+
default:
330+
$featureFlagError = FeatureFlagError::UNKNOWN_ERROR;
331+
}
332+
$result = null;
302333
} catch (Exception $e) {
303-
error_log("[PostHog][Client] Unable to get feature variants:" . $e->getMessage());
334+
error_log("[PostHog][Client] Unable to get feature variants: " . $e->getMessage());
335+
$featureFlagError = FeatureFlagError::UNKNOWN_ERROR;
304336
$result = null;
305337
}
306338
}
@@ -325,6 +357,10 @@ public function getFeatureFlag(
325357
$properties['$feature_flag_reason'] = $flagDetail['reason']['description'];
326358
}
327359

360+
if (!is_null($featureFlagError)) {
361+
$properties['$feature_flag_error'] = $featureFlagError;
362+
}
363+
328364
$this->capture([
329365
"properties" => $properties,
330366
"distinct_id" => $distinctId,
@@ -355,10 +391,7 @@ public function getFeatureFlagPayload(
355391
array $personProperties = array(),
356392
array $groupProperties = array(),
357393
): mixed {
358-
$results = json_decode(
359-
$this->flags($distinctId, $groups, $personProperties, $groupProperties),
360-
true
361-
);
394+
$results = $this->flags($distinctId, $groups, $personProperties, $groupProperties);
362395

363396
if (isset($results['featureFlags'][$key]) === false || $results['featureFlags'][$key] !== true) {
364397
return null;
@@ -517,10 +550,7 @@ private function fetchFlagsResponse(
517550
array $personProperties = [],
518551
array $groupProperties = []
519552
): ?array {
520-
return json_decode(
521-
$this->flags($distinctId, $groups, $personProperties, $groupProperties),
522-
true
523-
);
553+
return $this->flags($distinctId, $groups, $personProperties, $groupProperties);
524554
}
525555

526556
/**
@@ -600,9 +630,39 @@ public function getFlagsEtag(): ?string
600630
return $this->flagsEtag;
601631
}
602632

603-
private function normalizeFeatureFlags(string $response): string
633+
/**
634+
* Normalize feature flags response to ensure consistent format.
635+
* Decodes JSON, checks for quota limits, and transforms v4 to v3 format.
636+
*
637+
* @param string $response The raw JSON response
638+
* @return array The normalized response
639+
* @throws HttpException On invalid JSON or quota limit
640+
*/
641+
private function normalizeFeatureFlags(string $response): array
604642
{
605643
$decoded = json_decode($response, true);
644+
645+
if (!is_array($decoded)) {
646+
throw new HttpException(
647+
HttpException::API_ERROR,
648+
0,
649+
"Invalid JSON response"
650+
);
651+
}
652+
653+
// Check for quota limit in response body
654+
if (
655+
isset($decoded['quotaLimited'])
656+
&& is_array($decoded['quotaLimited'])
657+
&& in_array('feature_flags', $decoded['quotaLimited'])
658+
) {
659+
throw new HttpException(
660+
HttpException::QUOTA_LIMITED,
661+
0,
662+
"Feature flags quota limited"
663+
);
664+
}
665+
606666
if (isset($decoded['flags']) && !empty($decoded['flags'])) {
607667
// This is a v4 response, we need to transform it to a v3 response for backwards compatibility
608668
$transformedFlags = [];
@@ -619,18 +679,27 @@ private function normalizeFeatureFlags(string $response): string
619679
}
620680
$decoded['featureFlags'] = $transformedFlags;
621681
$decoded['featureFlagPayloads'] = $transformedPayloads;
622-
return json_encode($decoded);
623682
}
624683

625-
return $response;
684+
return $decoded;
626685
}
627686

687+
/**
688+
* Fetch feature flags from the PostHog API.
689+
*
690+
* @param string $distinctId The user's distinct ID
691+
* @param array $groups Group identifiers
692+
* @param array $personProperties Person properties for flag evaluation
693+
* @param array $groupProperties Group properties for flag evaluation
694+
* @return array The normalized feature flags response
695+
* @throws HttpException On network errors, API errors, or quota limits
696+
*/
628697
public function flags(
629698
string $distinctId,
630699
array $groups = array(),
631700
array $personProperties = [],
632701
array $groupProperties = []
633-
) {
702+
): array {
634703
$payload = array(
635704
'api_key' => $this->apiKey,
636705
'distinct_id' => $distinctId,
@@ -648,7 +717,7 @@ public function flags(
648717
$payload["group_properties"] = $groupProperties;
649718
}
650719

651-
$response = $this->httpClient->sendRequest(
720+
$httpResponse = $this->httpClient->sendRequest(
652721
'/flags/?v=2',
653722
json_encode($payload),
654723
[
@@ -659,9 +728,42 @@ public function flags(
659728
"shouldRetry" => false,
660729
"timeout" => $this->featureFlagsRequestTimeout
661730
]
662-
)->getResponse();
731+
);
732+
733+
$responseCode = $httpResponse->getResponseCode();
734+
$curlErrno = $httpResponse->getCurlErrno();
735+
736+
if ($responseCode === 0) {
737+
// CURLE_OPERATION_TIMEDOUT (28)
738+
// https://curl.se/libcurl/c/libcurl-errors.html
739+
if ($curlErrno === 28) {
740+
throw new HttpException(
741+
HttpException::TIMEOUT,
742+
0,
743+
"Request timed out"
744+
);
745+
}
746+
// Consider everything else a connection error
747+
// CURLE_COULDNT_RESOLVE_HOST (6)
748+
// CURLE_COULDNT_CONNECT (7)
749+
// CURLE_WEIRD_SERVER_REPLY (8)
750+
// etc.
751+
throw new HttpException(
752+
HttpException::CONNECTION_ERROR,
753+
0,
754+
"Connection error (curl errno: {$curlErrno})"
755+
);
756+
}
757+
758+
if ($responseCode >= 400) {
759+
throw new HttpException(
760+
HttpException::API_ERROR,
761+
$responseCode,
762+
"API error: HTTP {$responseCode}"
763+
);
764+
}
663765

664-
return $this->normalizeFeatureFlags($response);
766+
return $this->normalizeFeatureFlags($httpResponse->getResponse());
665767
}
666768

667769
/**

lib/FeatureFlagError.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace PostHog;
4+
5+
class FeatureFlagError
6+
{
7+
/**
8+
* Server returned errorsWhileComputingFlags=true
9+
*/
10+
public const ERRORS_WHILE_COMPUTING_FLAGS = 'errors_while_computing_flags';
11+
12+
/**
13+
* Requested flag not in API response
14+
*/
15+
public const FLAG_MISSING = 'flag_missing';
16+
17+
/**
18+
* Rate/quota limit exceeded
19+
*/
20+
public const QUOTA_LIMITED = 'quota_limited';
21+
22+
/**
23+
* Request timed out
24+
*/
25+
public const TIMEOUT = 'timeout';
26+
27+
/**
28+
* Network connectivity issue
29+
*/
30+
public const CONNECTION_ERROR = 'connection_error';
31+
32+
/**
33+
* Unexpected exceptions
34+
*/
35+
public const UNKNOWN_ERROR = 'unknown_error';
36+
37+
/**
38+
* Create an API error with HTTP status code
39+
*
40+
* @param int $status HTTP status code
41+
* @return string Error string in format "api_error_{status}"
42+
*/
43+
public static function apiError(int $status): string
44+
{
45+
return "api_error_{$status}";
46+
}
47+
}

lib/HttpClient.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ private function executePost($ch, bool $includeEtag = false): HttpResponse
155155
{
156156
$response = curl_exec($ch);
157157
$responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
158+
$curlErrno = curl_errno($ch);
158159
$etag = null;
159160

160161
if ($includeEtag && $response !== false) {
@@ -168,10 +169,10 @@ private function executePost($ch, bool $includeEtag = false): HttpResponse
168169
$etag = trim($matches[1]);
169170
}
170171

171-
return new HttpResponse($body, $responseCode, $etag);
172+
return new HttpResponse($body, $responseCode, $etag, $curlErrno);
172173
}
173174

174-
return new HttpResponse($response, $responseCode);
175+
return new HttpResponse($response, $responseCode, null, $curlErrno);
175176
}
176177

177178
private function handleError($code, $message)

lib/HttpException.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace PostHog;
4+
5+
/**
6+
* Exception for HTTP-related errors during API requests.
7+
*
8+
* This exception captures both the error type and HTTP status code
9+
* to enable specific error handling for different failure scenarios.
10+
*/
11+
class HttpException extends \Exception
12+
{
13+
/**
14+
* Request timed out
15+
*/
16+
public const TIMEOUT = 'timeout';
17+
18+
/**
19+
* Network connectivity issue
20+
*/
21+
public const CONNECTION_ERROR = 'connection_error';
22+
23+
/**
24+
* Rate/quota limit exceeded
25+
*/
26+
public const QUOTA_LIMITED = 'quota_limited';
27+
28+
/**
29+
* HTTP 4xx/5xx error from API
30+
*/
31+
public const API_ERROR = 'api_error';
32+
33+
/**
34+
* @var string
35+
*/
36+
private string $errorType;
37+
38+
/**
39+
* @var int
40+
*/
41+
private int $statusCode;
42+
43+
/**
44+
* @param string $errorType One of the error type constants (TIMEOUT, CONNECTION_ERROR, etc.)
45+
* @param int $statusCode HTTP status code (0 for connection/timeout errors)
46+
* @param string $message Error message
47+
*/
48+
public function __construct(string $errorType, int $statusCode = 0, string $message = '')
49+
{
50+
$this->errorType = $errorType;
51+
$this->statusCode = $statusCode;
52+
parent::__construct($message);
53+
}
54+
55+
/**
56+
* Get the error type constant
57+
*
58+
* @return string One of TIMEOUT, CONNECTION_ERROR, QUOTA_LIMITED, API_ERROR
59+
*/
60+
public function getErrorType(): string
61+
{
62+
return $this->errorType;
63+
}
64+
65+
/**
66+
* Get the HTTP status code
67+
*
68+
* @return int HTTP status code (0 for connection/timeout errors)
69+
*/
70+
public function getStatusCode(): int
71+
{
72+
return $this->statusCode;
73+
}
74+
}

lib/HttpResponse.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ class HttpResponse
77
private $response;
88
private $responseCode;
99
private $etag;
10+
private $curlErrno;
1011

11-
public function __construct($response, $responseCode, ?string $etag = null)
12+
public function __construct($response, $responseCode, ?string $etag = null, int $curlErrno = 0)
1213
{
1314
$this->response = $response;
1415
$this->responseCode = $responseCode;
1516
$this->etag = $etag;
17+
$this->curlErrno = $curlErrno;
1618
}
1719

1820
/**
@@ -48,4 +50,14 @@ public function isNotModified(): bool
4850
{
4951
return $this->responseCode === 304;
5052
}
53+
54+
/**
55+
* Get the curl error number (0 if no error)
56+
*
57+
* @return int
58+
*/
59+
public function getCurlErrno(): int
60+
{
61+
return $this->curlErrno;
62+
}
5163
}

0 commit comments

Comments
 (0)