123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- <?php
- /*
- * This file is a part of the DiscordPHP-Http project.
- *
- * Copyright (c) 2021-present David Cole <david.cole1340@gmail.com>
- *
- * This file is subject to the MIT license that is bundled
- * with this source code in the LICENSE file.
- */
- namespace Discord\Http;
- use Discord\Http\Exceptions\ContentTooLongException;
- use Discord\Http\Exceptions\InvalidTokenException;
- use Discord\Http\Exceptions\NoPermissionsException;
- use Discord\Http\Exceptions\NotFoundException;
- use Discord\Http\Exceptions\RequestFailedException;
- use Exception;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Log\LoggerInterface;
- use React\EventLoop\LoopInterface;
- use React\Promise\Deferred;
- use React\Promise\ExtendedPromiseInterface;
- use RuntimeException;
- use SplQueue;
- use Throwable;
- /**
- * Discord HTTP client.
- *
- * @author David Cole <david.cole1340@gmail.com>
- */
- class Http
- {
- /**
- * DiscordPHP-Http version.
- *
- * @var string
- */
- public const VERSION = 'v9.1.8';
- /**
- * Current Discord HTTP API version.
- *
- * @var string
- */
- public const HTTP_API_VERSION = 9;
- /**
- * Discord API base URL.
- *
- * @var string
- */
- public const BASE_URL = 'https://discord.com/api/v'.self::HTTP_API_VERSION;
- /**
- * The number of concurrent requests which can
- * be executed.
- *
- * @var int
- */
- public const CONCURRENT_REQUESTS = 5;
- /**
- * Authentication token.
- *
- * @var string
- */
- private $token;
- /**
- * Logger for HTTP requests.
- *
- * @var LoggerInterface
- */
- protected $logger;
- /**
- * HTTP driver.
- *
- * @var DriverInterface
- */
- protected $driver;
- /**
- * ReactPHP event loop.
- *
- * @var LoopInterface
- */
- protected $loop;
- /**
- * Array of request buckets.
- *
- * @var Bucket[]
- */
- protected $buckets = [];
- /**
- * The current rate-limit.
- *
- * @var RateLimit
- */
- protected $rateLimit;
- /**
- * Timer that resets the current global rate-limit.
- *
- * @var TimerInterface
- */
- protected $rateLimitReset;
- /**
- * Request queue to prevent API
- * overload.
- *
- * @var SplQueue
- */
- protected $queue;
- /**
- * Number of requests that are waiting for a response.
- *
- * @var int
- */
- protected $waiting = 0;
- /**
- * Http wrapper constructor.
- *
- * @param string $token
- * @param LoopInterface $loop
- * @param DriverInterface|null $driver
- */
- public function __construct(string $token, LoopInterface $loop, LoggerInterface $logger, DriverInterface $driver = null)
- {
- $this->token = $token;
- $this->loop = $loop;
- $this->logger = $logger;
- $this->driver = $driver;
- $this->queue = new SplQueue;
- }
- /**
- * Sets the driver of the HTTP client.
- *
- * @param DriverInterface $driver
- */
- public function setDriver(DriverInterface $driver): void
- {
- $this->driver = $driver;
- }
- /**
- * Runs a GET request.
- *
- * @param string|Endpoint $url
- * @param mixed $content
- * @param array $headers
- *
- * @return ExtendedPromiseInterface
- */
- public function get($url, $content = null, array $headers = []): ExtendedPromiseInterface
- {
- if (! ($url instanceof Endpoint)) {
- $url = Endpoint::bind($url);
- }
- return $this->queueRequest('get', $url, $content, $headers);
- }
- /**
- * Runs a POST request.
- *
- * @param string|Endpoint $url
- * @param mixed $content
- * @param array $headers
- *
- * @return ExtendedPromiseInterface
- */
- public function post($url, $content = null, array $headers = []): ExtendedPromiseInterface
- {
- if (! ($url instanceof Endpoint)) {
- $url = Endpoint::bind($url);
- }
- return $this->queueRequest('post', $url, $content, $headers);
- }
- /**
- * Runs a PUT request.
- *
- * @param string|Endpoint $url
- * @param mixed $content
- * @param array $headers
- *
- * @return ExtendedPromiseInterface
- */
- public function put($url, $content = null, array $headers = []): ExtendedPromiseInterface
- {
- if (! ($url instanceof Endpoint)) {
- $url = Endpoint::bind($url);
- }
- return $this->queueRequest('put', $url, $content, $headers);
- }
- /**
- * Runs a PATCH request.
- *
- * @param string|Endpoint $url
- * @param mixed $content
- * @param array $headers
- *
- * @return ExtendedPromiseInterface
- */
- public function patch($url, $content = null, array $headers = []): ExtendedPromiseInterface
- {
- if (! ($url instanceof Endpoint)) {
- $url = Endpoint::bind($url);
- }
- return $this->queueRequest('patch', $url, $content, $headers);
- }
- /**
- * Runs a DELETE request.
- *
- * @param string|Endpoint $url
- * @param mixed $content
- * @param array $headers
- *
- * @return ExtendedPromiseInterface
- */
- public function delete($url, $content = null, array $headers = []): ExtendedPromiseInterface
- {
- if (! ($url instanceof Endpoint)) {
- $url = Endpoint::bind($url);
- }
- return $this->queueRequest('delete', $url, $content, $headers);
- }
- /**
- * Builds and queues a request.
- *
- * @param string $method
- * @param Endpoint $url
- * @param mixed $content
- * @param array $headers
- *
- * @return ExtendedPromiseInterface
- */
- public function queueRequest(string $method, Endpoint $url, $content, array $headers = []): ExtendedPromiseInterface
- {
- $deferred = new Deferred();
- if (is_null($this->driver)) {
- $deferred->reject(new \Exception('HTTP driver is missing.'));
- return $deferred->promise();
- }
- $headers = array_merge($headers, [
- 'User-Agent' => $this->getUserAgent(),
- 'Authorization' => $this->token,
- 'X-Ratelimit-Precision' => 'millisecond',
- ]);
- $baseHeaders = [
- 'User-Agent' => $this->getUserAgent(),
- 'Authorization' => $this->token,
- 'X-Ratelimit-Precision' => 'millisecond',
- ];
- // If there is content and Content-Type is not set,
- // assume it is JSON.
- if (! is_null($content) && ! isset($headers['Content-Type'])) {
- $content = json_encode($content);
- $baseHeaders['Content-Type'] = 'application/json';
- $baseHeaders['Content-Length'] = strlen($content);
- }
- $headers = array_merge($baseHeaders, $headers);
- $request = new Request($deferred, $method, $url, $content ?? '', $headers);
- $this->sortIntoBucket($request);
- return $deferred->promise();
- }
- /**
- * Executes a request.
- *
- * @param Request $request
- * @param Deferred $deferred
- *
- * @return ExtendedPromiseInterface
- */
- protected function executeRequest(Request $request, Deferred $deferred = null): ExtendedPromiseInterface
- {
- if ($deferred === null) {
- $deferred = new Deferred();
- }
- if ($this->rateLimit) {
- $deferred->reject($this->rateLimit);
- return $deferred->promise();
- }
- $this->driver->runRequest($request)->done(function (ResponseInterface $response) use ($request, $deferred) {
- $data = json_decode((string) $response->getBody());
- $statusCode = $response->getStatusCode();
- // Discord Rate-limit
- if ($statusCode == 429) {
- if (! isset($data->global)) {
- if ($response->hasHeader('X-RateLimit-Global')) {
- $data->global = $response->getHeader('X-RateLimit-Global')[0] == 'true';
- } else {
- // Some other 429
- $this->logger->error($request. ' does not contain global rate-limit value');
- $rateLimitError = new RuntimeException('No rate limit global response', $statusCode);
- $deferred->reject($rateLimitError);
- $request->getDeferred()->reject($rateLimitError);
- return;
- }
- }
- if (! isset($data->retry_after)) {
- if ($response->hasHeader('Retry-After')) {
- $data->retry_after = $response->getHeader('Retry-After')[0];
- } else {
- // Some other 429
- $this->logger->error($request. ' does not contain retry after rate-limit value');
- $rateLimitError = new RuntimeException('No rate limit retry after response', $statusCode);
- $deferred->reject($rateLimitError);
- $request->getDeferred()->reject($rateLimitError);
- return;
- }
- }
- $rateLimit = new RateLimit($data->global, $data->retry_after);
- $this->logger->warning($request.' hit rate-limit: '.$rateLimit);
- if ($rateLimit->isGlobal() && ! $this->rateLimit) {
- $this->rateLimit = $rateLimit;
- $this->rateLimitReset = $this->loop->addTimer($rateLimit->getRetryAfter(), function () {
- $this->rateLimit = null;
- $this->rateLimitReset = null;
- $this->logger->info('global rate-limit reset');
- // Loop through all buckets and check for requests
- foreach ($this->buckets as $bucket) {
- $bucket->checkQueue();
- }
- });
- }
- $deferred->reject($rateLimit->isGlobal() ? $this->rateLimit : $rateLimit);
- }
- // Bad Gateway
- // Cloudflare SSL Handshake error
- // Push to the back of the bucket to be retried.
- elseif ($statusCode == 502 || $statusCode == 525) {
- $this->logger->warning($request.' 502/525 - retrying request');
- $this->executeRequest($request, $deferred);
- }
- // Any other unsuccessful status codes
- elseif ($statusCode < 200 || $statusCode >= 300) {
- $error = $this->handleError($response);
- $this->logger->warning($request.' failed: '.$error);
- $deferred->reject($error);
- $request->getDeferred()->reject($error);
- }
- // All is well
- else {
- $this->logger->debug($request.' successful');
- $deferred->resolve($response);
- $request->getDeferred()->resolve($data);
- }
- }, function (Exception $e) use ($request, $deferred) {
- $this->logger->warning($request.' failed: '.$e->getMessage());
- $deferred->reject($e);
- $request->getDeferred()->reject($e);
- });
- return $deferred->promise();
- }
- /**
- * Sorts a request into a bucket.
- *
- * @param Request $request
- */
- protected function sortIntoBucket(Request $request): void
- {
- $bucket = $this->getBucket($request->getBucketID());
- $bucket->enqueue($request);
- }
- /**
- * Gets a bucket.
- *
- * @param string $key
- *
- * @return Bucket
- */
- protected function getBucket(string $key): Bucket
- {
- if (! isset($this->buckets[$key])) {
- $bucket = new Bucket($key, $this->loop, $this->logger, function (Request $request) {
- $deferred = new Deferred();
- $this->queue->enqueue([$request, $deferred]);
- $this->checkQueue();
- return $deferred->promise();
- });
- $this->buckets[$key] = $bucket;
- }
- return $this->buckets[$key];
- }
- /**
- * Checks the request queue to see if more requests can be
- * sent out.
- */
- protected function checkQueue(): void
- {
- if ($this->waiting >= static::CONCURRENT_REQUESTS || $this->queue->isEmpty()) {
- $this->logger->debug('http not checking', ['waiting' => $this->waiting, 'empty' => $this->queue->isEmpty()]);
- return;
- }
- /**
- * @var Request $request
- * @var Deferred $deferred
- */
- [$request, $deferred] = $this->queue->dequeue();
- ++$this->waiting;
- $this->executeRequest($request)->then(function ($result) use ($deferred) {
- --$this->waiting;
- $this->checkQueue();
- $deferred->resolve($result);
- }, function ($e) use ($deferred) {
- --$this->waiting;
- $this->checkQueue();
- $deferred->reject($e);
- });
- }
- /**
- * Returns an exception based on the request.
- *
- * @param ResponseInterface $response
- *
- * @return Throwable
- */
- public function handleError(ResponseInterface $response): Throwable
- {
- $reason = $response->getReasonPhrase().' - ';
- $errorBody = (string) $response->getBody();
- $errorCode = $response->getStatusCode();
- // attempt to prettyify the response content
- if (($content = json_decode($errorBody)) !== null) {
- if (isset($content->code)) {
- $errorCode = $content->code;
- }
- $reason .= json_encode($content, JSON_PRETTY_PRINT);
- } else {
- $reason .= $errorBody;
- }
- switch ($response->getStatusCode()) {
- case 401:
- return new InvalidTokenException($reason, $errorCode);
- case 403:
- return new NoPermissionsException($reason, $errorCode);
- case 404:
- return new NotFoundException($reason, $errorCode);
- case 500:
- if (strpos(strtolower($errorBody), 'longer than 2000 characters') !== false ||
- strpos(strtolower($errorBody), 'string value is too long') !== false) {
- // Response was longer than 2000 characters and was blocked by Discord.
- return new ContentTooLongException('Response was more than 2000 characters. Use another method to get this data.', $errorCode);
- }
- default:
- return new RequestFailedException($reason, $errorCode);
- }
- }
- /**
- * Returns the User-Agent of the HTTP client.
- *
- * @return string
- */
- public function getUserAgent(): string
- {
- return 'DiscordBot (https://github.com/discord-php/DiscordPHP-HTTP, '.self::VERSION.')';
- }
- }
|