Skip to content

Commit

Permalink
fixup! feat: Add option to configure HTTP status codes to consider as…
Browse files Browse the repository at this point in the history
… failure
  • Loading branch information
pvgnd committed Mar 28, 2021
1 parent 627e516 commit 9fc9076
Show file tree
Hide file tree
Showing 7 changed files with 491 additions and 69 deletions.
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,87 @@ $ganeshaClient = new GaneshaHttpClient(
);
```

### How does GaneshaHttpClient determine the failure?

As documented in [Usage](https://github.com/ackintosh/ganesha#usage), Ganesha detects failures for each `$service`.
Below, We will show you how GaneshaHttpClient specify failure explicitly.

By default Ganesha considers a request is successful as soon as the server responded, whatever the HTTP status code.

Alternatively, you can use the `RestFailureDetector` implementation of `FailureDetectorInterface` to specify a list of HTTP Status Code to be considered as failure via an option passed to client.
This implementation will consider failure when these HTTP status codes are returned by the server:
- 500 (Internal Server Error)
- 502 (Bad Gateway ou Proxy Error)
- 503 (Service Unavailable)
- 504 (Gateway Time-out)
- 505 (HTTP Version not supported)

```php
// via constructor argument
$ganeshaClient = new GaneshaHttpClient(
$client, $ganesha, null,
new RestFailureDetector([503])
);

// via request method argument
$ganeshaClient->request(
'GET',
'http://api.example.com/awesome_resource',
[
// 'ganesha.failure_status_codes' is defined as RestFailureDetector::OPTION_KEY
'ganesha.failure_status_codes' => [503],
]
);
```

Alternatively, you can apply your own rules by implementing a class that implements the `FailureDetectorInterface`.

```php
use Ackintosh\Ganesha\HttpClient\FailureDetectorInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class SampleFailureDetector implements FailureDetectorInterface
{
/**
* @override
*/
public function isFailureResponse(ResponseInterface $response, array $requestOptions): bool
{
try {
$jsonData = $response->toArray();
} catch (ExceptionInterface $e) {
return true;
}

// Server is not RestFull and always returns HTTP 200 Status Code, but set an error flag in the JSON payload.
return true === ($jsonData['error'] ?? false);
}

/**
* @override
*/
public function getOptionKeys(): array
{
// No option is defined for this implementation
return [];
}
}

// ---

$ganesha = Builder::withRateStrategy()
// ...
->build();
$ganeshaClient = new GaneshaHttpClient(
$client,
$ganesha,
null,
// Pass the failure detector as an argument of GaneshaHttpClient constructor.
new SampleFailureDetector()
);
```

## [Companies using Ganesha :rocket:](#table-of-contents)

Here are some companies using Ganesha in production! We are proud of them. :elephant:
Expand Down
70 changes: 21 additions & 49 deletions src/Ganesha/GaneshaHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@

use Ackintosh\Ganesha;
use Ackintosh\Ganesha\Exception\RejectedException;
use Ackintosh\Ganesha\HttpClient\FailureDetectorInterface;
use Ackintosh\Ganesha\HttpClient\RestFailureDetector;
use Ackintosh\Ganesha\HttpClient\ServiceNameExtractor;
use Ackintosh\Ganesha\HttpClient\ServiceNameExtractorInterface;
use Ackintosh\Ganesha\HttpClient\TransportFailureDetector;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
Expand All @@ -31,13 +34,9 @@ final class GaneshaHttpClient implements HttpClientInterface
private $serviceNameExtractor;

/**
* @var array<string, mixed>
* @var FailureDetectorInterface
*/
private $defaultOptions = [
// 4xx and 5xx are considered as success by default because server responded
'ganesha.failure_status_codes' => [], // array - containing HTTP status codes in 4XX and 5XX ranges that
// should be considered as failure (int value expected)
];
private $failureDetector;

/**
* @param array<string, mixed> $defaultOptions An array containing valid GaneshaHttpClient options
Expand All @@ -46,38 +45,12 @@ public function __construct(
HttpClientInterface $client,
Ganesha $ganesha,
?ServiceNameExtractorInterface $serviceNameExtractor = null,
array $defaultOptions = []
?FailureDetectorInterface $failureDetector = null
) {
$this->client = $client;
$this->ganesha = $ganesha;
$this->serviceNameExtractor = $serviceNameExtractor ?: new ServiceNameExtractor();
$this->defaultOptions = self::mergeDefaultOptions($this->defaultOptions, $defaultOptions);
}

/**
* @param array<string, mixed> $defaultOptions
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
private static function mergeDefaultOptions(array $defaultOptions, array $options): array
{
return \array_merge($defaultOptions, $options);
}

/**
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
private function avoidGaneshaOptionsPropagation(array $options): array
{
$optionsToUnset = array_keys($this->defaultOptions);
$optionsToUnset[] = ServiceNameExtractor::OPTION_KEY;

foreach ($optionsToUnset as $optionName) {
unset($options[$optionName]);
}

return $options;
$this->failureDetector = $failureDetector ?: new TransportFailureDetector();
}

/**
Expand All @@ -87,27 +60,17 @@ private function avoidGaneshaOptionsPropagation(array $options): array
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$options = self::mergeDefaultOptions($this->defaultOptions, $options);
$serviceName = $this->serviceNameExtractor->extract($method, $url, $options);

if (!$this->ganesha->isAvailable($serviceName)) {
throw new RejectedException(sprintf('"%s" is not available', $serviceName));
}

$response = $this->client->request($method, $url, $this->avoidGaneshaOptionsPropagation($options));
try {
$response->getHeaders();

$this->ganesha->success($serviceName);
} catch (ClientExceptionInterface | ServerExceptionInterface $e) {
if ($this->isFailureStatusCode($e->getResponse()->getStatusCode(), $options)) {
$this->ganesha->failure($serviceName);
} else {
$this->ganesha->success($serviceName);
}
} catch (RedirectionExceptionInterface | TransportExceptionInterface $e) {
// 3xx when max redirection is reached and network issues are considered as failure
if ($this->failureDetector->isFailureResponse($response, $options)) {
$this->ganesha->failure($serviceName);
} else {
$this->ganesha->success($serviceName);
}

return $response;
Expand All @@ -123,9 +86,18 @@ public function stream($responses, float $timeout = null): ResponseStreamInterfa

/**
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
private function isFailureStatusCode(int $responseStatusCode, array $options): bool
private function avoidGaneshaOptionsPropagation(array $options): array
{
return \in_array($responseStatusCode, $options['ganesha.failure_status_codes'], true);
$optionsToUnset = $this->failureDetector->getOptionKeys();
// FIXME: ServiceNameExtractorInterface implementation should be able to provide its own options keys
$optionsToUnset[] = ServiceNameExtractor::OPTION_KEY;

foreach ($optionsToUnset as $optionName) {
unset($options[$optionName]);
}

return $options;
}
}
17 changes: 17 additions & 0 deletions src/Ganesha/HttpClient/FailureDetectorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
namespace Ackintosh\Ganesha\HttpClient;

use Symfony\Contracts\HttpClient\ResponseInterface;

interface FailureDetectorInterface
{
/**
* @param array<string, mixed> $requestOptions
*/
public function isFailureResponse(ResponseInterface $response, array $requestOptions): bool;

/**
* @return string[]
*/
public function getOptionKeys(): array;
}
76 changes: 76 additions & 0 deletions src/Ganesha/HttpClient/RestFailureDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php
namespace Ackintosh\Ganesha\HttpClient;

use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class RestFailureDetector implements FailureDetectorInterface
{
/**
* @var string
*/
public const OPTION_KEY = 'ganesha.failure_status_codes';

/**
* @var int[]
*/
public const DEFAULT_FAILURE_STATUS_CODES = [
500, // Internal Server Error
502, // Bad Gateway ou Proxy Error
503, // Service Unavailable
504, // Gateway Time-out
505, // HTTP Version not supported
];

/**
* @var int[]
*/
private $defaultFailureStatusCodes;

/**
* @param int[] $defaultFailureStatusCodes
*/
public function __construct(?array $defaultFailureStatusCodes = null)
{
$this->defaultFailureStatusCodes = $defaultFailureStatusCodes ?? self::DEFAULT_FAILURE_STATUS_CODES;
}

/**
* {@inheritdoc}
*/
public function getOptionKeys(): array
{
return [self::OPTION_KEY];
}

/**
* {@inheritdoc}
*/
public function isFailureResponse(ResponseInterface $response, array $requestOptions): bool
{
try {
// Ensure request is triggered
$response->getHeaders();

return false;
} catch (ClientExceptionInterface | ServerExceptionInterface $e) {
return $this->isFailureStatusCode($e->getResponse()->getStatusCode(), $requestOptions);
} catch (RedirectionExceptionInterface | TransportExceptionInterface $e) {
// 3xx when max redirection is reached and network issues are considered as failure
return true;
}
}

/**
* @param array<string, mixed> $requestOptions
*/
private function isFailureStatusCode(int $responseStatusCode, array $requestOptions): bool
{
$failureStatusCodes = $requestOptions['ganesha.failure_status_codes'] ?? $this->defaultFailureStatusCodes;

return \in_array($responseStatusCode, $failureStatusCodes, true);
}
}
38 changes: 38 additions & 0 deletions src/Ganesha/HttpClient/TransportFailureDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
namespace Ackintosh\Ganesha\HttpClient;

use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class TransportFailureDetector implements FailureDetectorInterface
{
/**
* {@inheritdoc}
*/
public function getOptionKeys(): array
{
return [];
}

/**
* {@inheritdoc}
*/
public function isFailureResponse(ResponseInterface $response, array $requestOptions): bool
{
try {
// Ensure request is triggered
$response->getContent(true);

return false;
} catch (ClientExceptionInterface | ServerExceptionInterface $e) {
// 4xx and 5xx are considered as success by default because server responded
return false;
} catch (RedirectionExceptionInterface | TransportExceptionInterface $e) {
// 3xx when max redirection is reached and network issues are considered as failure
return true;
}
}
}

0 comments on commit 9fc9076

Please sign in to comment.