Http.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. <?php
  2. /*
  3. * This file is a part of the DiscordPHP-Http project.
  4. *
  5. * Copyright (c) 2021-present David Cole <david.cole1340@gmail.com>
  6. *
  7. * This file is subject to the MIT license that is bundled
  8. * with this source code in the LICENSE file.
  9. */
  10. namespace Discord\Http;
  11. use Discord\Http\Exceptions\ContentTooLongException;
  12. use Discord\Http\Exceptions\InvalidTokenException;
  13. use Discord\Http\Exceptions\NoPermissionsException;
  14. use Discord\Http\Exceptions\NotFoundException;
  15. use Discord\Http\Exceptions\RequestFailedException;
  16. use Exception;
  17. use Psr\Http\Message\ResponseInterface;
  18. use Psr\Log\LoggerInterface;
  19. use React\EventLoop\LoopInterface;
  20. use React\Promise\Deferred;
  21. use React\Promise\ExtendedPromiseInterface;
  22. use RuntimeException;
  23. use SplQueue;
  24. use Throwable;
  25. /**
  26. * Discord HTTP client.
  27. *
  28. * @author David Cole <david.cole1340@gmail.com>
  29. */
  30. class Http
  31. {
  32. /**
  33. * DiscordPHP-Http version.
  34. *
  35. * @var string
  36. */
  37. public const VERSION = 'v9.1.8';
  38. /**
  39. * Current Discord HTTP API version.
  40. *
  41. * @var string
  42. */
  43. public const HTTP_API_VERSION = 9;
  44. /**
  45. * Discord API base URL.
  46. *
  47. * @var string
  48. */
  49. public const BASE_URL = 'https://discord.com/api/v'.self::HTTP_API_VERSION;
  50. /**
  51. * The number of concurrent requests which can
  52. * be executed.
  53. *
  54. * @var int
  55. */
  56. public const CONCURRENT_REQUESTS = 5;
  57. /**
  58. * Authentication token.
  59. *
  60. * @var string
  61. */
  62. private $token;
  63. /**
  64. * Logger for HTTP requests.
  65. *
  66. * @var LoggerInterface
  67. */
  68. protected $logger;
  69. /**
  70. * HTTP driver.
  71. *
  72. * @var DriverInterface
  73. */
  74. protected $driver;
  75. /**
  76. * ReactPHP event loop.
  77. *
  78. * @var LoopInterface
  79. */
  80. protected $loop;
  81. /**
  82. * Array of request buckets.
  83. *
  84. * @var Bucket[]
  85. */
  86. protected $buckets = [];
  87. /**
  88. * The current rate-limit.
  89. *
  90. * @var RateLimit
  91. */
  92. protected $rateLimit;
  93. /**
  94. * Timer that resets the current global rate-limit.
  95. *
  96. * @var TimerInterface
  97. */
  98. protected $rateLimitReset;
  99. /**
  100. * Request queue to prevent API
  101. * overload.
  102. *
  103. * @var SplQueue
  104. */
  105. protected $queue;
  106. /**
  107. * Number of requests that are waiting for a response.
  108. *
  109. * @var int
  110. */
  111. protected $waiting = 0;
  112. /**
  113. * Http wrapper constructor.
  114. *
  115. * @param string $token
  116. * @param LoopInterface $loop
  117. * @param DriverInterface|null $driver
  118. */
  119. public function __construct(string $token, LoopInterface $loop, LoggerInterface $logger, DriverInterface $driver = null)
  120. {
  121. $this->token = $token;
  122. $this->loop = $loop;
  123. $this->logger = $logger;
  124. $this->driver = $driver;
  125. $this->queue = new SplQueue;
  126. }
  127. /**
  128. * Sets the driver of the HTTP client.
  129. *
  130. * @param DriverInterface $driver
  131. */
  132. public function setDriver(DriverInterface $driver): void
  133. {
  134. $this->driver = $driver;
  135. }
  136. /**
  137. * Runs a GET request.
  138. *
  139. * @param string|Endpoint $url
  140. * @param mixed $content
  141. * @param array $headers
  142. *
  143. * @return ExtendedPromiseInterface
  144. */
  145. public function get($url, $content = null, array $headers = []): ExtendedPromiseInterface
  146. {
  147. if (! ($url instanceof Endpoint)) {
  148. $url = Endpoint::bind($url);
  149. }
  150. return $this->queueRequest('get', $url, $content, $headers);
  151. }
  152. /**
  153. * Runs a POST request.
  154. *
  155. * @param string|Endpoint $url
  156. * @param mixed $content
  157. * @param array $headers
  158. *
  159. * @return ExtendedPromiseInterface
  160. */
  161. public function post($url, $content = null, array $headers = []): ExtendedPromiseInterface
  162. {
  163. if (! ($url instanceof Endpoint)) {
  164. $url = Endpoint::bind($url);
  165. }
  166. return $this->queueRequest('post', $url, $content, $headers);
  167. }
  168. /**
  169. * Runs a PUT request.
  170. *
  171. * @param string|Endpoint $url
  172. * @param mixed $content
  173. * @param array $headers
  174. *
  175. * @return ExtendedPromiseInterface
  176. */
  177. public function put($url, $content = null, array $headers = []): ExtendedPromiseInterface
  178. {
  179. if (! ($url instanceof Endpoint)) {
  180. $url = Endpoint::bind($url);
  181. }
  182. return $this->queueRequest('put', $url, $content, $headers);
  183. }
  184. /**
  185. * Runs a PATCH request.
  186. *
  187. * @param string|Endpoint $url
  188. * @param mixed $content
  189. * @param array $headers
  190. *
  191. * @return ExtendedPromiseInterface
  192. */
  193. public function patch($url, $content = null, array $headers = []): ExtendedPromiseInterface
  194. {
  195. if (! ($url instanceof Endpoint)) {
  196. $url = Endpoint::bind($url);
  197. }
  198. return $this->queueRequest('patch', $url, $content, $headers);
  199. }
  200. /**
  201. * Runs a DELETE request.
  202. *
  203. * @param string|Endpoint $url
  204. * @param mixed $content
  205. * @param array $headers
  206. *
  207. * @return ExtendedPromiseInterface
  208. */
  209. public function delete($url, $content = null, array $headers = []): ExtendedPromiseInterface
  210. {
  211. if (! ($url instanceof Endpoint)) {
  212. $url = Endpoint::bind($url);
  213. }
  214. return $this->queueRequest('delete', $url, $content, $headers);
  215. }
  216. /**
  217. * Builds and queues a request.
  218. *
  219. * @param string $method
  220. * @param Endpoint $url
  221. * @param mixed $content
  222. * @param array $headers
  223. *
  224. * @return ExtendedPromiseInterface
  225. */
  226. public function queueRequest(string $method, Endpoint $url, $content, array $headers = []): ExtendedPromiseInterface
  227. {
  228. $deferred = new Deferred();
  229. if (is_null($this->driver)) {
  230. $deferred->reject(new \Exception('HTTP driver is missing.'));
  231. return $deferred->promise();
  232. }
  233. $headers = array_merge($headers, [
  234. 'User-Agent' => $this->getUserAgent(),
  235. 'Authorization' => $this->token,
  236. 'X-Ratelimit-Precision' => 'millisecond',
  237. ]);
  238. $baseHeaders = [
  239. 'User-Agent' => $this->getUserAgent(),
  240. 'Authorization' => $this->token,
  241. 'X-Ratelimit-Precision' => 'millisecond',
  242. ];
  243. // If there is content and Content-Type is not set,
  244. // assume it is JSON.
  245. if (! is_null($content) && ! isset($headers['Content-Type'])) {
  246. $content = json_encode($content);
  247. $baseHeaders['Content-Type'] = 'application/json';
  248. $baseHeaders['Content-Length'] = strlen($content);
  249. }
  250. $headers = array_merge($baseHeaders, $headers);
  251. $request = new Request($deferred, $method, $url, $content ?? '', $headers);
  252. $this->sortIntoBucket($request);
  253. return $deferred->promise();
  254. }
  255. /**
  256. * Executes a request.
  257. *
  258. * @param Request $request
  259. * @param Deferred $deferred
  260. *
  261. * @return ExtendedPromiseInterface
  262. */
  263. protected function executeRequest(Request $request, Deferred $deferred = null): ExtendedPromiseInterface
  264. {
  265. if ($deferred === null) {
  266. $deferred = new Deferred();
  267. }
  268. if ($this->rateLimit) {
  269. $deferred->reject($this->rateLimit);
  270. return $deferred->promise();
  271. }
  272. $this->driver->runRequest($request)->done(function (ResponseInterface $response) use ($request, $deferred) {
  273. $data = json_decode((string) $response->getBody());
  274. $statusCode = $response->getStatusCode();
  275. // Discord Rate-limit
  276. if ($statusCode == 429) {
  277. if (! isset($data->global)) {
  278. if ($response->hasHeader('X-RateLimit-Global')) {
  279. $data->global = $response->getHeader('X-RateLimit-Global')[0] == 'true';
  280. } else {
  281. // Some other 429
  282. $this->logger->error($request. ' does not contain global rate-limit value');
  283. $rateLimitError = new RuntimeException('No rate limit global response', $statusCode);
  284. $deferred->reject($rateLimitError);
  285. $request->getDeferred()->reject($rateLimitError);
  286. return;
  287. }
  288. }
  289. if (! isset($data->retry_after)) {
  290. if ($response->hasHeader('Retry-After')) {
  291. $data->retry_after = $response->getHeader('Retry-After')[0];
  292. } else {
  293. // Some other 429
  294. $this->logger->error($request. ' does not contain retry after rate-limit value');
  295. $rateLimitError = new RuntimeException('No rate limit retry after response', $statusCode);
  296. $deferred->reject($rateLimitError);
  297. $request->getDeferred()->reject($rateLimitError);
  298. return;
  299. }
  300. }
  301. $rateLimit = new RateLimit($data->global, $data->retry_after);
  302. $this->logger->warning($request.' hit rate-limit: '.$rateLimit);
  303. if ($rateLimit->isGlobal() && ! $this->rateLimit) {
  304. $this->rateLimit = $rateLimit;
  305. $this->rateLimitReset = $this->loop->addTimer($rateLimit->getRetryAfter(), function () {
  306. $this->rateLimit = null;
  307. $this->rateLimitReset = null;
  308. $this->logger->info('global rate-limit reset');
  309. // Loop through all buckets and check for requests
  310. foreach ($this->buckets as $bucket) {
  311. $bucket->checkQueue();
  312. }
  313. });
  314. }
  315. $deferred->reject($rateLimit->isGlobal() ? $this->rateLimit : $rateLimit);
  316. }
  317. // Bad Gateway
  318. // Cloudflare SSL Handshake error
  319. // Push to the back of the bucket to be retried.
  320. elseif ($statusCode == 502 || $statusCode == 525) {
  321. $this->logger->warning($request.' 502/525 - retrying request');
  322. $this->executeRequest($request, $deferred);
  323. }
  324. // Any other unsuccessful status codes
  325. elseif ($statusCode < 200 || $statusCode >= 300) {
  326. $error = $this->handleError($response);
  327. $this->logger->warning($request.' failed: '.$error);
  328. $deferred->reject($error);
  329. $request->getDeferred()->reject($error);
  330. }
  331. // All is well
  332. else {
  333. $this->logger->debug($request.' successful');
  334. $deferred->resolve($response);
  335. $request->getDeferred()->resolve($data);
  336. }
  337. }, function (Exception $e) use ($request, $deferred) {
  338. $this->logger->warning($request.' failed: '.$e->getMessage());
  339. $deferred->reject($e);
  340. $request->getDeferred()->reject($e);
  341. });
  342. return $deferred->promise();
  343. }
  344. /**
  345. * Sorts a request into a bucket.
  346. *
  347. * @param Request $request
  348. */
  349. protected function sortIntoBucket(Request $request): void
  350. {
  351. $bucket = $this->getBucket($request->getBucketID());
  352. $bucket->enqueue($request);
  353. }
  354. /**
  355. * Gets a bucket.
  356. *
  357. * @param string $key
  358. *
  359. * @return Bucket
  360. */
  361. protected function getBucket(string $key): Bucket
  362. {
  363. if (! isset($this->buckets[$key])) {
  364. $bucket = new Bucket($key, $this->loop, $this->logger, function (Request $request) {
  365. $deferred = new Deferred();
  366. $this->queue->enqueue([$request, $deferred]);
  367. $this->checkQueue();
  368. return $deferred->promise();
  369. });
  370. $this->buckets[$key] = $bucket;
  371. }
  372. return $this->buckets[$key];
  373. }
  374. /**
  375. * Checks the request queue to see if more requests can be
  376. * sent out.
  377. */
  378. protected function checkQueue(): void
  379. {
  380. if ($this->waiting >= static::CONCURRENT_REQUESTS || $this->queue->isEmpty()) {
  381. $this->logger->debug('http not checking', ['waiting' => $this->waiting, 'empty' => $this->queue->isEmpty()]);
  382. return;
  383. }
  384. /**
  385. * @var Request $request
  386. * @var Deferred $deferred
  387. */
  388. [$request, $deferred] = $this->queue->dequeue();
  389. ++$this->waiting;
  390. $this->executeRequest($request)->then(function ($result) use ($deferred) {
  391. --$this->waiting;
  392. $this->checkQueue();
  393. $deferred->resolve($result);
  394. }, function ($e) use ($deferred) {
  395. --$this->waiting;
  396. $this->checkQueue();
  397. $deferred->reject($e);
  398. });
  399. }
  400. /**
  401. * Returns an exception based on the request.
  402. *
  403. * @param ResponseInterface $response
  404. *
  405. * @return Throwable
  406. */
  407. public function handleError(ResponseInterface $response): Throwable
  408. {
  409. $reason = $response->getReasonPhrase().' - ';
  410. $errorBody = (string) $response->getBody();
  411. $errorCode = $response->getStatusCode();
  412. // attempt to prettyify the response content
  413. if (($content = json_decode($errorBody)) !== null) {
  414. if (isset($content->code)) {
  415. $errorCode = $content->code;
  416. }
  417. $reason .= json_encode($content, JSON_PRETTY_PRINT);
  418. } else {
  419. $reason .= $errorBody;
  420. }
  421. switch ($response->getStatusCode()) {
  422. case 401:
  423. return new InvalidTokenException($reason, $errorCode);
  424. case 403:
  425. return new NoPermissionsException($reason, $errorCode);
  426. case 404:
  427. return new NotFoundException($reason, $errorCode);
  428. case 500:
  429. if (strpos(strtolower($errorBody), 'longer than 2000 characters') !== false ||
  430. strpos(strtolower($errorBody), 'string value is too long') !== false) {
  431. // Response was longer than 2000 characters and was blocked by Discord.
  432. return new ContentTooLongException('Response was more than 2000 characters. Use another method to get this data.', $errorCode);
  433. }
  434. default:
  435. return new RequestFailedException($reason, $errorCode);
  436. }
  437. }
  438. /**
  439. * Returns the User-Agent of the HTTP client.
  440. *
  441. * @return string
  442. */
  443. public function getUserAgent(): string
  444. {
  445. return 'DiscordBot (https://github.com/discord-php/DiscordPHP-HTTP, '.self::VERSION.')';
  446. }
  447. }