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
134 changes: 118 additions & 16 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ public function getFeatureFlag(
$groupProperties
);
$result = null;
$featureFlagError = null;

foreach ($this->featureFlags as $flag) {
if ($flag["key"] == $key) {
Expand Down Expand Up @@ -290,17 +291,48 @@ public function getFeatureFlag(
if (!$flagWasEvaluatedLocally && !$onlyEvaluateLocally) {
try {
$response = $this->fetchFlagsResponse($distinctId, $groups, $personProperties, $groupProperties);
$errors = [];

if (isset($response['errorsWhileComputingFlags']) && $response['errorsWhileComputingFlags']) {
$errors[] = FeatureFlagError::ERRORS_WHILE_COMPUTING_FLAGS;
}

$requestId = isset($response['requestId']) ? $response['requestId'] : null;
$evaluatedAt = isset($response['evaluatedAt']) ? $response['evaluatedAt'] : null;
$flagDetail = isset($response['flags'][$key]) ? $response['flags'][$key] : null;
$featureFlags = $response['featureFlags'] ?? [];
if (array_key_exists($key, $featureFlags)) {
$result = $featureFlags[$key];
} else {
$errors[] = FeatureFlagError::FLAG_MISSING;
$result = null;
}

if (!empty($errors)) {
$featureFlagError = implode(',', $errors);
}
} catch (HttpException $e) {
error_log("[PostHog][Client] Unable to get feature variants: " . $e->getMessage());
switch ($e->getErrorType()) {
case HttpException::QUOTA_LIMITED:
$featureFlagError = FeatureFlagError::QUOTA_LIMITED;
break;
case HttpException::TIMEOUT:
$featureFlagError = FeatureFlagError::TIMEOUT;
break;
case HttpException::CONNECTION_ERROR:
$featureFlagError = FeatureFlagError::CONNECTION_ERROR;
break;
case HttpException::API_ERROR:
$featureFlagError = FeatureFlagError::apiError($e->getStatusCode());
break;
default:
$featureFlagError = FeatureFlagError::UNKNOWN_ERROR;
}
$result = null;
} catch (Exception $e) {
error_log("[PostHog][Client] Unable to get feature variants:" . $e->getMessage());
error_log("[PostHog][Client] Unable to get feature variants: " . $e->getMessage());
$featureFlagError = FeatureFlagError::UNKNOWN_ERROR;
$result = null;
}
}
Expand All @@ -325,6 +357,10 @@ public function getFeatureFlag(
$properties['$feature_flag_reason'] = $flagDetail['reason']['description'];
}

if (!is_null($featureFlagError)) {
$properties['$feature_flag_error'] = $featureFlagError;
}

$this->capture([
"properties" => $properties,
"distinct_id" => $distinctId,
Expand Down Expand Up @@ -355,10 +391,7 @@ public function getFeatureFlagPayload(
array $personProperties = array(),
array $groupProperties = array(),
): mixed {
$results = json_decode(
$this->flags($distinctId, $groups, $personProperties, $groupProperties),
true
);
$results = $this->flags($distinctId, $groups, $personProperties, $groupProperties);

if (isset($results['featureFlags'][$key]) === false || $results['featureFlags'][$key] !== true) {
return null;
Expand Down Expand Up @@ -517,10 +550,7 @@ private function fetchFlagsResponse(
array $personProperties = [],
array $groupProperties = []
): ?array {
return json_decode(
$this->flags($distinctId, $groups, $personProperties, $groupProperties),
true
);
return $this->flags($distinctId, $groups, $personProperties, $groupProperties);
}

/**
Expand Down Expand Up @@ -600,9 +630,39 @@ public function getFlagsEtag(): ?string
return $this->flagsEtag;
}

private function normalizeFeatureFlags(string $response): string
/**
* Normalize feature flags response to ensure consistent format.
* Decodes JSON, checks for quota limits, and transforms v4 to v3 format.
*
* @param string $response The raw JSON response
* @return array The normalized response
* @throws HttpException On invalid JSON or quota limit
*/
private function normalizeFeatureFlags(string $response): array
{
$decoded = json_decode($response, true);

if (!is_array($decoded)) {
throw new HttpException(
HttpException::API_ERROR,
0,
"Invalid JSON response"
);
}

// Check for quota limit in response body
if (
isset($decoded['quotaLimited'])
&& is_array($decoded['quotaLimited'])
&& in_array('feature_flags', $decoded['quotaLimited'])
) {
throw new HttpException(
HttpException::QUOTA_LIMITED,
0,
"Feature flags quota limited"
);
}

if (isset($decoded['flags']) && !empty($decoded['flags'])) {
// This is a v4 response, we need to transform it to a v3 response for backwards compatibility
$transformedFlags = [];
Expand All @@ -619,18 +679,27 @@ private function normalizeFeatureFlags(string $response): string
}
$decoded['featureFlags'] = $transformedFlags;
$decoded['featureFlagPayloads'] = $transformedPayloads;
return json_encode($decoded);
}

return $response;
return $decoded;
}

/**
* Fetch feature flags from the PostHog API.
*
* @param string $distinctId The user's distinct ID
* @param array $groups Group identifiers
* @param array $personProperties Person properties for flag evaluation
* @param array $groupProperties Group properties for flag evaluation
* @return array The normalized feature flags response
* @throws HttpException On network errors, API errors, or quota limits
*/
public function flags(
string $distinctId,
array $groups = array(),
array $personProperties = [],
array $groupProperties = []
) {
): array {
$payload = array(
'api_key' => $this->apiKey,
'distinct_id' => $distinctId,
Expand All @@ -648,7 +717,7 @@ public function flags(
$payload["group_properties"] = $groupProperties;
}

$response = $this->httpClient->sendRequest(
$httpResponse = $this->httpClient->sendRequest(
'/flags/?v=2',
json_encode($payload),
[
Expand All @@ -659,9 +728,42 @@ public function flags(
"shouldRetry" => false,
"timeout" => $this->featureFlagsRequestTimeout
]
)->getResponse();
);

$responseCode = $httpResponse->getResponseCode();
$curlErrno = $httpResponse->getCurlErrno();

if ($responseCode === 0) {
// CURLE_OPERATION_TIMEDOUT (28)
// https://curl.se/libcurl/c/libcurl-errors.html
if ($curlErrno === 28) {
throw new HttpException(
HttpException::TIMEOUT,
0,
"Request timed out"
);
}
// Consider everything else a connection error
// CURLE_COULDNT_RESOLVE_HOST (6)
// CURLE_COULDNT_CONNECT (7)
// CURLE_WEIRD_SERVER_REPLY (8)
// etc.
throw new HttpException(
HttpException::CONNECTION_ERROR,
0,
"Connection error (curl errno: {$curlErrno})"
);
}

if ($responseCode >= 400) {
throw new HttpException(
HttpException::API_ERROR,
$responseCode,
"API error: HTTP {$responseCode}"
);
}

return $this->normalizeFeatureFlags($response);
return $this->normalizeFeatureFlags($httpResponse->getResponse());
}

/**
Expand Down
47 changes: 47 additions & 0 deletions lib/FeatureFlagError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace PostHog;

class FeatureFlagError
{
/**
* Server returned errorsWhileComputingFlags=true
*/
public const ERRORS_WHILE_COMPUTING_FLAGS = 'errors_while_computing_flags';

/**
* Requested flag not in API response
*/
public const FLAG_MISSING = 'flag_missing';

/**
* Rate/quota limit exceeded
*/
public const QUOTA_LIMITED = 'quota_limited';

/**
* Request timed out
*/
public const TIMEOUT = 'timeout';

/**
* Network connectivity issue
*/
public const CONNECTION_ERROR = 'connection_error';

/**
* Unexpected exceptions
*/
public const UNKNOWN_ERROR = 'unknown_error';

/**
* Create an API error with HTTP status code
*
* @param int $status HTTP status code
* @return string Error string in format "api_error_{status}"
*/
public static function apiError(int $status): string
{
return "api_error_{$status}";
}
}
5 changes: 3 additions & 2 deletions lib/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ private function executePost($ch, bool $includeEtag = false): HttpResponse
{
$response = curl_exec($ch);
$responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlErrno = curl_errno($ch);
$etag = null;

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

return new HttpResponse($body, $responseCode, $etag);
return new HttpResponse($body, $responseCode, $etag, $curlErrno);
}

return new HttpResponse($response, $responseCode);
return new HttpResponse($response, $responseCode, null, $curlErrno);
}

private function handleError($code, $message)
Expand Down
74 changes: 74 additions & 0 deletions lib/HttpException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace PostHog;

/**
* Exception for HTTP-related errors during API requests.
*
* This exception captures both the error type and HTTP status code
* to enable specific error handling for different failure scenarios.
*/
class HttpException extends \Exception
{
/**
* Request timed out
*/
public const TIMEOUT = 'timeout';

/**
* Network connectivity issue
*/
public const CONNECTION_ERROR = 'connection_error';

/**
* Rate/quota limit exceeded
*/
public const QUOTA_LIMITED = 'quota_limited';

/**
* HTTP 4xx/5xx error from API
*/
public const API_ERROR = 'api_error';

/**
* @var string
*/
private string $errorType;

/**
* @var int
*/
private int $statusCode;

/**
* @param string $errorType One of the error type constants (TIMEOUT, CONNECTION_ERROR, etc.)
* @param int $statusCode HTTP status code (0 for connection/timeout errors)
* @param string $message Error message
*/
public function __construct(string $errorType, int $statusCode = 0, string $message = '')
{
$this->errorType = $errorType;
$this->statusCode = $statusCode;
parent::__construct($message);
}

/**
* Get the error type constant
*
* @return string One of TIMEOUT, CONNECTION_ERROR, QUOTA_LIMITED, API_ERROR
*/
public function getErrorType(): string
{
return $this->errorType;
}

/**
* Get the HTTP status code
*
* @return int HTTP status code (0 for connection/timeout errors)
*/
public function getStatusCode(): int
{
return $this->statusCode;
}
}
14 changes: 13 additions & 1 deletion lib/HttpResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ class HttpResponse
private $response;
private $responseCode;
private $etag;
private $curlErrno;

public function __construct($response, $responseCode, ?string $etag = null)
public function __construct($response, $responseCode, ?string $etag = null, int $curlErrno = 0)
{
$this->response = $response;
$this->responseCode = $responseCode;
$this->etag = $etag;
$this->curlErrno = $curlErrno;
}

/**
Expand Down Expand Up @@ -48,4 +50,14 @@ public function isNotModified(): bool
{
return $this->responseCode === 304;
}

/**
* Get the curl error number (0 if no error)
*
* @return int
*/
public function getCurlErrno(): int
{
return $this->curlErrno;
}
}
Loading
Loading