glyde/gateway
The Discord gateway protocol as a pure state machine.
No sockets, no timers, no clock, no randomness, no logging, no processes.
step is a total function of (Shard, Input) returning the Outputs the
caller must perform, whose results come back as the next Inputs.
The adapter contract:
- Perform every output of a batch, in the order given, before feeding the next input.
ArmTimerreplaces any timer already armed under the sameTimer;CancelTimeron an unarmed timer is a no-op; deliverFiredwith the exactStampyou were given.- Store the
ConnfromOpenand hand that value back on every socket input. Never synthesise one. - Commit the new
Shardbefore performing the batch, and queue any input produced during performance rather than re-enteringstep. - A send that fails is reported as
Closed(conn, None). Never throw out of an effect fold. - Seed the shard from something below 2^31 and different per shard.
Types
Heartbeat bookkeeping. Exists from HELLO onwards, so it lives in the phase.
pub type Beat {
Beat(interval_ms: Int, unacked: Int, quiet: Bool)
}
Constructors
-
Beat(interval_ms: Int, unacked: Int, quiet: Bool)Arguments
- interval_ms
-
From HELLO. Inside [1000, 600_000], because the only thing that reaches this field is a
frame.HeartbeatInterval, which cannot hold anything else. - unacked
-
Heartbeats sent since the last op 11. Reset by op 11 only: counting dispatch traffic stops zombie detection on the busiest connections.
- quiet
-
True when no frame of any kind has arrived since the last heartbeat. Reported in
Notice.Zombieand never consulted by the verdict.
The outbound command window: Discord allows 120 events per 60 seconds per connection and answers a flood with close 4008. Fresh on every reconnect.
pub type Budget {
Budget(spent: Int, capacity: Int)
}
Constructors
-
Budget(spent: Int, capacity: Int)
pub type Compression {
NoCompression
ZlibStream
}
Constructors
-
NoCompressionOne WebSocket text frame is one gateway payload. The only mode glyde’s own adapters implement.
-
ZlibStreamcompress=zlib-stream. Binary frames, payloads terminated by00 00 FF FF. The adapter owns the inflate context; the core frames.
Everything that does not change over a shard’s life. config/2 defaults
all but the token and intents, so the long tail is a record update:
let assert Ok(sharding) = gateway.sharding(index: 3, count: 16)
Config(..gateway.config(token:, intents:), sharding:)
pub type Config {
Config(
token: String,
intents: intents.Intents,
sharding: frame.Sharding,
properties: frame.Properties,
host: Host,
api_version: Int,
compression: Compression,
presence: option.Option(presence.Presence),
large_threshold: frame.LargeThreshold,
tuning: Tuning,
)
}
Constructors
-
Config( token: String, intents: intents.Intents, sharding: frame.Sharding, properties: frame.Properties, host: Host, api_version: Int, compression: Compression, presence: option.Option(presence.Presence), large_threshold: frame.LargeThreshold, tuning: Tuning, )Arguments
- properties
-
Sent in IDENTIFY. Analytics only; Discord does not act on it.
- host
-
The host to dial when there is no resume host.
- api_version
-
Gateway API version. A missing
vroutes to version 6, where intents are optional, so this is always sent. - compression
-
Transport compression.
NoCompressionunless the adapter you are using documents support; seeOutput.Inflate. - presence
-
Presence to send in IDENTIFY.
Nonemeans online with no activity. - large_threshold
-
Guild size above which Discord sends an offline member list. 50 is Discord’s default, and
large_thresholdclamps to the legal range.
Identifies one connection attempt. Socket inputs must carry it back, so the echo of a close we asked for is dropped, not read as a fresh failure.
pub type Conn {
Conn(Int)
}
Constructors
-
Conn(Int)
The two ways a connection’s bytes stop being trustworthy. Its own type and
not a Notice, because only these two tear the connection down.
pub type Corruption {
BufferFull(bytes: Int)
InflateBroke(why: InflateFailure)
}
Constructors
-
BufferFull(bytes: Int) -
InflateBroke(why: InflateFailure)
Why a WebSocket upgrade never came up. Discord answers a dead token with 401 and a bot dialling too often with 429, and the two need different answers, so the status travels rather than being flattened into prose.
pub type DialFailure {
Refused(status: Int, detail: String)
Unreachable(detail: String)
}
Constructors
-
Refused(status: Int, detail: String)The server answered the upgrade with a status other than 101.
-
Unreachable(detail: String)No answer to read a status from: DNS, TLS, a refused port, a socket that died mid-upgrade, or a transport that cannot see the response.
pub type Event {
Ready(
session_id: String,
user: id.Id(id.User),
resume_host: Host,
guild_count: Int,
)
Resumed
Dispatch(name: String, seq: Int, data: dynamic.Dynamic)
Reconnecting(in_ms: Int, resuming: Bool, why: Why)
Halted(reason: Halt)
}
Constructors
-
Arguments
- guild_count
-
How many guilds READY listed. The guilds themselves arrive as GUILD_CREATE dispatches after it.
-
Resumed -
Dispatch(name: String, seq: Int, data: dynamic.Dynamic)A dispatch, undecoded;
glyde/eventis the optional typed layer. READY and RESUMED arrive here too, afterReadyandResumed. -
Reconnecting(in_ms: Int, resuming: Bool, why: Why)The connection is gone and a reconnect is armed.
-
Halted(reason: Halt)Terminal.
pub type Halt {
Fatal(reason: close.Reason, shard: frame.Sharding)
Requested
IdentifyTooLarge(bytes: Int)
NoProgress(attempts: Int)
}
Constructors
-
Fatal(reason: close.Reason, shard: frame.Sharding)Discord refused for a reason no reconnect can fix.
shardis what this shard identified with, which 4010 and 4011 need to be actionable. -
Requested -
IdentifyTooLarge(bytes: Int)The IDENTIFY this config builds is
byteslong, over Discord’s 4096-byte payload limit. A presence or a token to shrink, not a connection to retry. -
NoProgress(attempts: Int)attemptsconnections in a row ended before READY, and the next one would identify. The shard stopped rather than keep spending from the identify budget on a failing dial. A resume loop never trips this.
Somewhere to dial: a bare host, no scheme and no path. Every URL Discord
hands out has a scheme, so the two are different types and a wss://…
cannot reach a slot that dials.
pub type Host =
@internal Host
pub type Ignored {
StaleConn(Conn)
StaleTimer(Timer)
StaleInflate(Stamp)
OutOfPhase
Terminal
}
Constructors
-
StaleConn(Conn)From a connection this shard has abandoned.
-
StaleTimer(Timer)From an arming this shard has superseded.
-
StaleInflate(Stamp)An inflate answer whose stamp is not the outstanding request: an adapter answering one twice, or echoing a stamp we never issued. A reset clears the request, so an answer it superseded lands as
OutOfPhase. -
OutOfPhaseMeaningful input, wrong phase: a duplicate HELLO, a
Startwhile already running, an inflate answer nobody asked for. -
TerminalThe shard has halted. Nothing will change that.
Reassembly for a compressed transport.
pub type Inbound {
Inbound(
buffer: BitArray,
inflating: option.Option(Stamp),
pending: List(BitArray),
)
}
Constructors
-
Inbound( buffer: BitArray, inflating: option.Option(Stamp), pending: List(BitArray), )Arguments
- buffer
-
Bytes on this connection that are not yet a complete payload. Only
intakewrites it: a reset replaces the whole record instead. - inflating
-
The stamp of the outstanding inflate request, if any. At most one is in flight: two completing out of order would apply sequences backwards.
- pending
-
Complete payloads that arrived while an inflate was outstanding, oldest first. Holding them in
bufferwould glue two payloads into one.
Why an inflate could not be answered with a payload. Neither is survivable inside a connection: the next payload would be read against a codec context that no longer matches the stream.
pub type InflateFailure {
ContextDesynchronised(detail: String)
NoInflateContext
}
Constructors
-
ContextDesynchronised(detail: String)The inflate itself failed.
detailis the host’s own message, for a log and nothing else. -
NoInflateContextThe host has no inflate context to answer with. A transport that negotiated compression with nothing to inflate with.
Everything that can happen to a shard. Every field is plain data, so an input log is a data file and replay is a fold.
pub type Input {
Start
Opened(conn: Conn)
OpenFailed(conn: Conn, failure: DialFailure)
Frame(conn: Conn, text: String)
Bytes(conn: Conn, data: BitArray)
Inflated(
conn: Conn,
stamp: Stamp,
result: Result(String, InflateFailure),
)
Closed(conn: Conn, code: option.Option(Int))
Fired(timer: Timer, stamp: Stamp)
IdentifySlotGranted(conn: Conn)
Command(command: command.Command)
Stop
}
Constructors
-
StartBegin. A no-op unless the phase is
Idle. -
Opened(conn: Conn)The transport established the WebSocket, upgrade complete.
-
OpenFailed(conn: Conn, failure: DialFailure)The transport could not establish it. Also the right input for a socket that died before the upgrade finished.
-
Frame(conn: Conn, text: String)A text frame arrived, carrying exactly one complete gateway payload. Under
NoCompressionthis is the only frame input. -
Bytes(conn: Conn, data: BitArray)One whole binary WebSocket message under a compressed transport, with any continuation frames already joined into it. It is part of a payload or the end of one, never several: a payload ends on a message boundary, and the core buffers until one does.
-
Inflated( conn: Conn, stamp: Stamp, result: Result(String, InflateFailure), )The answer to an
Output.Inflate. Either failure ends the connection. -
Closed(conn: Conn, code: option.Option(Int))The socket closed.
Nonemeans the transport died with no close frame. A transport that synthesises 1006 for that case is saying the same thing. -
A timer fired.
stampmust be the value from theArmTimerthat armed it; a mismatch means the firing is stale and is dropped. -
IdentifySlotGranted(conn: Conn)The identify queue granted this connection a slot.
-
Command(command: command.Command)The host wants to send a gateway command.
-
StopClose cleanly and stop. Terminal.
pub type LargeThreshold =
frame.LargeThreshold
pub type Notice {
Ignored(what: Ignored)
Zombie(unacked: Int, quiet: Bool)
AwaitingIdentifySlot
InvalidSession(resumable: Bool)
SpuriousAck
UndecodableFrame(reason: Undecodable)
ResumeHostRejected(host: Host)
CommandQueued(depth: Int)
CommandDropped(depth: Int)
PayloadTooLarge(bytes: Int)
BufferOverflow(bytes: Int)
InflateFailed(why: InflateFailure)
}
Constructors
-
Ignored(what: Ignored)An input that did not apply. Typed, so the negative paths are assertable.
-
Zombie(unacked: Int, quiet: Bool)The zombie detector fired.
unackedis how many heartbeats went unacknowledged;quietis whether anything at all was arriving. -
AwaitingIdentifySlotSitting in
Queued. Atmax_concurrency: 1a large fleet spends real time here. -
InvalidSession(resumable: Bool)Discord sent op 9.
resumableis thedfield. -
SpuriousAckAn op 11 with no heartbeat outstanding: the gateway is acknowledging beats we did not send.
-
UndecodableFrame(reason: Undecodable)A frame we could not decode, or an opcode we do not model. The connection is fine; the frame is not.
-
ResumeHostRejected(host: Host)Repeated dial failures against the session’s resume host; falling back to the configured one. A bad resume host must not wedge a shard.
-
CommandQueued(depth: Int)depthis the size of the pending queue after the change. -
CommandDropped(depth: Int) -
PayloadTooLarge(bytes: Int)An outbound payload was over Discord’s 4096-byte limit and was not sent. Sending it earns close 4002, which reads as “we sent malformed JSON”.
-
BufferOverflow(bytes: Int)The reassembly buffer went past
max_payload_byteswith no complete payload in it. -
InflateFailed(why: InflateFailure)
What the caller must do. Perform them in order and perform all of them.
pub type Output {
Open(conn: Conn, host: Host, path: String)
Send(frame: frame.Outbound)
Close(code: Int)
Drop
ArmTimer(timer: Timer, in_ms: Int, stamp: Stamp)
CancelTimer(timer: Timer)
RequestIdentifySlot(conn: Conn)
ReleaseIdentifySlot(conn: Conn)
Inflate(conn: Conn, stamp: Stamp, bytes: BitArray)
ResetInflater(conn: Conn)
Emit(event: Event)
Note(notice: Notice)
}
Constructors
-
Open a WebSocket to
wss://<host><path>, spelling the host withhost_to_string.pathalready carries?v=,&encoding=jsonand&compress=; the adapter must not modify it. -
Send(frame: frame.Outbound)Send
frame.textas a text frame. Fire and forget: an adapter whose send fails reportsClosed(conn, None)and never throws. The opcode rides along for logging, so nothing has to parse the payload back. -
Close(code: Int)Send a close frame with this code, then tear the socket down. Only 1000, which invalidates the session, or 4000, which keeps it resumable.
-
DropAbandon the transport with no close frame: the peer already closed, or nothing was ever connected.
-
Arm
timerto fire inin_ms, replacing any timer already armed under the same name, then deliverFired(timer, stamp)with this exact stamp.in_msis always in [0, 600_000]. -
CancelTimer(timer: Timer)Cancel
timer. Cancelling an unarmed timer is a no-op, and cancellation need not be atomic: the core tolerates aFiredthat arrives anyway. -
RequestIdentifySlot(conn: Conn)Ask the identify queue for a slot for this connection.
-
ReleaseIdentifySlot(conn: Conn)Tell the identify queue this connection no longer needs its slot. Idempotent; releasing a slot that was never granted is a no-op.
-
Inflate exactly one complete compressed payload and answer with
Inflated(conn, stamp, _). At most one is ever outstanding. -
ResetInflater(conn: Conn)Discard the inflate context for
connand start a fresh one. Emitted alongside everyOpen: zlib state does not carry across connections. -
Emit(event: Event)A protocol event for the host application.
-
Note(notice: Notice)Diagnostics, never protocol-significant.
Where the shard is in the connection lifecycle, carrying exactly the data that exists at that point.
pub type Phase {
Idle(intent: Intent)
Waiting(intent: Intent)
Dialing(intent: Intent)
Greeting(intent: Intent)
Queued(beat: Beat)
Identifying(beat: Beat)
Resuming(beat: Beat, session: Session)
Live(beat: Beat, session: Session, budget: Budget)
Dead(reason: close.Reason)
Stopped
Unusable(bytes: Int)
Exhausted(attempts: Int)
}
Constructors
-
Idle(intent: Intent)Nothing has started;
Startis the only input that does anything.intentisIdentifyfromnewandResumefromresuming. -
Waiting(intent: Intent)No socket,
Reconnectarmed.intentis what the next connection does when HELLO arrives. Initial connect, backoff and both pauses land here. -
Dialing(intent: Intent)Openemitted, awaitingOpenedorOpenFailed. The handshake watchdog covers this window, so a transport hung in TLS cannot wedge the shard. -
Greeting(intent: Intent)Socket up, awaiting HELLO (op 10). Watchdog still armed.
-
Queued(beat: Beat)HELLO seen, waiting for an IDENTIFY slot. No
Intent, because a RESUME never queues. The handshake watchdog is cancelled here: atmax_concurrency: 1the sixteenth shard waits 75 seconds for a slot. -
Identifying(beat: Beat)IDENTIFY is on the wire, awaiting READY. There is no session and provably no sequence number.
-
RESUME is on the wire. Replayed dispatches arrive before
RESUMED, advancesession.seq, and each re-arms the handshake watchdog. -
READY or RESUMED has landed.
-
Dead(reason: close.Reason)Terminal. A close code no reconnect can fix.
-
StoppedTerminal. The host asked to stop.
-
Unusable(bytes: Int)Terminal. The IDENTIFY this config builds is over Discord’s 4096-byte payload limit, so no connection can get past the handshake.
-
Exhausted(attempts: Int)Terminal.
attemptsconnections in a row ended before READY, which is a shard that is broken rather than one that is unlucky.
pub type Properties =
frame.Properties
One shard’s complete protocol state.
pub type Shard {
Shard(
config: Config,
phase: Phase,
attempts: Int,
stamps: Stamps,
pending: List(command.Command),
inbound: Inbound,
rng: rng.Rng,
)
}
Constructors
-
Shard( config: Config, phase: Phase, attempts: Int, stamps: Stamps, pending: List(command.Command), inbound: Inbound, rng: rng.Rng, )Arguments
- attempts
-
Consecutive failed connection attempts. Reset to 0 by READY or RESUMED and by nothing else, so an accept-then-close loop still backs off.
- pending
-
Commands the host asked to send that have not gone out. Outlives a connection, unlike the budget.
- inbound
-
Reassembly for a compressed transport. Empty under
NoCompression.
pub type Sharding =
frame.Sharding
pub type ShardingError =
frame.ShardingError
Identifies one arming of one timer, and one inflate request. A cancel cannot recall a delivered message, so a stale fire is recognised when it lands.
pub type Stamp {
Stamp(Int)
}
Constructors
-
Stamp(Int)
The four timers the protocol needs. All are one-shot and re-armed on fire, so a leaked repeater is unrepresentable.
pub type Timer {
Heartbeat
Handshake
Reconnect
Commands
}
Constructors
-
HeartbeatSend the next heartbeat, or notice the connection is a zombie.
-
HandshakeThe connection has taken too long to reach READY or RESUMED.
-
ReconnectDial again.
-
CommandsThe 60-second command window rolled over.
Every number that is a glyde policy choice rather than a Discord rule.
pub type Tuning {
Tuning(
handshake_timeout_ms: Int,
backoff_base_ms: Int,
backoff_max_ms: Int,
identify_backoff_max_ms: Int,
no_progress_limit: Int,
missed_ack_limit: Int,
command_limit: Int,
command_queue_max: Int,
max_payload_bytes: Int,
)
}
Constructors
-
Tuning( handshake_timeout_ms: Int, backoff_base_ms: Int, backoff_max_ms: Int, identify_backoff_max_ms: Int, no_progress_limit: Int, missed_ack_limit: Int, command_limit: Int, command_queue_max: Int, max_payload_bytes: Int, )Arguments
- handshake_timeout_ms
-
No READY or RESUMED this long after the dial and the handshake is wedged. Does not run while waiting for an identify slot.
- backoff_max_ms
-
Ceiling on the reconnect ladder when the next connection will RESUME. Only downtime is at stake there, because a RESUME is unmetered.
- identify_backoff_max_ms
-
Ceiling on the ladder when the next connection must IDENTIFY. Discord allows 1000 a day and the penalty is a token reset: half jitter spends
86_400_000 / (0.75 * cap)a day, so stay above 115_200. No timer is armed for longer than ten minutes, so a ceiling past that is ten minutes. - no_progress_limit
-
Connection attempts in a row that never reach READY or RESUMED before the shard halts instead of redialling. Our choice, not Discord’s: below 1 is 1, and the ladder cannot count past 32.
- missed_ack_limit
-
Consecutive heartbeats with no op 11 before the connection is a zombie. Discord’s rule is one, and anything below 1 is treated as 1.
- command_limit
-
Outbound commands per 60s window. Discord’s hard limit is 120 and exceeding it is close 4008; the gap is heartbeat headroom.
- command_queue_max
-
Commands buffered while the window is closed or the shard is not live. Past this, the oldest is dropped with a
Notice. Below 0 is 0. - max_payload_bytes
-
Hard cap on the bytes held between messages, so a gateway that stops sending the sync-flush suffix cannot grow the buffer forever. A payload that is complete when it arrives is delivered whatever its size.
Everything that can arrive on a healthy socket and still be unusable. A value and not a sentence, so a host can match on one and a test can assert which it got.
pub type Undecodable {
FrameUnreadable(why: frame.Unreadable)
UnknownOpcode(op: Int)
ReadyIncomplete(why: ready.ReadyRejected)
ReadyWithoutResumeHost
BinaryFrameWithoutCompression
}
Constructors
-
FrameUnreadable(why: frame.Unreadable)The envelope reader could not read the frame. Carried as
frame’s own value rather than restated here: a second copy of these names is a second thing that has to stay true. -
UnknownOpcode(op: Int)A well formed frame carrying an opcode glyde does not model. Discord adding one must not kill a session.
-
ReadyIncomplete(why: ready.ReadyRejected)READY arrived without the three fields a session needs.
-
ReadyWithoutResumeHostREADY’s
resume_gateway_urlhad no host in it. The session is kept and the next RESUME goes to the configured host: a missing hint is not worth one of the 1000 identifies a day. -
BinaryFrameWithoutCompressionA binary frame with no transport compression negotiated. Nothing can be done with the bytes, and dropping them silently looks like a quiet gateway.
pub type Why {
DialFailed(failure: DialFailure)
PeerClosed(code: option.Option(Int))
ZombieConnection
HandshakeStalled
HandshakeUnreadable
ServerRequested
SessionInvalidated
TransportCorrupt
}
Constructors
-
DialFailed(failure: DialFailure) -
PeerClosed(code: option.Option(Int)) -
ZombieConnection -
HandshakeStalledThe handshake watchdog expired with nothing to show for it.
-
HandshakeUnreadableDiscord answered the handshake and the answer could not be read. Nothing stalled: the accompanying
UndecodableFramesays what was wrong with it. -
ServerRequested -
SessionInvalidated -
TransportCorrupt
Values
pub fn config(
token token: String,
intents intents: intents.Intents,
) -> Config
The mandatory fields, with everything else defaulted.
pub fn default_host() -> Host
Discord’s front door, and what config fills Config.host with. GET /gateway/bot may name another one; a RESUME goes to the host READY named.
pub fn describe(notice: Notice) -> String
One line per diagnostic, for a host that just wants to log them. Every variant carries its values too, so acting on one never means parsing this.
pub fn describe_halt(reason: Halt) -> String
Why the shard stopped, and for a fatal close what to do about it.
pub fn describe_timer(timer: Timer) -> String
pub fn describe_why(why: Why) -> String
Why the connection ended and a reconnect is armed. A host printing a reconnect without this says a bot is flapping and never says what is wrong with it.
pub fn disarm(
deadlines: List(Deadline),
timer: Timer,
) -> List(Deadline)
Forget every arming of timer. ArmTimer replaces rather than adds, so an
adapter runs this before recording a new one, and again on CancelTimer.
pub fn host_of(url from: String) -> Result(Host, Nil)
The host of a gateway URL, scheme and path and query dropped. Error(Nil)
when there is nothing in it to dial.
pub fn host_to_string(host: Host) -> String
For a log line or a URL an adapter is building. The core hands out Host
everywhere else, so this is the one direction that needs asking for.
pub fn is_terminal(shard: Shard) -> Bool
True once the shard has halted, however it got there. An adapter’s loop exits here.
pub fn large_threshold(value: Int) -> frame.LargeThreshold
Discord’s rule, not ours: large_threshold is 50 to 250, and a value
outside it is clamped here rather than at the handshake.
pub fn large_threshold_value(
threshold: frame.LargeThreshold,
) -> Int
The number Config.large_threshold holds, after the clamp.
pub fn new(config config: Config, seed seed: Int) -> Shard
A fresh shard in Idle with nothing armed. Feed it Start. Keep seed
below 2^31 and different per shard, or shards jitter in lockstep.
pub fn properties(
os os: String,
browser browser: String,
device device: String,
) -> frame.Properties
Fills Config.properties. Discord logs these and acts on none of them.
pub fn reseed(shard shard: Shard, seed seed: Int) -> Shard
A shard whose jitter starts again from seed, everything else untouched.
Same rule as new: below 2^31, and different for every shard of a fleet.
pub fn resuming(
config config: Config,
seed seed: Int,
session session: Session,
) -> Shard
A shard that will RESUME instead of IDENTIFY on its first connection, for a host that persisted a session across a process restart.
pub fn session(shard: Shard) -> option.Option(Session)
The session, if there is one to resume with, including while disconnected.
Persist this across a process restart and hand it to resuming.
pub fn shard_index(sharding: frame.Sharding) -> Int
This shard’s 0-based place in the fleet.
pub fn sharding(
index index: Int,
count count: Int,
) -> Result(frame.Sharding, frame.ShardingError)
sharding(index: 0, count: 1) is an unsharded bot, which Discord reads as
sending no shard array at all. index is 0-based and must be below
count, so a fleet the gateway would answer with close 4010 is refused
here rather than at the handshake.