Skip to content

Commit

Permalink
Attempt to implement ratelimiting via Redis locks
Browse files Browse the repository at this point in the history
  • Loading branch information
xHeaven committed Nov 24, 2024
1 parent 8740b80 commit 08bafc4
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 97 deletions.
213 changes: 117 additions & 96 deletions src/GraphQLClientMethods.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,40 @@
namespace Luminarix\Shopify\GraphQLClient;

use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Traits\Macroable;
use JetBrains\PhpStorm\ArrayShape;
use Luminarix\Shopify\GraphQLClient\Authenticators\Abstracts\AbstractAppAuthenticator;
use Luminarix\Shopify\GraphQLClient\Enums\GraphQLRequestType;
use Luminarix\Shopify\GraphQLClient\Exceptions\ClientNotInitializedException;
use Luminarix\Shopify\GraphQLClient\Exceptions\ClientRequestFailedException;
use Luminarix\Shopify\GraphQLClient\Integrations\ShopifyConnector;
use Luminarix\Shopify\GraphQLClient\Services\RateLimitService;

class GraphQLClientMethods
{
use Macroable;

private float|int|null $maxAvailableLimit = null;
private RateLimitService $rateLimitService;

private float|int|null $lastAvailableLimit = null;
private float|int|null $requestedQueryCost = null;

private float|int|null $actualQueryCost = null;

private float|int|null $maximumAvailable = null;

private float|int|null $currentlyAvailable = null;

private float|int|null $restoreRate = null;

private bool $isThrottled = false;

private int $tries = 0;

public function __construct(
private readonly AbstractAppAuthenticator $appAuthenticator,
private ?ShopifyConnector $connector = null,
) {
$this->connector = new ShopifyConnector($this->appAuthenticator);
$this->rateLimitService = new RateLimitService($this->appAuthenticator->getShopDomain());
}

/**
Expand All @@ -37,26 +47,13 @@ public function __construct(
*/
public function query(string $query, bool $withExtensions = false, bool $detailedCost = false): GraphQLClientTransformer
{
throw_if($this->connector === null, ClientNotInitializedException::class);

$response = $this->connector->create()->query($query, $detailedCost);
$response = $this->makeQueryRequest($query, $withExtensions, $detailedCost);

throw_if($response->failed(), ClientRequestFailedException::class, $response);

$response = $response->json();

$this->ispectResponse(
type: GraphQLRequestType::QUERY,
response: $response,
query: $query,
withExtensions: $withExtensions,
detailedCost: $detailedCost
);
/** @var array $response */
$response = $withExtensions ? $response : data_get($response, 'data');

return new GraphQLClientTransformer(
data: array_filter(Arr::wrap(
value: $withExtensions ? $response : data_get($response, 'data')
))
data: array_filter($response)
);
}

Expand All @@ -68,107 +65,131 @@ public function query(string $query, bool $withExtensions = false, bool $detaile
*/
public function mutate(string $query, array $variables, bool $withExtensions = false, bool $detailedCost = false): GraphQLClientTransformer
{
throw_if($this->connector === null, ClientNotInitializedException::class);

$response = $this->connector->create()->mutation($query, $variables, $detailedCost);

throw_if($response->failed(), ClientRequestFailedException::class, $response);

$response = $response->json();
$response = $this->makeMutationRequest($query, $variables, $withExtensions, $detailedCost);

$this->ispectResponse(
type: GraphQLRequestType::MUTATION,
response: $response,
query: $query,
withExtensions: $withExtensions,
detailedCost: $detailedCost,
variables: $variables
);
/** @var array $response */
$response = $withExtensions ? $response : data_get($response, 'data');

return new GraphQLClientTransformer(
data: array_filter(
Arr::wrap(
value: $withExtensions ? $response : data_get($response, 'data')
)
)
data: array_filter($response)
);
}

#[ArrayShape([
'requestedQueryCost' => 'float|int|null',
'actualQueryCost' => 'float|int|null',
'maxAvailableLimit' => 'float|int|null',
'lastAvailableLimit' => 'float|int|null',
'restoreRate' => 'float|int|null',
'isThrottled' => 'bool',
])]
public function getRateLimitInfo(): array
{
return [
'maxAvailableLimit' => $this->maxAvailableLimit,
'lastAvailableLimit' => $this->lastAvailableLimit,
'requestedQueryCost' => $this->requestedQueryCost,
'actualQueryCost' => $this->actualQueryCost,
'maxAvailableLimit' => $this->maximumAvailable,
'lastAvailableLimit' => $this->currentlyAvailable,
'restoreRate' => $this->restoreRate,
'isThrottled' => $this->isThrottled,
];
}

/**
* @throws ClientNotInitializedException
* @throws ClientRequestFailedException
*/
private function makeQueryRequest(string $query, bool $withExtensions = false, bool $detailedCost = false): array
{
throw_if($this->connector === null, ClientNotInitializedException::class);

$response = $this->connector->create()->query($query, $detailedCost);

throw_if($response->failed(), ClientRequestFailedException::class, $response);

$response = Arr::wrap($response->json());

$this->ispectResponse($response);

if ($this->isThrottled) {
if ($this->tries < config('shopify-graphql.throttle_max_tries')) {
$this->tries++;

$this->rateLimitService->waitIfNecessary((float)$this->requestedQueryCost);

return $this->makeQueryRequest($query, $withExtensions, $detailedCost);
}

throw_if(true, ClientRequestFailedException::class, $response);
}

return $response;
}

/**
* @throws ClientNotInitializedException
* @throws ClientRequestFailedException
*/
private function makeMutationRequest(string $query, array $variables, bool $withExtensions = false, bool $detailedCost = false): array
{
throw_if($this->connector === null, ClientNotInitializedException::class);

$response = $this->connector->create()->mutation($query, $variables, $detailedCost);

throw_if($response->failed(), ClientRequestFailedException::class, $response);

$response = Arr::wrap($response->json());

$this->ispectResponse($response);

if ($this->isThrottled) {
if ($this->tries < config('shopify-graphql.throttle_max_tries')) {
$this->tries++;

$this->rateLimitService->waitIfNecessary((float)$this->requestedQueryCost);

return $this->makeMutationRequest($query, $variables, $withExtensions, $detailedCost);
}

throw_if(true, ClientRequestFailedException::class, $response);
}

return $response;
}

private function ispectResponse(
GraphQLRequestType $type,
mixed $response,
string $query,
bool $withExtensions,
bool $detailedCost,
array $variables = [],
array $response,
): void {
if (!is_array($response)) {
return;
}
$this->updateRateLimitInfo($response);
}

/** @var float|int|null $requestedQueryCost */
$requestedQueryCost = data_get($response, 'extensions.cost.requestedQueryCost');
/** @var float|int|null $actualQueryCost */
$actualQueryCost = data_get($response, 'extensions.cost.actualQueryCost');

/** @var float|int|null $maxAvailableLimit */
$maxAvailableLimit = data_get($response, 'extensions.cost.throttleStatus.maximumAvailable');
/** @var float|int|null $lastAvailableLimit */
$lastAvailableLimit = data_get($response, 'extensions.cost.throttleStatus.currentlyAvailable');
/** @var float|int|null $restoreRate */
$restoreRate = data_get($response, 'extensions.cost.throttleStatus.restoreRate');

$this->updateRateLimitInfo($maxAvailableLimit, $lastAvailableLimit, $restoreRate);

$context = [
'type' => $type->value,
'query' => $query,
'variables' => $variables,
'withExtensions' => $withExtensions,
'detailedCost' => $detailedCost,
'requestedQueryCost' => $requestedQueryCost,
'actualQueryCost' => $actualQueryCost,
private function updateRateLimitInfo(array $response): void
{
$this->requestedQueryCost = $this->getCost($response, 'requestedQueryCost');
$this->actualQueryCost = $this->getCost($response, 'actualQueryCost');
$this->maximumAvailable = $this->getCost($response, 'throttleStatus.maximumAvailable');
$this->currentlyAvailable = $this->getCost($response, 'throttleStatus.currentlyAvailable');
$this->restoreRate = $this->getCost($response, 'throttleStatus.restoreRate');
$this->isThrottled = $this->isThrottled($response);

$rateLimitData = [
'maximumAvailable' => $this->maximumAvailable,
'currentlyAvailable' => $this->currentlyAvailable,
'restoreRate' => $this->restoreRate,
];
$this->rateLimitService->updateRateLimitInfo($rateLimitData);
}

if (
$requestedQueryCost !== null &&
$requestedQueryCost > 900
) {
Log::warning(
message: "The requested query cost is high: {$requestedQueryCost}\nConsider optimizing the query (see context).",
context: $context
);
}
private function getCost(array $response, string $costType): float|int|null
{
/** @var float|int|null $cost */
$cost = data_get($response, "extensions.cost.{$costType}");

if (
$actualQueryCost !== null &&
$actualQueryCost > 900
) {
Log::warning(
message: "The actual query cost is high: {$actualQueryCost}\nConsider optimizing the query (see context).",
context: $context
);
}
return $cost;
}

private function updateRateLimitInfo(float|int|null $maxAvailable, float|int|null $lastAvailable, float|int|null $restoreRate): void
private function isThrottled(array $response): bool
{
$this->maxAvailableLimit = $maxAvailable;
$this->lastAvailableLimit = $lastAvailable;
$this->restoreRate = $restoreRate;
return data_get($response, 'errors.0.extensions.code') === 'THROTTLED';
}
}
13 changes: 12 additions & 1 deletion src/Integrations/ShopifyConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,22 @@ public function create(): ShopifyResource

public function hasRequestFailed(Response $response): ?bool
{
return Arr::exists($response->json(), 'errors');
$isThrottled = $this->isThrottled($response);

if ($isThrottled && config('shopify-graphql.fail_on_throttled')) {
return true;
}

return Arr::exists($response->json(), 'errors') && !$isThrottled;
}

protected function defaultAuth(): HeaderAuthenticator
{
return new HeaderAuthenticator($this->appAuthenticator->accessToken, 'X-Shopify-Access-Token');
}

private function isThrottled(Response $response): bool
{
return Arr::exists($response->json(), 'errors') && data_get($response->json(), 'errors.0.extensions.code') === 'THROTTLED';
}
}
Loading

0 comments on commit 08bafc4

Please sign in to comment.