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

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

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

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

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

  • InvalidUtf8Response

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

  • ResponseTimeout

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

Search Document