Skip to content

Commit

Permalink
Feature/79 1006 handling (#95)
Browse files Browse the repository at this point in the history
* Rework reconnecting/resuming

* Privatize stopping automatic heartbeats

* Add testing + refactors

* cs

* Restore meta & raw eventer getters

* Stop awaiting heartbeat at disconnect

* Add handling for undocumented 1006

* Change error messages to reflect reality more accurately
  • Loading branch information
Exanlv authored Jul 14, 2024
1 parent 8b2b12e commit 2ede7cd
Show file tree
Hide file tree
Showing 23 changed files with 543 additions and 778 deletions.
16 changes: 16 additions & 0 deletions fakes/RetrierFake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Fakes\Ragnarok\Fenrir;

use Exan\Retrier\Retrier;
use React\Promise\ExtendedPromiseInterface;

class RetrierFake extends Retrier
{
public function retry(int $attempts, callable $action): ExtendedPromiseInterface
{
return PromiseFake::get($action(1));
}
}
34 changes: 34 additions & 0 deletions fakes/WebsocketFake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Fakes\Ragnarok\Fenrir;

use Evenement\EventEmitter;
use JsonSerializable;
use Ragnarok\Fenrir\WebsocketInterface;
use React\Promise\ExtendedPromiseInterface;

class WebsocketFake extends EventEmitter implements WebsocketInterface
{
public array $openings = [];

public function open(string $url): ExtendedPromiseInterface
{
$this->openings[] = $url;

return PromiseFake::get();
}

public function close(int $code, string $reason): void
{
}

public function send(string $message, bool $useBucket = true): void
{
}

public function sendAsJson(array|JsonSerializable $item, bool $useBucket): void
{
}
}
95 changes: 95 additions & 0 deletions src/Constants/GatewayCloseCodes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace Ragnarok\Fenrir\Constants;

class GatewayCloseCodes
{
final public const LIB_INSTANTIATED_RECONNECT = 1001;
final public const LIB_INSTANTIATED_RESUME = 1003;

final public const DESCRIPTIONS = [
1001 => 'Library instantiated: Reconnect required',
1003 => 'Library instantiated: Resume required',

1006 => 'Underlying connection closed',
4000 => 'Unknown error',
4001 => 'Unknown opcode',
4002 => 'Decode error',
4003 => 'Not authenticated',
4004 => 'Authentication failed',
4005 => 'Already authenticated',
4007 => 'Invalid sequence',
4008 => 'Rate limited',
4009 => 'Session timed out',
4010 => 'Invalid shard',
4011 => 'Sharding required',
4012 => 'Invalid API version',
4013 => 'Invalid intents',
4015 => 'Disallowed intents',
];

final public const USER_ERROR = [
1001 => false,
1003 => false,

1006 => false,
4000 => true,
4001 => false,
4002 => false,
4003 => false,
4004 => true,
4005 => false,
4007 => false,
4008 => false,
4009 => false,
4010 => true,
4011 => true,
4012 => false,
4013 => true,
4014 => true,
];

final public const RECOVERABLE = [
1001 => true,
1003 => true,

1006 => true,
4000 => true,
4001 => true,
4002 => true,
4003 => true,
4004 => false,
4005 => true,
4007 => true,
4008 => true,
4009 => true,
4010 => false,
4011 => false,
4012 => false,
4013 => false,
4014 => false,
];

final public const RESUMABLE = [
1001 => false,
1003 => true,

1006 => false,
4000 => true,
4001 => true,
4002 => true,
4003 => false,
4004 => false,
4005 => true,
4007 => false,
4008 => true,
4009 => false,
4010 => false,
4011 => false,
4012 => false,
4013 => false,
4014 => false,
];
}
1 change: 1 addition & 0 deletions src/Constants/WebsocketEvents.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
class WebsocketEvents
{
final public const MESSAGE = 'MESSAGE';
final public const CLOSE = 'CLOSE';
}
2 changes: 1 addition & 1 deletion src/Discord.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ public function withGateway(
$this->token,
$intents,
$this->mapper,
new Websocket($timeout, $this->logger, [$this->token => '::token::']),
$this->logger,
$timeout
);

return $this;
Expand Down
111 changes: 91 additions & 20 deletions src/Gateway/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
namespace Ragnarok\Fenrir\Gateway;

use Exan\Eventer\Eventer;
use Exan\Retrier\Retrier;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Ragnarok\Fenrir\Bitwise\Bitwise;
use Ragnarok\Fenrir\Constants\GatewayCloseCodes;
use Ragnarok\Fenrir\Constants\MetaEvents;
use Ragnarok\Fenrir\Constants\WebsocketEvents;
use Ragnarok\Fenrir\DataMapper;
use Ragnarok\Fenrir\EventHandler;
use Ragnarok\Fenrir\Gateway\Handlers\HeartbeatAcknowledgedEvent;
use Ragnarok\Fenrir\Gateway\Handlers\IdentifyHelloEvent;
use Ragnarok\Fenrir\Gateway\Handlers\IdentifyResumeEvent;
use Ragnarok\Fenrir\Gateway\Handlers\InvalidSessionEvent;
use Ragnarok\Fenrir\Gateway\Handlers\Meta\UnacknowledgedHeartbeatEvent;
use Ragnarok\Fenrir\Gateway\Handlers\PassthroughEvent;
Expand All @@ -23,7 +26,7 @@
use Ragnarok\Fenrir\Gateway\Handlers\RequestHeartbeatEvent;
use Ragnarok\Fenrir\Gateway\Helpers\PresenceUpdateBuilder;
use Ragnarok\Fenrir\Gateway\Objects\Payload;
use Ragnarok\Fenrir\Websocket;
use Ragnarok\Fenrir\WebsocketInterface;
use Ratchet\RFC6455\Messaging\MessageInterface;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;
Expand All @@ -34,21 +37,19 @@
*/
class Connection implements ConnectionInterface
{
public const DISCORD_VERSION = 10;
public const DEFAULT_WEBSOCKET_URL = 'wss://gateway.discord.gg/';
private const QUERY_DATA = ['v' => 10];

private const QUERY_DATA = ['v' => self::DISCORD_VERSION];

private const HEARTBEAT_ACK_TIMEOUT = 2.5;

private ?int $sequence = null;

private Websocket $websocket;

private ?string $sessionId = null;
private ?string $resumeUrl = null;

public EventHandler $events;
public Eventer $raw;
public Eventer $meta;

private TimerInterface $heartbeatTimer;
private TimerInterface $unacknowledgedHeartbeatTimer;
Expand All @@ -60,15 +61,14 @@ public function __construct(
private string $token,
private Bitwise $intents,
private DataMapper $mapper,
private WebsocketInterface $websocket,
private LoggerInterface $logger = new NullLogger(),
int $timeout = 10,
private Eventer $raw = new Eventer(),
private Eventer $meta = new Eventer(),
private Retrier $retrier = new Retrier(),
) {
$this->websocket = new Websocket($timeout, $logger, [$this->token => '::token::']);
$this->events = new EventHandler($mapper);

$this->raw = new Eventer();
$this->meta = new Eventer();

$this->websocket->on(WebsocketEvents::MESSAGE, function (MessageInterface $message) {
$parsedMessage = json_decode((string) $message, depth: 1024);
if ($parsedMessage === null) {
Expand All @@ -80,6 +80,8 @@ public function __construct(
$this->raw->emit((string) $payload->op, [$this, $payload, $this->logger]);
});

$this->websocket->on(WebsocketEvents::CLOSE, $this->handleClose(...));

$this->registerEvents();
}

Expand All @@ -100,6 +102,69 @@ private function registerEvents(): void
$this->meta->register(UnacknowledgedHeartbeatEvent::class);
}

private function handleClose(int $code, string $reason): void
{
$this->stopAutomaticHeartbeats();

$description = GatewayCloseCodes::DESCRIPTIONS[$code] ?? sprintf('Unknown error code %d - %s', $code, $reason);
$isUserError = GatewayCloseCodes::USER_ERROR[$code] ?? false;
$isRecoverable = GatewayCloseCodes::RECOVERABLE[$code] ?? false;
$isResumable = GatewayCloseCodes::RECOVERABLE[$code] ?? false;

$message = $description . ' '
. (
$isUserError
? 'This is likely a userspace error.'
: 'This is likely caused by Discord or the library. If you suspect the latter, please report it on Github.'
);

$context = ['code' => $code, 'reason' => $reason];

if (!$isRecoverable) {
$this->logger->emergency($message, $context);

$this->loop->stop();
return;
}

$this->logger->error($message, $context);

if ($isResumable && $this->meetsResumeRequirements()) {
$this->resumeConnection();
return;
}

$this->sequence = null;
$this->startNewConnection();
}

private function resumeConnection(): void
{
$this->retrier->retry(3, function ($i) {
$this->logger->info(sprintf('Reconnecting and resuming session, attempt %d.', $i));

return $this->connect($this->resumeUrl)->then(function () {
$this->raw->registerOnce(IdentifyResumeEvent::class);
});
});
}

private function startNewConnection(): void
{
$this->retrier->retry(3, function ($i) {
$this->logger->info(sprintf('Forceful reconnection attempt %d.', $i));

return $this->open()->then(function () {
$this->raw->registerOnce(IdentifyHelloEvent::class);
});
});
}

public function meetsResumeRequirements(): bool
{
return !(is_null($this->resumeUrl) || is_null($this->sessionId));
}

public function getDefaultUrl(): string
{
return self::DEFAULT_WEBSOCKET_URL;
Expand All @@ -115,11 +180,6 @@ public function setSequence(int $sequence): void
$this->sequence = $sequence;
}

public function resetSequence(): void
{
$this->sequence = null;
}

public function connect(string $url): ExtendedPromiseInterface
{
$url .= '?' . http_build_query(self::QUERY_DATA);
Expand All @@ -129,6 +189,8 @@ public function connect(string $url): ExtendedPromiseInterface

public function disconnect(int $code, string $reason): void
{
$this->cancelHeartbeatAcknowledgement();

$this->websocket->close($code, $reason);
}

Expand Down Expand Up @@ -171,7 +233,14 @@ private function expectHeartbeatAcknowledgement(): void

public function acknowledgeHeartbeat(): void
{
$this->loop->cancelTimer($this->unacknowledgedHeartbeatTimer);
$this->cancelHeartbeatAcknowledgement();
}

private function cancelHeartbeatAcknowledgement(): void
{
if (isset($this->unacknowledgedHeartbeatTimer)) {
$this->loop->cancelTimer($this->unacknowledgedHeartbeatTimer);
}
}

public function startAutomaticHeartbeats(int $ms): void
Expand All @@ -180,10 +249,12 @@ public function startAutomaticHeartbeats(int $ms): void
$this->logger->debug('Started heartbeat timer', ['ms' => $ms]);
}

public function stopAutomaticHeartbeats(): void
private function stopAutomaticHeartbeats(): void
{
$this->loop->cancelTimer($this->heartbeatTimer);
$this->logger->debug('Cancelled heartbeat timer');
if (isset($this->heartbeatTimer)) {
$this->loop->cancelTimer($this->heartbeatTimer);
$this->logger->debug('Cancelled heartbeat timer');
}
}

public function getEventHandler(): EventHandler
Expand Down
5 changes: 3 additions & 2 deletions src/Gateway/ConnectionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ public function getDefaultUrl(): string;

public function getSequence(): ?int;
public function setSequence(int $sequence);
public function resetSequence(): void;

public function connect(string $url): ExtendedPromiseInterface;
public function disconnect(int $code, string $reason): void;
Expand All @@ -29,14 +28,16 @@ public function getResumeUrl(): ?string;
public function sendHeartbeat(): void;
public function acknowledgeHeartbeat(): void;
public function startAutomaticHeartbeats(int $ms): void;
public function stopAutomaticHeartbeats(): void;

public function getEventHandler(): EventHandler;
public function getRawHandler(): Eventer;
public function getMetaHandler(): Eventer;


public function identify(): void;
public function resume(): void;

public function meetsResumeRequirements(): bool;

public function updatePresence(PresenceUpdateBuilder $presenceUpdate): void;
}
Loading

0 comments on commit 2ede7cd

Please sign in to comment.