eveseat/eveapi

View on GitHub
src/Jobs/EsiBase.php

Summary

Maintainability
A
55 mins
Test Coverage
<?php

/*
 * This file is part of SeAT
 *
 * Copyright (C) 2015 to present Leon Jacobs
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

namespace Seat\Eveapi\Jobs;

use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use Seat\Eseye\Exceptions\RequestFailedException;
use Seat\Eveapi\Exception\PermanentInvalidTokenException;
use Seat\Eveapi\Exception\TemporaryEsiOutageException;
use Seat\Eveapi\Exception\UnavailableEveServersException;
use Seat\Eveapi\InteractsWithToken;
use Seat\Eveapi\Jobs\Middleware\CheckEsiRateLimit;
use Seat\Eveapi\Jobs\Middleware\CheckEsiRouteStatus;
use Seat\Eveapi\Jobs\Middleware\CheckServerStatus;
use Seat\Eveapi\Models\RefreshToken;
use Seat\Services\Contracts\EsiClient;
use Seat\Services\Contracts\EsiResponse;
use Seat\Services\Helpers\AnalyticsContainer;
use Seat\Services\Jobs\Analytics;
use Throwable;

/**
 * Class EsiBase.
 *
 * @package Seat\Eveapi\Jobs
 */
abstract class EsiBase extends AbstractJob
{
    use InteractsWithToken;

    /**
     * ANTI_RACE_DELAY prevents rapid job recycling with low queue depths.
     */
    const ANTI_RACE_DELAY = 10;

    /**
     * ACCESS_TOKEN_EXPIRY_DELAY forces lock release after 25m (as access token expiry is 20m).
     */
    const ACCESS_TOKEN_EXPIRY_DELAY = 25 * 60;

    const RATE_LIMIT = 80;

    const RATE_LIMIT_DURATION = 300;

    const RATE_LIMIT_KEY = 'esiratelimit';

    const PERMANENT_INVALID_TOKEN_MESSAGES = [
        'invalid_token: The refresh token is expired.',
        'invalid_token: The refresh token does not match the client specified.',
        'invalid_grant: Invalid refresh token. Character grant missing/expired.',
        'invalid_grant: Invalid refresh token. Unable to migrate grant.',
        'invalid_grant: Invalid refresh token. Token missing/expired.',
    ];

    /**
     * The maximum number of unhandled exceptions to allow before failing.
     *
     * @var int
     */
    public $maxExceptions = 3;

    /**
     * @var string By default, queue all ESI jobs on public queue.
     */
    public $queue = 'public'; // By default, queue all ESI jobs on public queue.

    /**
     * The HTTP method used for the API Call.
     *
     * Eg: GET, POST, PUT, DELETE
     *
     * @var string
     */
    protected $method = '';

    /**
     * The ESI endpoint to call.
     *
     * Eg: /characters/{character_id}/
     *
     * @var string
     */
    protected $endpoint = '';

    /**
     * The endpoint version to use.
     *
     * Eg: v1, v4
     *
     * @var int
     */
    protected $version = '';

    /**
     * The SSO scope required to make the call.
     *
     * @var string
     */
    protected $scope = 'public';

    /**
     * The page to retrieve.
     *
     * Jobs that expect paged responses should have
     * this value set.
     *
     * @var int
     */
    protected $page = null;

    /**
     * The body to send along with the request.
     *
     * @var array
     */
    protected $request_body = [];

    /**
     * Any query string parameters that should be sent
     * with the request.
     *
     * @var array
     */
    protected $query_string = [];

    /**
     * @var \Seat\Eveapi\Models\RefreshToken
     */
    protected $token;

    /**
     * @var \Seat\Services\Contracts\EsiClient
     */
    protected EsiClient $esi;

    /**
     * @throws \Illuminate\Contracts\Container\BindingResolutionException
     */
    public function __construct()
    {
        // Attach an ESI Client.
        $this->esi = app()->make(EsiClient::class);
    }

    /**
     * @return array
     */
    public function middleware()
    {
        return [
            new CheckEsiRateLimit,
            new CheckServerStatus,
            new CheckEsiRouteStatus,
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function tags(): array
    {
        $tags = parent::tags();

        if (is_null($this->token))
            $tags[] = 'public';

        return $tags;
    }

    /**
     * @return int
     */
    public function getRateLimitKeyTtl(): int
    {
        // Redis 'default' database can be different from 'cache'
        $redis = Redis::connection('cache');

        return $redis->ttl(Cache::getPrefix() . self::RATE_LIMIT_KEY);
    }

    /**
     * @return array
     */
    public function getRoles(): array
    {
        return $this->roles ?: [];
    }

    /**
     * @return \Seat\Eveapi\Models\RefreshToken|null
     */
    public function getToken(): ?RefreshToken
    {
        return $this->token;
    }

    protected function getClient(): EsiClient
    {
        return $this->esi;
    }

    /**
     * @return string
     */
    public function getScope(): string
    {
        return $this->scope ?: '';
    }

    /**
     * @return string
     */
    public function getEndpoint(): string
    {
        return $this->endpoint;
    }

    /**
     * @return \Illuminate\Support\Carbon
     */
    public function backoff()
    {
        return now()->addSeconds($this->attempts() * 300);
    }

    /**
     * @param  \Throwable  $exception
     *
     * @throws \Exception
     */
    public function failed(Throwable $exception)
    {
        parent::failed($exception);

        // used token is non longer valid, remove it from the system.
        if ($exception instanceof PermanentInvalidTokenException) {
            $this->token->delete();
        }

        // TQ server is not available, clear cache, so middleware will prevent to grant jobs to be processed.
        if ($exception instanceof UnavailableEveServersException) {
            cache()->remember('eve_db_status', 60, function () {
                return null;
            });
        }
    }

    /**
     * @param  int  $amount
     *
     * @throws \Psr\SimpleCache\InvalidArgumentException
     */
    public function incrementEsiRateLimit(int $amount = 1)
    {

        if ($this->getRateLimitKeyTtl() > 3) {

            cache()->increment(self::RATE_LIMIT_KEY, $amount);

        } else {

            cache()->set(self::RATE_LIMIT_KEY, $amount, carbon('now')
                ->addSeconds(self::RATE_LIMIT_DURATION));
        }
    }

    /**
     * @param  array  $path_values
     * @return \Seat\Services\Contracts\EsiResponse
     *
     * @throws \Seat\Eseye\Exceptions\RequestFailedException
     * @throws \Seat\Eveapi\Exception\PermanentInvalidTokenException
     * @throws \Seat\Eveapi\Exception\TemporaryEsiOutageException
     * @throws \Seat\Eveapi\Exception\UnavailableEveServersException
     * @throws \Throwable
     */
    public function retrieve(array $path_values = []): EsiResponse
    {
        $this->validateCall();

        $this->esi->setVersion($this->version);
        $this->esi->setBody($this->request_body);
        $this->esi->setQueryString($this->query_string);

        // Configure the page to get
        if (! is_null($this->page))
            $this->esi->page($this->page);

        $this->configureTokenForEsiClient();

        // Generally, we want to bubble up exceptions all the way to the
        // callee. However, in the case of this worker class, we need to
        // try and be vigilant with tokens that may have expired. So for
        // those cases we wrap in a try/catch.
        try {
            $result = $this->esi->invoke($this->method, $this->endpoint, $path_values);

            // Update the refresh token we have stored in the database.
            $this->updateRefreshToken();

        } catch (RequestFailedException $exception) {
            $this->handleEsiFailedCall($exception);
        }

        // If this is a cached load, don't bother with any further
        // processing.
        if ($result->isFromCache())
            return $result;

        // Perform error checking
        $this->warning($result);

        return $result;
    }

    /**
     * Validates a call to ensure that a method and endpoint is set
     * in the job that is using this base class.
     *
     * @return void
     *
     * @throws \Exception
     */
    public function validateCall(): void
    {

        if (! in_array($this->method, ['get', 'post', 'put', 'patch', 'delete']))
            throw new Exception('Invalid HTTP method used');

        if (trim($this->endpoint) === '')
            throw new Exception('Empty endpoint used');

        // Enfore a version specification unless this is a 'meta' call.
        if (trim($this->version) === '' && ! in_array('meta', $this->tags()))
            throw new Exception('Version is empty');
    }

    /**
     * Logs warnings to the Eseye logger.
     *
     * These warnings will also cause analytics jobs to be
     * sent to allow for monitoring of endpoint changes.
     *
     * @param  \Seat\Services\Contracts\EsiResponse  $response
     */
    public function warning(EsiResponse $response): void
    {

        if (! is_null($response->getPagesCount()) && $this->page === null) {

            $this->esi->getLogger()->warning('Response contained pages but none was expected');

            dispatch(new Analytics((new AnalyticsContainer)
                ->set('type', 'endpoint_warning')
                ->set('ec', 'unexpected_page')
                ->set('el', $this->version)
                ->set('ev', $this->endpoint)))->onQueue('default');
        }

        if (! is_null($this->page) && $response->getPagesCount() === null) {

            $this->esi->getLogger()->warning('Expected a paged response but had none');

            dispatch(new Analytics((new AnalyticsContainer)
                ->set('type', 'endpoint_warning')
                ->set('ec', 'missing_pages')
                ->set('el', $this->version)
                ->set('ev', $this->endpoint)))->onQueue('default');
        }

        if ($response->hasHeader('warning')) {

            $this->esi->getLogger()->warning('A response contained a warning: ' .
                $response->getHeaderLine('warning'));

            dispatch(new Analytics((new AnalyticsContainer)
                ->set('type', 'generic_warning')
                ->set('ec', 'missing_pages')
                ->set('el', $this->endpoint)
                ->set('ev', $response->getHeader('Warning'))))->onQueue('default');
        }
    }

    /**
     * Check if there are any pages left in a response
     * based on the number of pages available and the
     * current page.
     *
     * @param  int|null  $pages
     * @return bool
     */
    public function nextPage(?int $pages): bool
    {

        if (is_null($pages) || $this->page >= $pages)
            return false;

        $this->page++;

        return true;
    }

    /**
     * @param  \Seat\Eseye\Exceptions\RequestFailedException  $exception
     *
     * @throws \Seat\Eseye\Exceptions\RequestFailedException
     * @throws \Seat\Eveapi\Exception\PermanentInvalidTokenException
     * @throws \Seat\Eveapi\Exception\TemporaryEsiOutageException
     * @throws \Seat\Eveapi\Exception\UnavailableEveServersException|\Psr\SimpleCache\InvalidArgumentException
     */
    private function handleEsiFailedCall(RequestFailedException $exception)
    {
        // increment ESI rate limit
        $this->incrementEsiRateLimit();

        $response = $exception->getEsiResponse();

        // Update the refresh token we have stored in the database.
        $this->updateRefreshToken();

        // in case SSO did odd stuff with generated token, falsify the expires date/time
        // so eseye library will renew the token on next call.
        if ($response->getErrorCode() == 403 && $response->error() == 'token expiry is too far in the future') {
            if ($this->token) {
                $this->token->expires_on = carbon()->subMinutes(10);
                $this->token->save();
            }

            throw new TemporaryEsiOutageException($response->error(), $response->getErrorCode(), $exception);
        }

        // If the token can't login and we get an HTTP 400 together with
        // and error message stating that this is an invalid_token, remove
        // the token from SeAT.
        if ($response->getErrorCode() == 400 && in_array($response->error(), self::PERMANENT_INVALID_TOKEN_MESSAGES))
            throw new PermanentInvalidTokenException($response->error(), $response->getErrorCode(), $exception);

        if (($response->getErrorCode() == 503 && $response->error() == 'The datasource tranquility is temporarily unavailable') ||
            ($response->getErrorCode() == 504 && $response->error() == 'Timeout contacting tranquility'))
            throw new UnavailableEveServersException($response->error(), $response->getErrorCode(), $exception);

        if ($response->getErrorCode() >= 500)
            throw new TemporaryEsiOutageException($response->error(), $response->getErrorCode(), $exception);

        // Rethrow the exception
        throw $exception;
    }
}