Skip to content
Open
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/tough-pianos-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": patch
---

Cap Retry-After delays at maximum_backoff_duration to bound synchronous retry waits.
4 changes: 2 additions & 2 deletions lib/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,10 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders
if ($shouldRetry === false) {
break;
} elseif ($this->isRetryableStatus($responseCode)) {
// Retry transient failures. Honor Retry-After when provided, otherwise use
// Retry transient failures. Honor Retry-After up to the maximum backoff, otherwise use
// exponential backoff starting at 100ms.
$retryAfterMs = $this->retryAfterMilliseconds($responseHeaders);
usleep(($retryAfterMs ?? $backoff) * 1000);
usleep(min($retryAfterMs ?? $backoff, $this->maximumBackoffDurationMs) * 1000);
$backoff *= 2;
} else {
// Do not retry non-transient responses (e.g. 400, 401, 403, 413,
Expand Down
38 changes: 38 additions & 0 deletions test/HttpClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use PHPUnit\Framework\TestCase;
use PostHog\HttpClient;

require_once __DIR__ . '/fixtures/usleep_mock.php';

class RetryAfterHttpClient extends HttpClient
{
/** @param array<int, string> $headers */
Expand Down Expand Up @@ -119,6 +121,42 @@ public function testHttpResponsesAndRetryPolicy(int $status, bool $retry, int $a
}
}

public static function retryDelayCases(): array
{
return [
'large rate-limit delay capped' => [429, ['Retry-After' => '60'], [2500000, 2500000]],
'large service-unavailable delay capped' => [503, ['Retry-After' => '60'], [2500000, 2500000]],
'small delay honored' => [429, ['Retry-After' => '1'], [1000000, 1000000]],
'date delay capped' => [503, ['Retry-After' => 'Thu, 01 Jan 2099 00:00:00 GMT'], [2500000, 2500000]],
'no header uses exponential backoff' => [503, [], [100000, 200000]],
];
}

#[DataProvider('retryDelayCases')]
public function testRetryDelay(int $status, array $headers, array $expectedDelays): void
{
global $httpClientSleepDurations;
$httpClientSleepDurations = [];
$server = new LocalHttpServer([
['status' => $status, 'headers' => $headers],
['status' => $status, 'headers' => $headers],
['status' => 200, 'body' => 'delivered'],
]);

try {
$client = new HttpClient($server->address(), useSsl: false, maximumBackoffDuration: 2500);
$response = $client->sendRequest('/batch/', '{"batch":[]}');

$this->assertSame($expectedDelays, $httpClientSleepDurations);
$this->assertSame(200, $response->getResponseCode());
$this->assertSame('delivered', $response->getResponse());
$this->assertCount(3, $server->requests());
} finally {
$httpClientSleepDurations = null;
$server->stop();
}
}

public function testConditionalGetPreservesEtagAndSeparatesResponseHeaders(): void
{
$server = new LocalHttpServer([
Expand Down
15 changes: 15 additions & 0 deletions test/fixtures/usleep_mock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace PostHog;

function usleep(int $microseconds): void
{
global $httpClientSleepDurations;

if (is_array($httpClientSleepDurations)) {
$httpClientSleepDurations[] = $microseconds;
return;
}

\usleep($microseconds);
}