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
5 changes: 5 additions & 0 deletions .changeset/fresh-pears-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": patch
---

Retry capture delivery on transient HTTP errors and respect Retry-After responses.
21 changes: 21 additions & 0 deletions .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: SDK Compliance Tests

permissions:
contents: read
packages: read
pull-requests: write

on:
pull_request:
push:
branches:
- main

jobs:
compliance:
name: PostHog SDK compliance tests
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@be8b8d5a3f94a249659844e94832e874f049c1e4
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-context: "."
test-harness-version: "0.8.0"
24 changes: 6 additions & 18 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ private function doGetFeatureFlagResult(

if (!$flagWasEvaluatedLocally && !$onlyEvaluateLocally) {
try {
$response = $this->requestFlags($distinctId, $groups, $personProperties, $groupProperties);
$response = $this->requestFlags($distinctId, $groups, $personProperties, $groupProperties, false, [$key]);
$errors = [];

if (isset($response['errorsWhileComputingFlags']) && $response['errorsWhileComputingFlags']) {
Expand Down Expand Up @@ -1662,24 +1662,12 @@ private function requestFlags(
$payload = array(
'api_key' => $this->apiKey,
'distinct_id' => $distinctId,
'groups' => empty($groups) ? (object) [] : $groups,
'person_properties' => empty($personProperties) ? (object) [] : $personProperties,
'group_properties' => empty($groupProperties) ? (object) [] : $groupProperties,
'geoip_disable' => $disableGeoip,
);

if (!empty($groups)) {
$payload["groups"] = $groups;
}

if (!empty($personProperties)) {
$payload["person_properties"] = $personProperties;
}

if (!empty($groupProperties)) {
$payload["group_properties"] = $groupProperties;
}

if ($disableGeoip) {
$payload["geoip_disable"] = true;
}

if ($flagKeys !== null) {
$payload["flag_keys_to_evaluate"] = array_values($flagKeys);
}
Expand Down Expand Up @@ -2012,7 +2000,7 @@ private function hasExplicitCaptureDistinctId(array &$msg): bool

private function normalizeMessageUuid(array $msg): array
{
if (array_key_exists('uuid', $msg) && !$this->isValidUuid($msg['uuid'])) {
if (!array_key_exists('uuid', $msg) || !$this->isValidUuid($msg['uuid'])) {
$msg['uuid'] = Uuid::v4();
}

Expand Down
55 changes: 48 additions & 7 deletions lib/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders
do {
// open connection
$ch = curl_init();
$responseHeaders = [];

if (null !== $payload) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
Expand Down Expand Up @@ -131,9 +132,14 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders
curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
}

// Capture response headers if we need to extract ETag
// Capture response headers if we need to extract ETag or honor Retry-After.
if ($includeEtag) {
curl_setopt($ch, CURLOPT_HEADER, true);
} else {
curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, string $header) use (&$responseHeaders): int {
$responseHeaders[] = trim($header);
return strlen($header);
});
}

// retry failed requests just once to diminish impact on performance
Expand All @@ -155,14 +161,14 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders

if ($shouldRetry === false) {
break;
} elseif (($responseCode >= 500 && $responseCode <= 600) || 429 == $responseCode) {
// If status code is greater than 500 and less than 600, it indicates server error
// Error code 429 indicates rate limited.
// Retry uploading in these cases.
usleep($backoff * 1000);
} elseif ($this->isRetryableStatus($responseCode)) {
// Retry transient failures. Honor Retry-After when provided, otherwise use
// exponential backoff starting at 100ms.
$retryAfterMs = $this->retryAfterMilliseconds($responseHeaders);
usleep(($retryAfterMs ?? $backoff) * 1000);
$backoff *= 2;
} else {
// Do not retry non-5xx/non-429 responses (e.g. 4xx, 413 Payload Too Large,
// Do not retry non-transient responses (e.g. 400, 401, 403, 413,
// or responseCode 0 for network errors). PHP sends synchronously in the hosting
// app's request path, so broad retries would slow down the host application.
break;
Expand Down Expand Up @@ -199,6 +205,41 @@ private function executePost($ch, bool $includeEtag = false): HttpResponse
return new HttpResponse($response, $responseCode, null, $curlErrno);
}

protected function isRetryableStatus(int $responseCode): bool
{
return $responseCode === 408
|| $responseCode === 429
|| ($responseCode >= 500 && $responseCode <= 600);
}

/**
* @param array<int, string> $headers
*/
protected function retryAfterMilliseconds(array $headers): ?int
{
foreach ($headers as $header) {
if (stripos($header, 'Retry-After:') !== 0) {
continue;
}

$value = trim(substr($header, strlen('Retry-After:')));
if ($value === '') {
return null;
}

if (ctype_digit($value)) {
return max(0, (int) $value * 1000);
}

$timestamp = strtotime($value);
if ($timestamp !== false) {
return max(0, (int) (($timestamp - time()) * 1000));
}
}

return null;
}

private function handleError($code, $message)
{
if (null !== $this->errorHandler) {
Expand Down
4 changes: 3 additions & 1 deletion lib/QueueConsumer.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ public function __construct($apiKey, $options = array())
}

if (isset($options["compress_request"])) {
$this->compress_request = json_decode($options["compress_request"]);
$this->compress_request = is_bool($options["compress_request"])
? $options["compress_request"]
: (bool) json_decode($options["compress_request"]);
}

$this->queue = array();
Expand Down
21 changes: 21 additions & 0 deletions sdk_compliance_adapter/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM php:8.3-cli

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip \
&& rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json composer.lock /app/
COPY lib/ /app/lib/
COPY bin/ /app/bin/

RUN composer install --no-interaction --prefer-dist --no-dev --no-progress

COPY sdk_compliance_adapter/ /app/sdk_compliance_adapter/

EXPOSE 8080

CMD ["php", "/app/sdk_compliance_adapter/adapter.php"]
12 changes: 12 additions & 0 deletions sdk_compliance_adapter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# PostHog PHP SDK Compliance Adapter

This adapter wraps the PostHog PHP SDK for the PostHog SDK compliance test harness.

## Local run

```sh
cd sdk_compliance_adapter
docker compose up --build --abort-on-container-exit --exit-code-from test-harness
```

The adapter exposes the standard harness endpoints: `/health`, `/init`, `/capture`, `/flush`, `/state`, and `/reset`.
Loading
Loading