tadpole/gateway/shard
One gateway connection, end to end: HELLO, heartbeats, identify or resume, close-code decisions, reconnect with backoff. The shard actor owns all protocol state — sequence, session id, heartbeat misses — so a transport can die without losing the session it will resume with. Decisions that do not need a socket are pure functions here, mirrored against the tables in tadpole/gateway. Stability: Growing.
Almost every bot meets this module through tadpole/bot.
Come here directly to run a shard with your own event sink — a custom
dispatcher, a test harness, a second consumer of lifecycle data.
When you reach for this
start gives back a subject; typed events flow to the subject you
passed as events, and connection news to the lifecycle subject if
you supplied one. Connection trouble is not a start failure:
attempts retry with backoff and report through lifecycle instead.
ShardConfig, field by field
token— the bot token, sent inside IDENTIFY and RESUME payloads.intents— the raw bitfield Discord expects in IDENTIFY. Build it with tadpole/intent andintent.to_int, not by hand.shard—#(shard_id, shard_count), Discord’s documented order. One shard this milestone:#(0, 1).url— the full gateway URL including version and encoding, e.g.wss://gateway.discord.gg/?v=10&encoding=json(bot.gleam’s constant). No default; the caller always names one. When READY names a resume_gateway_url, later resume reconnects dial that host instead, carrying this URL’s query parameters.lifecycle—Some(subject)to receive Lifecycle notices,Noneto run silent.
Lifecycle
Connected— the websocket handshake succeeded; HELLO has not arrived, so identify/resume has not run.Disconnected(close_code, will_resume)— the connection closed.close_codeis Discord’s code, or 1006 when the transport died without one.will_resumeis the decision that follows.ConnectFailed(error)— one connect attempt failed; a retry is already scheduled. Expect this repeatedly during outages.
ShardMsg and the reconnect ladder
ShardMsg is the actor’s message type, public only because the
subject’s type mentions it. Callers send Stop; every other variant
(HeartbeatTick, Inbound, SocketClosed, TransportDown, ReconnectNow)
is plumbing driven by timers and the transport. Stop closes the
socket and stops the actor; it does not wait for Discord to
acknowledge the close frame.
The close-code ladder, applied by next_action_on_close and tested
against tadpole/gateway:
| Situation | Action |
|---|---|
| close 4004, 4010-4014 (bad token, bad shard, bad version, bad or disallowed intents) | GiveUp — the docs mark these reconnect: false; a config problem, retrying without a fix loops |
| close 1000, 1001, 1006, 4000, 4001, 4002, with a stored session | Resume |
| same codes, no stored session | IdentifyFresh |
| close 4003 (not authenticated), 4005-4009, or any other code | IdentifyFresh — the docs mark 4003 reconnect: true, and every other reconnectable close has an unusable session |
op 9 Invalid Session, d = true | Resume — outranks whatever close code follows |
op 9 Invalid Session, d = false | IdentifyFresh; the stored session is forgotten |
| transport died with no close frame | treated as 1006 |
| 3 missed heartbeat ACKs | close with a keep-session code, reconnect, attempt to resume — the docs’ zombie rule; a failed resume falls through to fresh |
Heartbeat and zombie rules
HELLO carries the heartbeat interval — floored at 1s, and 45s if
Discord’s HELLO is unreadable, so zombie detection keeps running.
Each tick sends a HEARTBEAT carrying the last sequence number and
re-arms itself; there is no separate timer process to supervise.
Discord can also demand an immediate heartbeat (op 1, often around
RESUME); the shard answers with the current sequence. A HEARTBEAT_ACK
resets the miss counter; after gateway.max_missed_acks (3) missed
ACKs the connection is a zombie and the shard reconnects fresh.
Reconnects wait gateway.backoff_ms(attempts): 1s, 2s, 4s, … capped
at 60s, no jitter. HELLO resets the attempt counter.
Concurrency
One actor owns everything: session id, sequence, heartbeat counters, the connection, its timers. Nothing to lock.
process.sendto the shard, events, or lifecycle subject is safe from any process; sends are async and never block.- The events subject can only be received by its owner — create it in the process that will read it.
startblocks for at most one websocket handshake (~5s); after that everything is async.next_action_on_closeandon_invalid_sessionare pure — safe to call anywhere, which is how tests pin the ladder.
Example
import gleam/erlang/process
import gleam/option.{Some}
import tadpole/gateway/shard
import tadpole/intent
// Illustrative — tadpole/bot wires exactly this.
pub fn run_shard(token: String) -> process.Subject(shard.ShardMsg) {
let events = process.new_subject()
let assert Ok(shard_subject) =
shard.start(
shard.ShardConfig(
token: token,
intents: intent.to_int(
intent.new() |> intent.enable(intent.guild_messages),
),
shard: #(0, 1),
url: "wss://gateway.discord.gg/?v=10&encoding=json",
lifecycle: None,
),
events: events,
)
// Read `events` here; send shard.Stop to close.
shard_subject
}
See also
tadpole/gateway— the pure tables this actor appliestadpole/gateway/transport— the websocket under the actortadpole/bot— the user-facing wiring of this module
Types
What the shard does after a websocket close. Tested against the close-code table in tadpole/gateway; the actor applies it verbatim.
pub type CloseAction {
Resume
IdentifyFresh
GiveUp
}
Constructors
-
ResumeReconnect and RESUME the stored session.
-
IdentifyFreshReconnect and IDENTIFY as a brand new session.
-
GiveUpDo not reconnect: the docs mark these closes reconnect: false. Every one is a config or token problem, and retrying without fixing it just loops.
pub type Lifecycle {
Connected
Disconnected(close_code: Int, will_resume: Bool)
ConnectFailed(error: error.TadpoleError)
}
Constructors
-
ConnectedThe websocket handshake succeeded; HELLO has not arrived yet.
-
Disconnected(close_code: Int, will_resume: Bool) -
ConnectFailed(error: error.TadpoleError)
pub type ShardConfig {
ShardConfig(
token: String,
intents: Int,
shard: #(Int, Int),
url: String,
lifecycle: option.Option(process.Subject(Lifecycle)),
)
}
Constructors
-
ShardConfig( token: String, intents: Int, shard: #(Int, Int), url: String, lifecycle: option.Option(process.Subject(Lifecycle)), )Arguments
- shard
-
#(shard_id, shard_count), Discord’s documented order.
- lifecycle
-
Optional lifecycle notices: connected, disconnected (with close code and what happens next), connect failures. Discord’s close codes ride along here.
Messages the shard actor runs on. Internal plumbing — the subject
type leaks it, but callers only ever send Stop themselves.
pub type ShardMsg {
HeartbeatTick
Inbound(frame: frame.Frame)
SocketClosed(close_code: Int)
TransportDown
ReconnectNow
Stop
}
Constructors
-
HeartbeatTickHeartbeat interval fired. The shard re-arms the timer after each tick, so there is no separate timer process to supervise.
-
Inbound(frame: frame.Frame)A parsed gateway frame arrived from the transport.
-
SocketClosed(close_code: Int)The websocket closed; carries Discord’s close code when one was sent (1006 when the transport died without one).
-
TransportDownThe transport process died without a close frame. Late notices for an already-handled close are ignored by the shard.
-
ReconnectNowBackoff elapsed; retry the connection.
-
StopClose the connection and stop the actor. Does not wait for Discord to acknowledge the close frame.
Values
pub fn connect_url(
config_url: String,
resume_gateway_url: option.Option(String),
resume_next: Bool,
) -> String
The URL the next connection dials. A resume dials the gateway URL Discord handed out at READY — the docs: it replaces the URL first connected with, carrying the same query parameters — and every other connection dials the configured URL. When READY offered no resume URL, the configured URL keeps working. Pure so tests pin the choice.
pub fn next_action_on_close(
close_code: Int,
has_session: Bool,
) -> CloseAction
The close-code decision: reconnectable codes reconnect, resumable codes with a stored session resume, everything else starts fresh.
pub fn on_invalid_session(resumable: Bool) -> CloseAction
Op 9 Invalid Session carries its own verdict, which outranks whatever
close code follows: True means the session is still there and RESUME
is allowed, False means it is gone and a fresh identify is due.
pub fn start(
config: ShardConfig,
events events: process.Subject(events.Event),
) -> Result(process.Subject(ShardMsg), error.TadpoleError)
Start one shard: an actor that connects to config.url, identifies
(or resumes a stored session), heartbeats on Discord’s interval, and
dispatches typed events to events. start itself never fails on
network problems — connection attempts retry with gateway backoff and
report through config.lifecycle — so callers get a subject back and
the shard does the rest.
The initialiser blocks for at most one websocket handshake (~5s).
Stop the shard by sending it Stop.