@@ -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 /**
0 commit comments