Skip to main content

src/aws/waiter.gleam

//// Polling helper for Smithy `@waitable` operations.
////
//// The codegen-emitted `wait_until_<name>` functions are thin
//// wrappers over `wait`. Each wrapper:
////
////   1. Invokes the underlying typed operation in a `step` closure.
////   2. Matches the result against the waiter's acceptors (a list
////      of `state` + `matcher` rules lifted from the Smithy trait).
////      Returns `Settled` when an acceptor with `state: success`
////      matches, `FailedNow(err)` when `state: failure` matches,
////      `Continue` otherwise.
////   3. Calls `wait(step, max_attempts, min_delay, max_delay)` to
////      drive the loop with exponential backoff between attempts.
////
//// Backoff doubles the previous delay (starting at `min_delay_ms`)
//// up to `max_delay_ms` — Smithy's standard waiter cadence. The
//// helper sleeps between attempts via `process.sleep`; tests pass
//// zero delays so they don't block.
////
//// `step` carries the 1-indexed attempt number. The codegen
//// generally ignores it but specialised callers can use it for
//// dynamic adjustments (e.g. emit a log line per attempt).

import gleam/erlang/process

/// What a single waiter step produced. The codegen-emitted
/// `wait_until_<name>` wrapper turns the typed operation's
/// `Result(Output, Error)` plus the configured acceptors into one
/// of these three states.
pub type Step(error) {
  /// At least one `state: success` acceptor matched — the wait
  /// returns `Ok(Nil)` immediately.
  Settled
  /// No acceptor matched yet — sleep for the current backoff and
  /// re-invoke `step` (unless we've hit `max_attempts`).
  Continue
  /// A `state: failure` acceptor matched — the wait returns
  /// `Error(Failed(_))` immediately, no further attempts.
  FailedNow(error)
}

/// What can go wrong driving a waiter to completion.
pub type WaiterError(error) {
  /// A `state: failure` acceptor matched on attempt `attempts`.
  Failed(error: error)
  /// Reached the configured `max_attempts` without ever settling
  /// or failing. `attempts` is the actual number of attempts made.
  MaxAttemptsExceeded(attempts: Int)
}

/// Drive a waiter step closure to completion, sleeping between
/// attempts with exponential backoff capped at `max_delay_ms`.
/// Returns `Ok(Nil)` on settle, `Error(Failed(_))` on a
/// `state: failure` acceptor match, `Error(MaxAttemptsExceeded(_))`
/// when the attempt budget is exhausted. `max_attempts == 0` is a
/// defensive guard that returns immediately without invoking
/// `step`.
pub fn wait(
  step step: fn(Int) -> Step(error),
  max_attempts max_attempts: Int,
  min_delay_ms min_delay_ms: Int,
  max_delay_ms max_delay_ms: Int,
) -> Result(Nil, WaiterError(error)) {
  case max_attempts > 0 {
    False -> Error(MaxAttemptsExceeded(attempts: 0))
    True -> loop(step, 1, max_attempts, min_delay_ms, max_delay_ms)
  }
}

fn loop(
  step: fn(Int) -> Step(error),
  attempt: Int,
  max_attempts: Int,
  current_delay_ms: Int,
  max_delay_ms: Int,
) -> Result(Nil, WaiterError(error)) {
  case step(attempt) {
    Settled -> Ok(Nil)
    FailedNow(e) -> Error(Failed(error: e))
    Continue ->
      case attempt >= max_attempts {
        True -> Error(MaxAttemptsExceeded(attempts: max_attempts))
        False -> {
          // The Gleam OTP scheduler treats `process.sleep(0)` as a
          // no-op; the loop drives attempts back-to-back in that
          // case (used by tests).
          sleep(current_delay_ms)
          loop(
            step,
            attempt + 1,
            max_attempts,
            next_delay(current_delay_ms, max_delay_ms),
            max_delay_ms,
          )
        }
      }
  }
}

/// Smithy's waiter backoff: double the previous delay, capped at
/// `max_delay_ms`. Starts at `min_delay_ms` (the first sleep) and
/// climbs from there.
fn next_delay(current_ms: Int, max_delay_ms: Int) -> Int {
  let doubled = current_ms * 2
  case doubled > max_delay_ms {
    True -> max_delay_ms
    False -> doubled
  }
}

fn sleep(ms: Int) -> Nil {
  case ms <= 0 {
    True -> Nil
    False -> process.sleep(ms)
  }
}