Skip to main content

src/aws/internal/http_send.gleam

//// HTTP send abstraction used by all HTTP-based credential providers (and
//// later by the request pipeline itself).
////
//// Every provider that talks to AWS endpoints takes a `Send` value so tests
//// can drive it with a stub. Production code calls `default_send`, which
//// dispatches via `gleam_httpc` (Erlang's `httpc`).
////
//// Errors are normalised to a single `HttpError` sum type so the providers
//// can pattern-match on category without depending on `httpc`'s shape
//// directly.

import aws/streaming.{type StreamingBody}
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/httpc

pub type HttpError {
  /// Could not reach the host (DNS, TCP, TLS).
  ConnectFailed(reason: String)
  /// Connection succeeded but no response came back in time.
  Timeout
  /// Response body was not the expected encoding.
  InvalidBody(reason: String)
  /// Anything else the transport surfaced.
  Other(reason: String)
}

/// A function that sends a request and returns a response (or an HTTP error).
/// The body is `BitArray` so providers can deal in raw bytes without forcing
/// UTF-8 decoding decisions on the transport.
pub type Send =
  fn(Request(BitArray)) -> Result(Response(BitArray), HttpError)

/// Streaming variant of `Send`. The response body is a `StreamingBody`,
/// which v1 carries as a single buffered chunk and the eventual chunked
/// transport delivers as a true byte stream. Object-streaming GETs
/// (S3 `GetObject`, MediaLive, etc.) take this shape so call sites can
/// migrate today and pick up the real streaming path later without an
/// API change.
pub type StreamingSend =
  fn(Request(BitArray)) -> Result(Response(StreamingBody), HttpError)

/// Lift a buffered `Send` into a `StreamingSend` by wrapping its
/// response body as a `Buffered` `StreamingBody`. Use this when a
/// test wants to stub the streaming transport with a buffered fake,
/// or when a caller has only a buffered sender available and wants
/// it to satisfy a `StreamingSend` slot. Production code should
/// take `http_streaming.default_send` for genuine chunked transfer.
pub fn lift_to_streaming(send: Send) -> StreamingSend {
  fn(req) {
    case send(req) {
      Ok(resp) ->
        Ok(response.Response(
          status: resp.status,
          headers: resp.headers,
          body: streaming.from_bit_array(resp.body),
        ))
      Error(e) -> Error(e)
    }
  }
}

/// Default total-request timeout for `default_send`. 30 seconds is the
/// gleam_httpc default and a reasonable upper bound for control-plane and
/// list operations. Object-streaming GETs that may take longer want a
/// dedicated, more generous Send.
pub const default_timeout_seconds: Int = 30

/// IMDS gets a much shorter total timeout because its first call goes to a
/// link-local address that doesn't resolve at all on a non-EC2 host.
/// Without this, the TCP retransmit window can stall the whole credential
/// chain for tens of seconds — enough to trip the credentials cache actor's
/// 5-second call timeout.
pub const imds_timeout_seconds: Int = 2

/// Production sender — 30 second total timeout, TLS verification on.
pub fn default_send(
  req: Request(BitArray),
) -> Result(Response(BitArray), HttpError) {
  do_send(default_config(), req)
}

/// Build a `Send` with a custom total timeout. Use this for endpoints that
/// need either fast-fail behaviour (IMDS) or extra patience (large object
/// downloads). TLS verification and redirect-following match
/// `default_send`'s defaults; if you need to tweak those, drop down to
/// `gleam_httpc` directly.
pub fn with_timeout(seconds seconds: Int) -> Send {
  let config = httpc.configure() |> httpc.timeout(seconds * 1000)
  fn(req) { do_send(config, req) }
}

/// IMDS-tuned sender. Equivalent to `with_timeout(seconds: imds_timeout_seconds)`,
/// exported as a constant so call sites read meaningfully.
pub fn imds_send(
  req: Request(BitArray),
) -> Result(Response(BitArray), HttpError) {
  with_timeout(seconds: imds_timeout_seconds)(req)
}

fn default_config() -> httpc.Configuration {
  httpc.configure() |> httpc.timeout(default_timeout_seconds * 1000)
}

fn do_send(
  config: httpc.Configuration,
  req: Request(BitArray),
) -> Result(Response(BitArray), HttpError) {
  case httpc.dispatch_bits(config, req) {
    Ok(response) -> Ok(response)
    Error(httpc.FailedToConnect(_, _)) ->
      Error(ConnectFailed(reason: "could not connect to host"))
    Error(httpc.ResponseTimeout) -> Error(Timeout)
    Error(httpc.InvalidUtf8Response) ->
      Error(InvalidBody(reason: "response body was not valid UTF-8"))
  }
}