Probe abandonment wedge: a still-alive caller whose acquire_probe call
times out on a spent deadline was elected the probe server-side (reply
to a dead ref) but never settled and never died, wedging probing[route]
forever. Two layers: the caller now casts abandon_probe/1 on timeout
(server settles only if the entry's monitored pid matches, so a later
probe is never clobbered), and the server arms a force-settle timer at
election as a backstop against a lost abandon.
Interaction callbacks shared one route key: templatize collapsed the
interaction token to :id, so every callback shared one bucket and one
Remaining:0/429 stalled all acks toward the 3s deadline. Restore
per-interaction separation via the same non-reversible token digest used
for webhooks (id still collapses); secrets never stored raw.
Adds the full README (quickstart, interaction styles, privileged intents,
cache, REST, slash registration, event semantics, Nostrum porting guide,
reliability design), an examples/echo_bot.exs Mix.install script, ex_doc
config in mix.exs, and rewrites Dexcord.Gateway's moduledoc to describe
the finished reliability behavior instead of stale Phase 0-1 scope notes.
Root cause of the order/seed-dependent integration failures: when the WS
upgrade's 101 response and the server's first frame (HELLO) arrive in one TCP
segment, Mint returns them as [status, headers, {:data, hello}, {:done}] - the
{:data} ordered BEFORE the {:done} that builds the websocket. process_responses
only decoded {:data} once the websocket existed, so the coalesced HELLO was
silently dropped and the client wedged in :hello_wait. Coalescing is far more
likely under full-suite scheduler load, which also starved the cadence-sensitive
tests - hence the flaky, seed-varying failures. Buffer pre-upgrade bytes and
decode them the instant the websocket is built.
Test hermeticity:
- Add Dexcord.EnvSandbox: snapshot all :dexcord app env in setup, restore
wholesale on_exit. Replaces every file's hand-rolled per-key save/restore so no
knob (timing bounds, budgets, ratelimit clock, base URLs) can leak between tests.
- gateway_fatal_test: await the directly-started supervisor's full termination
(monitor + DOWN) instead of a fire-and-forget Process.exit, so its globally-named
singletons and named ETS are gone before the next test's tree starts.
- Monitor the elected probe caller and release parked waiters on its death;
guarantee probe_settled via try/after around request issuance so a raising
encode or killed caller can no longer wedge a route permanently.
- Never store secret tokens in public route keys: digest webhook tokens and
collapse interaction-callback tokens to :id; add an idle-TTL sweep evicting
stale bucket + route rows (:ratelimit_idle_ttl_ms / :ratelimit_sweep_interval_ms).
- Enforce a per-request deadline (:api_deadline_ms, default 30s); convert
probe-park GenServer.call timeouts into an error tuple.
- Reset the counter to limit-1 on window elapse to avoid the reset thundering
herd (un-deads the limit field); clamp the shared counter floor at -1.
- Preserve non-JSON bodies: {:ok, {:raw, body}} for 2xx, bounded snippet in
error messages.
- Enforce the identify gap on every identify path via do_identify/1, so an
op 9 d:false reidentify in :resuming no longer bypasses it.
- Add a reidentify_pending? flag so a dispatch trickling in during the
post-op-9 wait can't clobber the pending reidentify timer.
- Delete dead conn_gen state and correct the moduledoc.
- Extract pure stale_socket?/2 for direct unit testing.
- Add deterministic tests for the hello_wait heartbeat re-arm, the resuming
identify gap, the reidentify clobber guard, and stale_socket?/2.
Add Dexcord.Api.Ratelimit (route->bucket learning, per-bucket windows via
monotonic X-RateLimit-Reset-After deadlines, global lock, injectable clock,
probe serialization for unknown routes). Rework Dexcord.Api.request/4 around
acquire/wait/update with 429 + global handling and up to 3 retries; add the
~16 typed endpoints and a Dexcord.Api.Error struct. Start Ratelimit in the
supervisor after Finch. Add unit tests (route-key table, bucket/global math
with injected clock) and integration tests against a new Dexcord.FakeRest
Bandit server.
- fix heartbeat timer loss in :hello_wait (re-arm during identify-gap wait)
- honor server op 1 in :identifying/:hello_wait, not just :connected/:resuming
- full op 9 handling: d:false in :resuming re-IDENTIFYs on the same socket after
the mandated 1-5s; d:false elsewhere floors the reconnect; d:true keeps session
- resume-loop cap (5 consecutive failed resumes -> fresh IDENTIFY) via Session ETS
- explicit conn_gen/stale-socket guard backstopping Mint's :unknown
- bounded TCP connect + configurable handshake watchdogs so a stalled reconnect
self-heals instead of wedging
- FakeGateway scripted Bandit/WebSock test server + TestHandler
- integration suite (happy/zombie/kill/close-codes/op7/op9/replay/cap/backoff/gap/
stale), fatal-path end-to-end, and unit tests for resume counter + backoff math