You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
434 lines
15 KiB
434 lines
15 KiB
<?php
|
|
|
|
/*
|
|
* Request.php
|
|
* Copyright (c) 2021 james@firefly-iii.org
|
|
*
|
|
* This file is part of the Firefly III Data Importer
|
|
* (https://github.com/firefly-iii/data-importer).
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Nordigen\Request;
|
|
|
|
use JsonException;
|
|
use App\Exceptions\AgreementExpiredException;
|
|
use App\Exceptions\ImporterErrorException;
|
|
use App\Exceptions\ImporterHttpException;
|
|
use App\Exceptions\RateLimitException;
|
|
use App\Services\Shared\Response\Response;
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Exception\ClientException;
|
|
use GuzzleHttp\Exception\GuzzleException;
|
|
use GuzzleHttp\Exception\TransferException;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
|
|
/**
|
|
* Class Request
|
|
*/
|
|
abstract class Request
|
|
{
|
|
private string $base;
|
|
private array $body;
|
|
private array $parameters;
|
|
private float $timeOut = 3.14;
|
|
|
|
private string $token;
|
|
private string $url;
|
|
|
|
private int $remaining = -1;
|
|
private int $reset = -1;
|
|
|
|
/**
|
|
* @throws ImporterHttpException
|
|
*/
|
|
abstract public function get(): Response;
|
|
|
|
/**
|
|
* @throws ImporterHttpException
|
|
*/
|
|
abstract public function post(): Response;
|
|
|
|
/**
|
|
* @throws ImporterHttpException
|
|
*/
|
|
abstract public function put(): Response;
|
|
|
|
public function setBody(array $body): void
|
|
{
|
|
$this->body = $body;
|
|
}
|
|
|
|
public function setParameters(array $parameters): void
|
|
{
|
|
Log::debug('Request parameters will be set to: ', $parameters);
|
|
$this->parameters = $parameters;
|
|
}
|
|
|
|
public function setTimeOut(float $timeOut): void
|
|
{
|
|
$this->timeOut = $timeOut;
|
|
}
|
|
|
|
/**
|
|
* @throws ImporterErrorException
|
|
* @throws ImporterHttpException
|
|
* @throws AgreementExpiredException
|
|
* @throws RateLimitException
|
|
*/
|
|
protected function authenticatedGet(): array
|
|
{
|
|
$fullUrl = sprintf('%s/%s', $this->getBase(), $this->getUrl());
|
|
|
|
if (0 !== count($this->parameters)) {
|
|
$fullUrl = sprintf('%s?%s', $fullUrl, http_build_query($this->parameters));
|
|
}
|
|
Log::debug(sprintf('authenticatedGet(%s)', $fullUrl));
|
|
$client = $this->getClient();
|
|
$body = null;
|
|
|
|
try {
|
|
$res = $client->request(
|
|
'GET',
|
|
$fullUrl,
|
|
[
|
|
'headers' => [
|
|
'Accept' => 'application/json',
|
|
'Content-Type' => 'application/json',
|
|
'Authorization' => sprintf('Bearer %s', $this->getToken()),
|
|
'User-Agent' => sprintf('Firefly III GoCardless importer / %s / %s', config('importer.version'), config('auth.line_b')),
|
|
],
|
|
]
|
|
);
|
|
} catch (ClientException|GuzzleException|TransferException $e) {
|
|
$statusCode = $e->getCode();
|
|
if (429 === $statusCode) {
|
|
Log::debug(sprintf('Ran into exception: %s', $e::class));
|
|
$this->logRateLimitHeaders($e->getResponse(), true);
|
|
// $this->reportRateLimit($fullUrl, $e);
|
|
$this->pauseForRateLimit($e->getResponse(), true);
|
|
|
|
return [];
|
|
}
|
|
Log::error(sprintf('Original error: %s: %s', $e::class, $e->getMessage()));
|
|
|
|
// crash but there is a response, log it.
|
|
if (method_exists($e, 'getResponse') && method_exists($e, 'hasResponse') && $e->hasResponse()) {
|
|
$response = $e->getResponse();
|
|
Log::error(sprintf('%s', $response->getBody()->getContents()));
|
|
}
|
|
|
|
// if no response, parse as normal error response
|
|
if (method_exists($e, 'hasResponse') && !$e->hasResponse()) {
|
|
throw new ImporterHttpException(sprintf('Exception: %s', $e->getMessage()), 0, $e);
|
|
}
|
|
|
|
// if app can get response, parse it.
|
|
$json = [];
|
|
if (method_exists($e, 'getResponse')) {
|
|
$body = (string) $e->getResponse()->getBody();
|
|
$json = json_decode($body, true) ?? [];
|
|
}
|
|
if (array_key_exists('summary', $json) && str_contains((string) $json['summary'], 'expired')) {
|
|
$exception = new AgreementExpiredException();
|
|
$exception->json = $json;
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
// if status code is 503, the account does not exist.
|
|
$exception = new ImporterErrorException(sprintf('%s: %s', $e::class, $e->getMessage()), 0, $e);
|
|
$exception->json = $json;
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
$this->logRateLimitHeaders($res, false);
|
|
$this->pauseForRateLimit($res, false);
|
|
|
|
if (200 !== $res->getStatusCode()) {
|
|
// return body, class must handle this
|
|
Log::error(sprintf('[1] Status code is %d', $res->getStatusCode()));
|
|
|
|
$body = (string) $res->getBody();
|
|
}
|
|
$body ??= (string) $res->getBody();
|
|
|
|
try {
|
|
$json = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $e) {
|
|
throw new ImporterHttpException(
|
|
sprintf(
|
|
'Could not decode JSON (%s). Error[%d] is: %s. Response: %s',
|
|
$fullUrl,
|
|
$res->getStatusCode(),
|
|
$e->getMessage(),
|
|
$body
|
|
)
|
|
);
|
|
}
|
|
|
|
if (null === $json) {
|
|
throw new ImporterHttpException(sprintf('Body is empty. [2] Status code is %d.', $res->getStatusCode()));
|
|
}
|
|
if (config('importer.log_return_json')) {
|
|
Log::debug('JSON', $json);
|
|
}
|
|
|
|
return $json;
|
|
}
|
|
|
|
public function getBase(): string
|
|
{
|
|
return $this->base;
|
|
}
|
|
|
|
public function setBase(string $base): void
|
|
{
|
|
$this->base = $base;
|
|
}
|
|
|
|
public function getUrl(): string
|
|
{
|
|
return $this->url;
|
|
}
|
|
|
|
public function setUrl(string $url): void
|
|
{
|
|
$this->url = $url;
|
|
}
|
|
|
|
private function getClient(): Client
|
|
{
|
|
// config here
|
|
|
|
return new Client(
|
|
[
|
|
'connect_timeout' => $this->timeOut,
|
|
]
|
|
);
|
|
}
|
|
|
|
public function getToken(): string
|
|
{
|
|
return $this->token;
|
|
}
|
|
|
|
public function setToken(string $token): void
|
|
{
|
|
$this->token = $token;
|
|
}
|
|
|
|
/**
|
|
* @throws GuzzleException
|
|
* @throws ImporterHttpException
|
|
*/
|
|
protected function authenticatedJsonPost(array $json): array
|
|
{
|
|
Log::debug(sprintf('Now at %s', __METHOD__));
|
|
$fullUrl = sprintf('%s/%s', $this->getBase(), $this->getUrl());
|
|
|
|
if (0 !== count($this->parameters)) {
|
|
$fullUrl = sprintf('%s?%s', $fullUrl, http_build_query($this->parameters));
|
|
}
|
|
|
|
$client = $this->getClient();
|
|
|
|
try {
|
|
$res = $client->request(
|
|
'POST',
|
|
$fullUrl,
|
|
[
|
|
'json' => $json,
|
|
'headers' => [
|
|
'Accept' => 'application/json',
|
|
'Content-Type' => 'application/json',
|
|
'Authorization' => sprintf('Bearer %s', $this->getToken()),
|
|
],
|
|
]
|
|
);
|
|
} catch (ClientException $e) {
|
|
// TODO error response, not an exception.
|
|
throw new ImporterHttpException(sprintf('AuthenticatedJsonPost: %s', $e->getMessage()), 0, $e);
|
|
}
|
|
$body = (string) $res->getBody();
|
|
$this->logRateLimitHeaders($res, false);
|
|
$this->pauseForRateLimit($res, false);
|
|
|
|
try {
|
|
$json = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $e) {
|
|
// TODO error response, not an exception.
|
|
throw new ImporterHttpException(sprintf('AuthenticatedJsonPost JSON: %s', $e->getMessage()), 0, $e);
|
|
}
|
|
|
|
return $json;
|
|
}
|
|
|
|
private function logRateLimitHeaders(ResponseInterface $res, bool $fromErrorSituation): void
|
|
{
|
|
$headers = $res->getHeaders();
|
|
$method = $fromErrorSituation ? 'error' : 'debug';
|
|
if (array_key_exists('http_x_ratelimit_limit', $headers)) {
|
|
Log::{$method}(sprintf('Rate limit: %s', trim(implode(' ', $headers['http_x_ratelimit_limit']))));
|
|
}
|
|
if (array_key_exists('http_x_ratelimit_remaining', $headers)) {
|
|
Log::{$method}(sprintf('Rate limit remaining: %s', trim(implode(' ', $headers['http_x_ratelimit_remaining']))));
|
|
}
|
|
if (array_key_exists('http_x_ratelimit_reset', $headers)) {
|
|
Log::{$method}(sprintf('Rate limit reset: %s', trim(implode(' ', $headers['http_x_ratelimit_reset']))));
|
|
}
|
|
|
|
if (array_key_exists('http_x_ratelimit_account_success_limit', $headers)) {
|
|
Log::{$method}(sprintf('Account success rate limit: %s', trim(implode(' ', $headers['http_x_ratelimit_account_success_limit']))));
|
|
}
|
|
if (array_key_exists('http_x_ratelimit_account_success_remaining', $headers)) {
|
|
Log::{$method}(sprintf('Account success rate limit remaining: %s', trim(implode(' ', $headers['http_x_ratelimit_account_success_remaining']))));
|
|
}
|
|
if (array_key_exists('http_x_ratelimit_account_success_reset', $headers)) {
|
|
Log::{$method}(sprintf('Account success rate limit reset: %s', trim(implode(' ', $headers['http_x_ratelimit_account_success_reset']))));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws RateLimitException
|
|
*/
|
|
private function pauseForRateLimit(ResponseInterface $res, bool $fromErrorSituation): void
|
|
{
|
|
$method = $fromErrorSituation ? 'error' : 'debug';
|
|
Log::{$method}('Now in pauseForRateLimit');
|
|
$headers = $res->getHeaders();
|
|
|
|
// raw header values for debugging:
|
|
Log::debug(sprintf('http_x_ratelimit_remaining: %s', json_encode($headers['http_x_ratelimit_remaining'] ?? false)));
|
|
Log::debug(sprintf('http_x_ratelimit_reset: %s', json_encode($headers['http_x_ratelimit_reset'] ?? false)));
|
|
Log::debug(sprintf('http_x_ratelimit_account_success_remaining: %s', json_encode($headers['http_x_ratelimit_account_success_remaining'] ?? false)));
|
|
Log::debug(sprintf('http_x_ratelimit_account_success_reset: %s', json_encode($headers['http_x_ratelimit_account_success_reset'] ?? false)));
|
|
|
|
// first the normal rate limit:
|
|
$remaining = (int) ($headers['http_x_ratelimit_remaining'][0] ?? -2);
|
|
$reset = (int) ($headers['http_x_ratelimit_reset'][0] ?? -2);
|
|
$this->reportAndPause('Rate limit', $remaining, $reset, $fromErrorSituation);
|
|
|
|
// then the account success rate limit:
|
|
$remaining = (int) ($headers['http_x_ratelimit_account_success_remaining'][0] ?? -2);
|
|
$reset = (int) ($headers['http_x_ratelimit_account_success_reset'][0] ?? -2);
|
|
|
|
// save the remaining info in the object.
|
|
$this->reset = $reset;
|
|
if ($remaining > -1) { // zero or more.
|
|
Log::{$method}('Save the account success limits? YES');
|
|
$this->remaining = $remaining;
|
|
}
|
|
if ($remaining < 0) { // less than zero.
|
|
Log::{$method}('Save the account success limits? NO');
|
|
}
|
|
|
|
$this->reportAndPause('Account success limit', $remaining, $reset, $fromErrorSituation);
|
|
}
|
|
|
|
public static function formatTime(int $reset): string
|
|
{
|
|
$return = '';
|
|
if ($reset < 0) {
|
|
Log::warning('The reset time is negative!');
|
|
$return = '-';
|
|
$reset = abs($reset);
|
|
}
|
|
|
|
// days:
|
|
$days = floor($reset / 86400);
|
|
if ($days > 0) {
|
|
$return .= sprintf('%dd', $days);
|
|
}
|
|
$reset -= ($days * 86400);
|
|
|
|
$hours = floor($reset / 3600);
|
|
if ($hours > 0) {
|
|
$return .= sprintf('%dh', $hours);
|
|
}
|
|
$reset -= ($hours * 3600);
|
|
$minutes = floor($reset / 60);
|
|
if ($minutes > 0) {
|
|
$return .= sprintf('%dm', $minutes);
|
|
}
|
|
$reset -= ($minutes * 60);
|
|
$seconds = $reset % 60;
|
|
if ($seconds > 0) {
|
|
$return .= sprintf('%ds', $seconds);
|
|
}
|
|
|
|
return $return;
|
|
}
|
|
|
|
private function reportAndPause(string $type, int $remaining, int $reset, bool $fromErrorSituation): void
|
|
{
|
|
if ($remaining < 0) {
|
|
// no need to report:
|
|
return;
|
|
}
|
|
$resetString = self::formatTime($reset);
|
|
if ($remaining >= 5) {
|
|
Log::debug(sprintf('%s: %d requests left, and %s before the limit resets.', $type, $remaining, $resetString));
|
|
|
|
return;
|
|
}
|
|
if ($remaining >= 1) {
|
|
Log::warning(sprintf('%s: %d requests remaining, it will take %s for the limit to reset.', $type, $remaining, $resetString));
|
|
|
|
return;
|
|
}
|
|
|
|
// extra message if error.
|
|
if ($reset > 1) {
|
|
Log::error(sprintf('%s: Have zero requests left!', $type));
|
|
}
|
|
|
|
// do exit?
|
|
if (true === config('nordigen.exit_for_rate_limit') && $fromErrorSituation) {
|
|
throw new RateLimitException(sprintf('%s reached: there are %d requests left and %s before the limit resets.', $type, $remaining, $resetString));
|
|
}
|
|
|
|
// no exit. Do sleep?
|
|
if ($reset < 300 && $reset > 0) {
|
|
Log::info(sprintf('%s reached, sleep %s for reset.', $type, $resetString));
|
|
sleep($reset + 1);
|
|
|
|
return;
|
|
}
|
|
if ($reset >= 300) {
|
|
Log::error(sprintf('%s: Refuse to sleep for %s, throw exception instead.', $type, $resetString));
|
|
}
|
|
if ($reset < 0) {
|
|
Log::error(sprintf('%s: Reset time is a negative number (%d = %s), this is an issue.', $type, $reset, $resetString));
|
|
}
|
|
if ($fromErrorSituation) {
|
|
throw new RateLimitException(sprintf('%s reached: %d requests remaining, and %s before the limit resets.', $type, $remaining, $resetString));
|
|
}
|
|
}
|
|
|
|
public function getRemaining(): int
|
|
{
|
|
return $this->remaining;
|
|
}
|
|
|
|
public function getReset(): int
|
|
{
|
|
return $this->reset;
|
|
}
|
|
}
|