Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rest host fallback #147

Merged
merged 32 commits into from
May 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8408d33
Created host file, added code to check for expired time
sacOO7 Apr 7, 2022
717d631
Added hosttest to test hosts
sacOO7 Apr 7, 2022
116dced
Created basic hostCache to store host for given duration
sacOO7 Apr 8, 2022
a09de17
Refactored hostcache
sacOO7 Apr 8, 2022
ec389d9
Extracted HostCache in a separate class, added tests for the same
sacOO7 Apr 8, 2022
13f1865
Added getPrimaryRestHost method to clientOptions
sacOO7 Apr 9, 2022
8a47a15
Updated Host class with preferred and fallback impl
sacOO7 Apr 9, 2022
ade66b0
Refactored hosts, added support for primaryHost and fallbacks in the …
sacOO7 Apr 9, 2022
bbbb852
Refactored AblyRest, moved constants under Defaults
sacOO7 Apr 9, 2022
ced94c1
Fixed API_VERSION and LIB_VERSION references in the code
sacOO7 Apr 9, 2022
923ffb6
Added host tests for fallbacks
sacOO7 Apr 9, 2022
6885ab3
Moved systemTime to Miscellaneous, refactored HostCache and its tests
sacOO7 Apr 9, 2022
2e3d49f
Fixed failing expired host test
sacOO7 Apr 9, 2022
dcb8946
Refactored AblyRest with host fallbacks
sacOO7 Apr 9, 2022
972040f
Updated flaky cached fallback test
sacOO7 Apr 9, 2022
fbf5d35
Added code/test to check for active internet connection
sacOO7 Apr 9, 2022
31332d6
Updated fallbackHost integration test
sacOO7 Apr 11, 2022
86b70eb
Refactored http MaxRetryCount test
sacOO7 Apr 11, 2022
93109d5
Refactored custom restHost and Fallbacks test
sacOO7 Apr 11, 2022
15cfddb
Refactored ablyRestTest
sacOO7 Apr 12, 2022
a3093df
Refactoring shuffling logic without affecting original fallbackHosts
sacOO7 Apr 12, 2022
27e182a
Revert "Refactoring shuffling logic without affecting original fallba…
sacOO7 Apr 12, 2022
5e517eb
Maintained - Refactoring shuffling logic without affecting original f…
sacOO7 Apr 12, 2022
62e0e3e
Replaced all static assertions with TestCase instance
sacOO7 Apr 14, 2022
906f122
Updated cached fallback host integration test
sacOO7 Apr 14, 2022
3a33ff0
Refactored tests, added custom env fallbacks test
sacOO7 Apr 14, 2022
a6742fc
Implemented spec to set hostheader for fallback host, added test for …
sacOO7 Apr 15, 2022
3f38565
Refactored code to avoid duplication of host header during for loop
sacOO7 Apr 15, 2022
a5e3c7e
Refactored AblyRest code
sacOO7 Apr 15, 2022
91f8d9f
Refactored parse headers method
sacOO7 Apr 17, 2022
5610742
Added spec annotations to tests, removed unnecessary comments
sacOO7 Apr 18, 2022
59f08a4
Removed unnecessary comment for Host file
sacOO7 May 16, 2022
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
176 changes: 62 additions & 114 deletions src/AblyRest.php
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
<?php
namespace Ably;

use Ably\Models\ClientOptions;
use Ably\Models\PaginatedResult;
use Ably\Models\HttpPaginatedResponse;
use Ably\Exceptions\AblyException;
use Ably\Exceptions\AblyRequestException;
use Ably\Models\ClientOptions;
use Ably\Models\HttpPaginatedResponse;
use Ably\Models\PaginatedResult;
use Ably\Utils\Miscellaneous;

/**
* Ably REST client
*/
class AblyRest {

const API_VERSION = '1.1';
const LIB_VERSION = '1.1.6';

public $options;
/**
* Map of agents that will be appended to the agent header.
Expand All @@ -28,7 +26,7 @@ class AblyRest {

static function ablyAgentHeader()
{
$sdk_identifier = 'ably-php/'.self::LIB_VERSION;
$sdk_identifier = 'ably-php/'.Defaults::LIB_VERSION;
$runtime_identifier = 'php/'.Miscellaneous::getNumeric(phpversion());
$agent_header = $sdk_identifier.' '.$runtime_identifier;
foreach(self::$agents as $agent_identifier => $agent_version) {
Expand All @@ -52,9 +50,7 @@ static function ablyAgentHeader()
*/
public $channels;

// RSC15f Cached fallback host
private $cachedHost = null;
private $cachedHostExpires = null;
public $host;

/**
* Constructor
Expand Down Expand Up @@ -86,7 +82,7 @@ public function __construct( $options = [] ) {
$this->auth = new $authClass( $this, $this->options );
$this->channels = new Channels( $this );
$this->push = new Push( $this );

$this->host = new Host($this->options);
return $this;
}

Expand Down Expand Up @@ -116,14 +112,6 @@ public function time() {
return $res[0];
}

/**
* Returns local time
* @return integer system time in milliseconds
*/
public function systemTime() {
return intval( round( microtime(true) * 1000 ) );
}

/**
* Does a GET request, automatically injecting auth headers and handling fallback on server failure
* @see AblyRest::request()
Expand Down Expand Up @@ -155,6 +143,17 @@ public function put( $path, $headers = [], $params = [], $returnHeaders = false,
public function delete( $path, $headers = [], $params = [], $returnHeaders = false, $auth = true ) {
return $this->requestInternal( 'DELETE', $path, $headers, $params, $returnHeaders, $auth );
}

/**
* Returns hosts in the order 1. Cached Host or Primary Host 2. Randomized Fallback Hosts
* @return \Generator hosts
*/
public function getHosts() {
$prefHost = $this->host->getPreferredHost();
yield $prefHost;
yield from $this->host->fallbackHosts($prefHost);
}

/**
* Does a HTTP request, automatically injecting auth headers and handling fallback on server failure.
* This method is used internally and `request` is the preferable method to use.
Expand All @@ -169,52 +168,57 @@ public function delete( $path, $headers = [], $params = [], $returnHeaders = fal
* body, depending on $returnHeaders, body is automatically decoded
* @throws AblyRequestException if the request fails
*/
public function requestInternal( $method, $path, $headers = [], $params = [], $returnHeaders = false,
$auth = true ) {

public function requestInternal( $method, $path, $headers = [], $params = [], $returnHeaders = false, $auth = true ) {
$mergedHeaders = array_merge( [
'Accept: application/json',
'X-Ably-Version: ' .self::API_VERSION,
'X-Ably-Version: ' .Defaults::API_VERSION,
'Ably-Agent: ' .self::ablyAgentHeader(),
], $headers );

if ( $auth ) { // inject auth headers
$mergedHeaders = array_merge( $this->auth->getAuthHeaders(), $mergedHeaders );
}

try {
if ( !empty( $this->options->getFallbackHosts() ) ) {
$res = $this->requestWithFallback( $method, $path, $mergedHeaders, $params );
} else {
$hostUrl = $this->options->getHostUrl($this->options->getRestHost()). $path;
$res = $this->http->request( $method, $hostUrl , $mergedHeaders, $params );
}
} catch (AblyRequestException $e) {
// check if the exception was caused by an expired
// token = authorised request + using token auth + specific error message
$res = $e->getResponse();

$causedByExpiredToken = $auth
&& !$this->auth->isUsingBasicAuth()
&& ($e->getCode() >= 40140)
&& ($e->getCode() < 40150);

if ( $causedByExpiredToken ) { // renew the token
$this->auth->authorize();

// merge headers now and use auth = false to prevent potential endless recursion
$mergedHeaders = array_merge( $this->auth->getAuthHeaders(), $headers );

return $this->requestInternal($method, $path, $mergedHeaders, $params, $returnHeaders, $auth = false);
} else {
throw $e;
$attempt = 0;
$maxPossibleRetries = min(count($this->options->getFallbackHosts()), $this->options->httpMaxRetryCount);
foreach ($this->getHosts() as $host) {
$hostUrl = $this->options->getHostUrl($host). $path;
try {
$updatedHeaders = $mergedHeaders;
if ($host != $this->options->getPrimaryRestHost()) { // set hostHeader for fallback host (RSC15j)
$updatedHeaders[] = "Host: " . $host;
}
$response = $this->http->request( $method, $hostUrl, $updatedHeaders, $params );
$this->host->setPreferredHost($host);
break;
} catch (AblyRequestException $e) {
$response = $e->getResponse();
// Clear cached host if it failed (RSC15f)
$this->host->setPreferredHost("");

$isServerError = $e->getStatusCode() >= 500 && $e->getStatusCode() <= 504; // RSC15d
if ( $isServerError && $attempt < $maxPossibleRetries) {
$attempt += 1;
} else {
$causedByExpiredToken = $auth && !$this->auth->isUsingBasicAuth()
&& ($e->getCode() >= 40140)
&& ($e->getCode() < 40150);

if ( $causedByExpiredToken ) { // renew the token
$this->auth->authorize();

// merge headers now and use auth = false to prevent potential endless recursion
$mergedHeaders = array_merge( $this->auth->getAuthHeaders(), $headers );

return $this->requestInternal($method, $path, $mergedHeaders, $params, $returnHeaders, $auth = false);
} else {
throw $e;
}
}
}
}

if (!$returnHeaders) {
$res = $res['body'];
$response = $response['body'];
}
return $res;
return $response;
}

/**
Expand Down Expand Up @@ -245,66 +249,10 @@ public function request( $method, $path, $params = [], $body = '', $headers = []
return new HttpPaginatedResponse( $this, 'Ably\Models\Untyped', null, $method, $path, $body, $headers );
}

/**
* Does a HTTP request backed up by fallback servers
*/
private function getHosts() {
// The cached fallback host
if ( $this->cachedHost != null ) {
if ( $this->systemTime() > $this->cachedHostExpires ) {
$this->cachedHost = null;
$this->cachedHostExpires = null;
} else {
yield $this->cachedHost;
}
}

// Default host
yield $this->options->getRestHost();

// Fallback hosts
foreach ($this->options->getFallbackHosts() as $host) {
if ( $host != $this->cachedHost ) { // Don't try twice the same host
yield $host;
}
}
}

protected function requestWithFallback( $method, $path, $headers = [], $params = [] ) {
$maxAttempts = min( $this->options->httpMaxRetryCount, count( $this->options->getFallbackHosts() ));
$attempt = 0;
foreach ($this->getHosts() as $host) {
$hostUrl = $this->options->getHostUrl($host). $path;
try {
$response = $this->http->request( $method, $hostUrl, $headers, $params );

// Keep fallback host for later (RSC15f)
if ( $attempt > 0 && $host != $this->options->getRestHost()) {
$this->cachedHost = $host;
$this->cachedHostExpires = $this->systemTime() + $this->options->fallbackRetryTimeout;
}

return $response;
} catch (AblyRequestException $e) {
// Clear cached host if it failed (RSC15f)
if ( $host == $this->cachedHost ) {
$this->cachedHost = null;
$this->cachedHostExpires = null;
}

// other error code than timeout, rethrow exception
if ( $e->getCode() < 50000 ) {
throw $e;
}

if ( $attempt >= $maxAttempts ) {
Log::e( 'Failed to connect to server and all of the fallback servers.' );
throw $e;
}

$attempt += 1;
}
}
// RTN17c
function hasActiveInternetConnection() {
$response = $this->http->get(Defaults::$internetCheckUrl);
return $response["body"] == Defaults::$internetCheckOk;
}

/**
Expand Down
8 changes: 3 additions & 5 deletions src/Auth.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
<?php
namespace Ably;

use Ably\AblyRest;
use Ably\Log;
use Ably\Exceptions\AblyException;
use Ably\Models\AuthOptions;
use Ably\Models\ClientOptions;
use Ably\Models\TokenDetails;
use Ably\Models\TokenParams;
use Ably\Models\TokenRequest;
use Ably\Exceptions\AblyException;

/**
* Provides authentification methods for AblyRest instances
Expand Down Expand Up @@ -108,7 +106,7 @@ public function authorizeInternal( $tokenParams = [], $authOptions = [], $force
// using cached token
Log::d( 'Auth::authorize: using cached token, unknown expiration time' );
return $this->tokenDetails;
} else if ( $this->tokenDetails->expires - self::TOKEN_EXPIRY_MARGIN > $this->ably->systemTime() ) {
} else if ( $this->tokenDetails->expires - self::TOKEN_EXPIRY_MARGIN > Utils\Miscellaneous::systemTime()) {
// using cached token
Log::d( 'Auth::authorize: using cached token, expires on ' . date( 'Y-m-d H:i:s', $this->tokenDetails->expires / 1000 ) );
return $this->tokenDetails;
Expand Down Expand Up @@ -318,7 +316,7 @@ public function createTokenRequest( $tokenParams = [], $authOptions = [] ) {
if ( $authOptions->queryTime ) {
$tokenRequest->timestamp = $this->ably->time();
} else if ( empty( $tokenRequest->timestamp ) ) {
$tokenRequest->timestamp = $this->ably->systemTime();
$tokenRequest->timestamp = Utils\Miscellaneous::systemTime();
}

if ( empty( $tokenRequest->clientId ) ) {
Expand Down
5 changes: 5 additions & 0 deletions src/Defaults.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
namespace Ably;

class Defaults {
const API_VERSION = '1.1';
const LIB_VERSION = '1.1.6';

static $restHost = "rest.ably.io";
static $realtimeHost = "realtime.ably.io";
static $port = 80;
static $tlsPort = 443;

static $internetCheckUrl = "https://internet-up.ably-realtime.com/is-the-internet-up.txt";
static $internetCheckOk = "yes\n";

static $fallbackHosts = [
'a.ably-realtime.com',
'b.ably-realtime.com',
Expand Down
42 changes: 42 additions & 0 deletions src/Host.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php
namespace Ably;

class Host {
private $primaryHost;
private $fallbackHosts;
private $hostCache;

public function __construct($clientOptions)
{
$this->primaryHost = $clientOptions->getPrimaryRestHost();
$this->fallbackHosts = $clientOptions->getFallbackHosts();
$this->hostCache = new HostCache($clientOptions->fallbackRetryTimeout);
}

public function fallbackHosts($currentHost) {
if ($currentHost != $this->primaryHost) {
yield $this->primaryHost;
}
$shuffledFallbacks = $this->fallbackHosts;
shuffle($shuffledFallbacks);
foreach ($shuffledFallbacks as $fallbackHost) {
if ($currentHost != $fallbackHost) {
yield $fallbackHost;
}
}
}

// getPreferredHost - Used to retrieve host in the order 1. Cached host 2. primary host
public function getPreferredHost() {
$host = $this->hostCache->get();
if (empty($host)) {
return $this->primaryHost;
}
return $host;
}

public function setPreferredHost($host) {
$this->hostCache->put($host);
}
}

32 changes: 32 additions & 0 deletions src/HostCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Ably;

// TODO - Add SyncMutex support to the class to avoid data corruption due to concurrent READ/WRITE (e.g. Apache multithreading environment)
use Ably\Utils\Miscellaneous;

class HostCache
{
private $timeoutDuration;
private $expireTime;
private $host = "";

public function __construct($timeoutDurationInMs)
{
$this->timeoutDuration = $timeoutDurationInMs;
}

public function put($host)
{
$this->host = $host;
$this->expireTime = Miscellaneous::systemTime() + $this->timeoutDuration;
}

public function get()
{
if (empty($this->host) || Miscellaneous::systemTime() > $this->expireTime) {
return "";
}
return $this->host;
}
}
Loading