tadpole/rest/execute
The REST executor: turns RestRequests into responses over an injected
transport. Tests run against canned gleam/http values; the Erlang
target ships gleam_httpc. Rate-limit behaviour is learned from
response headers and 429 bodies only (see tadpole/rest/rate_limit)
and applied as best-effort waits before sending.
Transport failures surface as RestStatus with the synthetic status 0 and a “no HTTP response: …” body. No variant in error.gleam describes a REST transport failure honestly — GatewayConnectFailed means gateway reconnects — and status 0 reads as “no HTTP happened” anywhere a status is printed.
When you reach for this
Behind tadpole/rest/endpoints for the known
calls; directly when you need a Discord route the endpoint helpers
do not wrap, or when one run of calls should share rate-limit state
(send_in_session). Tests reach for it constantly: Transport is
one function.
Transport injection
Transport is
fn(Request(String)) -> Result(Response(String), TransportError) —
the executor knows nothing else about HTTP. httpc_transport
wraps gleam_httpc for production; tests hand in a closure returning
canned responses; a future JS target would hand in fetch. The
TransportError shapes mirror gleam_httpc 5.x’s HttpError
one-to-one, so nothing is lost in translation and other transports
map onto the same three cases.
The status-0 convention
When the transport fails — no connection, timeout, non-UTF-8 body —
no HTTP response exists to report. Rather than stretch a variant,
the failure becomes error.RestStatus with status: 0,
discord_code: None, and the transport’s own description in the
body: "no HTTP response: failed to connect (ipv4: ..., ipv6: ...)".
Matching RestStatus(_, 0, _, _) catches every “Discord never saw
this request” case.
send, send_with_sleep, send_in_session
send— one request, throwaway rate-limit state: waits learned during this call throttle only this call’s own 429 retries, and nothing carries to the next call. What the endpoint helpers use.send_with_sleep—sendwith the sleep function injected; tests pass a recorder instead of waiting real milliseconds.send_in_session— the request runs inside aRestSession, so a wait learned from one response gates the next call on the same route. Returns the updated session; thread it through your loop.
429 retry flow
On a 429: fold the response into the session, then, if the client
has retry_on_429 and attempts remain, sleep the window the
response asked for — the Retry-After header (whole seconds, to ms)
first, else the body’s fractional retry_after — and try again.
When retries run out, the error is error.RateLimited carrying the
route, the last window in ms, the global flag, and the bucket id.
Zero-window 429s retry immediately. Sleeps are real
process.sleep: the calling process waits.
RestSession ownership honesty
A RestSession is a plain value, not a process. Passing it to two
processes gives each an independent copy — no cross-process
coordination, and no corruption either. The executor owns no clock:
a wait is measured as time elapsed since the response that produced
it, so a session reused long after real time passed waits slightly
long. That errs safe.
Concurrency
sendandsend_with_sleepshare nothing between calls: safe from as many processes as you like.- A session threaded through one process serializes that route’s calls — that is the point. Sent across processes, it is copied and learned waits diverge silently (see above).
- Every sleep blocks the calling process: pre-send waits, 429 windows, and the HTTP round trip (bounded by the client timeout).
Failure modes
RestStatus for any non-2xx and RateLimited when 429s outlast
the retries — nothing else. The body is returned raw, so no
DecodeFailed here; decoding is the endpoint’s job. Never panics,
and the token never appears in an error.
See also
tadpole/rest/rate_limit— the pure state behind the waitstadpole/rest— request builders and header parsingtadpole/rest/endpoints— the endpoint helpers on top
Types
pub type ConnectError {
Posix(code: String)
TlsAlert(code: String, detail: String)
}
Constructors
-
Posix(code: String)An OS-level connect failure, e.g. “econnrefused” or “nxdomain”.
-
TlsAlert(code: String, detail: String)The TLS handshake was refused; code and detail name the alert.
Rate-limit bookkeeping for a run of calls: the client, the observed bucket states, and the route -> bucket bindings. A plain value, not a process — passing it to two processes gives each its own copy, and cross-process coordination is future work.
States are keyed by the masked route key until a response names a bucket, then by that bucket id, as rate_limit.associate describes.
pub type RestSession {
RestSession(
client: rest.RestClient,
buckets: dict.Dict(String, rate_limit.BucketState),
routes: dict.Dict(String, error.BucketId),
)
}
Constructors
-
RestSession( client: rest.RestClient, buckets: dict.Dict(String, rate_limit.BucketState), routes: dict.Dict(String, error.BucketId), )
A function that performs HTTP. The executor only knows this shape, so tests hand in canned responses and a future JS target can hand in fetch without this module caring.
pub type Transport =
fn(request.Request(String)) -> Result(
response.Response(String),
TransportError,
)
What a transport can fail with. The shapes mirror gleam_httpc 5.x’s
HttpError one-to-one — the only transport shipped here — so nothing
is lost in translation and a different transport maps onto the same
three cases.
pub type TransportError {
InvalidUtf8Response
FailedToConnect(ip4: ConnectError, ip6: ConnectError)
ResponseTimeout
}
Constructors
-
InvalidUtf8ResponseThe response body was not valid UTF-8, so it could not be read as text.
-
FailedToConnect(ip4: ConnectError, ip6: ConnectError)No connection could be established: the IPv4 and the IPv6 attempt each failed with their own detail.
-
ResponseTimeoutNo response arrived within the client’s timeout.
Values
pub fn httpc_transport(
timeout_ms: Int,
) -> fn(request.Request(String)) -> Result(
response.Response(String),
TransportError,
)
The real transport, on gleam_httpc. timeout_ms bounds how long one
request may take; Discord calls regularly run past a second, so a
30s timeout (the httpc default) is a sensible client setting.
pub fn new_session(client: rest.RestClient) -> RestSession
A session with no observed limits yet.
pub fn send(
client: rest.RestClient,
request: rest.RestRequest,
transport: fn(request.Request(String)) -> Result(
response.Response(String),
TransportError,
),
) -> Result(rest.RestResponse, error.TadpoleError)
Execute one request against Discord’s API. Sleeps are real (gleam/erlang/process.sleep): 429 retries and pre-send waits block the calling process.
State is per call: the rate-limit bookkeeping lives only as long as
this request, so learned waits throttle this request’s own retries
and nothing carries to the next call. Beginner bots do one call at a
time; to share bucket state across a run of calls, use new_session
with send_in_session.
Fails with RestStatus for any non-2xx (status 0 means the transport never got a response), DecodeFailed is not raised here — the body is returned raw — and RateLimited once 429s outlast the configured retries. Never panics; the token never appears in an error.
pub fn send_in_session(
session: RestSession,
request: rest.RestRequest,
transport: fn(request.Request(String)) -> Result(
response.Response(String),
TransportError,
),
sleep_fn: fn(Int) -> Nil,
) -> #(Result(rest.RestResponse, error.TadpoleError), RestSession)
Execute a request inside a session, so waits learned from one response gate the next call on the same route.
The executor owns no clock: a wait is measured from the response that produced it, as if that response had just arrived. A session reused after real time has passed can therefore wait slightly long — that errs safe. The first call on an unknown route always sends immediately; limits are discovered, not predicted.
pub fn send_with_sleep(
client: rest.RestClient,
request: rest.RestRequest,
transport: fn(request.Request(String)) -> Result(
response.Response(String),
TransportError,
),
sleep_fn: fn(Int) -> Nil,
) -> Result(rest.RestResponse, error.TadpoleError)
Same as send with the sleep function injected: tests pass a no-op
that records durations instead of waiting.
pub fn transport_error_to_string(
transport_error: TransportError,
) -> String
Human-readable form, used in the RestStatus body a transport failure becomes. Never contains the token.