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

Lifecycle

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:

SituationAction
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 sessionResume
same codes, no stored sessionIdentifyFresh
close 4003 (not authenticated), 4005-4009, or any other codeIdentifyFresh — the docs mark 4003 reconnect: true, and every other reconnectable close has an unusable session
op 9 Invalid Session, d = trueResume — outranks whatever close code follows
op 9 Invalid Session, d = falseIdentifyFresh; the stored session is forgotten
transport died with no close frametreated as 1006
3 missed heartbeat ACKsclose 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.

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

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

  • Resume

    Reconnect and RESUME the stored session.

  • IdentifyFresh

    Reconnect and IDENTIFY as a brand new session.

  • GiveUp

    Do 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

  • Connected

    The 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

  • HeartbeatTick

    Heartbeat 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).

  • TransportDown

    The transport process died without a close frame. Late notices for an already-handled close are ignored by the shard.

  • ReconnectNow

    Backoff elapsed; retry the connection.

  • Stop

    Close 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.

Search Document