ReqManagedAgents.Providers.BedrockAgentCore.WaitBudget (ReqManagedAgents v0.13.2)

Copy Markdown View Source

The single wall-clock budget a BedrockAgentCore.provision/2 call spends across all of its waits and every request they make.

One provision can wait twice — first for a same-name harness to finish deleting, then for the new one to reach READY. Bounding each wait by a poll count made those budgets additive and left the wall-clock cost unbounded in both directions: a count says nothing about how long one poll's HTTP call takes, and two counted waits spend two full budgets. A %WaitBudget{} is built once at the entry to provision/2 and threaded through every wait, so deadline_at is one absolute monotonic instant that all of them share.

The two budget shapes

:timeout (ms) is the whole budget, and the deadline alone bounds the loops. The legacy :ready_poll_ms / :ready_max_polls pair is still accepted and keeps its count semantics, under a deadline derived from poll_ms * (max_polls + 1) — every poll the count allows, so the count is what binds — floored at the default. The floor matters: ready_poll_ms: 0 means "poll as fast as you can, N times" — attempts, not time — and deriving a zero-length wall-clock budget from it would end the wait after a single poll.

An explicit :timeout with no :ready_poll_ms also derives its own cadence, scaled to the budget and never wider than the default. A budget shorter than one default interval would otherwise buy exactly one poll — creating a harness, looking at it once and rolling it back, with the arithmetic entirely correct.

Why next/1 stops early

next/1 returns :poll only while a whole poll interval still fits inside what is left, not merely while time remains. A wait must stop while it still has time to return its named error tuple; overrunning the deadline and being killed from outside is the failure this module exists to prevent.

Summary

Types

The opts this budget reads, and the keys new/1 can reject.

t()

deadline_at is an absolute System.monotonic_time(:millisecond) instant, not a duration — it is what makes the budget shareable across two sequential waits. polls_left is :infinity when the caller gave an explicit :timeout.

Whether a wait may poll once more, or has run out of budget.

Functions

The receive_timeout one HTTP attempt of a poll may use.

Builds the budget for one provision/2 call from its opts, starting the clock.

Whether the wait may sleep and poll once more.

Milliseconds left before the deadline; negative once it has passed.

Records one poll against the legacy count. A :timeout-shaped budget is unchanged.

Types

opt_key()

@type opt_key() :: :timeout | :ready_poll_ms | :ready_max_polls

The opts this budget reads, and the keys new/1 can reject.

t()

@type t() :: %ReqManagedAgents.Providers.BedrockAgentCore.WaitBudget{
  deadline_at: integer(),
  poll_ms: non_neg_integer(),
  polls_left: non_neg_integer() | :infinity
}

deadline_at is an absolute System.monotonic_time(:millisecond) instant, not a duration — it is what makes the budget shareable across two sequential waits. polls_left is :infinity when the caller gave an explicit :timeout.

verdict()

@type verdict() :: :poll | :expired

Whether a wait may poll once more, or has run out of budget.

Functions

attempt_timeout(budget, configured, attempts)

@spec attempt_timeout(t(), timeout(), pos_integer()) :: pos_integer()

The receive_timeout one HTTP attempt of a poll may use.

Bounded by the smaller of the client's configured timeout and what is left of the budget, then divided by attempts — the poll's transport retries the request, so a per-attempt timeout equal to the whole remaining budget would let one hung poll spend it several times over. Never returns less than 1: a poll on a spent budget degrades to "try once, briefly", and a non-positive receive_timeout is rejected by the transport.

What this does not cover

receive_timeout bounds the receive phase of one attempt. Three costs sit outside it, so a poll can exceed its share of the budget:

  • the transport's sleeps between retries (exponential backoff — roughly 1 s then 2 s at the default policy), and a Retry-After header, which the server chooses;
  • the connect timeout, which is a separate 30 s default;
  • the time to establish TLS on a cold connection.

A wait therefore overruns its deadline by at most one poll's envelope, and the smaller the budget the larger that envelope is in proportion — at a 200 ms budget, one transient error costs seconds. Bounding the total needs a deadline-aware retry delay, which belongs in the client rather than here.

new(opts)

@spec new(keyword()) :: {:ok, t()} | {:error, {:invalid_opts, opt_key()}}

Builds the budget for one provision/2 call from its opts, starting the clock.

Reads :timeout (ms) and the legacy :ready_poll_ms / :ready_max_polls. Each must be a non-negative integer; anything else returns {:error, {:invalid_opts, key}}. That includes timeout: :infinity, which this budget deliberately cannot express — a wait with no deadline is the defect it exists to prevent.

next(budget)

@spec next(t()) :: verdict()

Whether the wait may sleep and poll once more.

:poll requires both that the legacy count is not exhausted and that a whole poll_ms sleep fits inside the time left.

remaining(wait_budget)

@spec remaining(t()) :: integer()

Milliseconds left before the deadline; negative once it has passed.

spend(budget)

@spec spend(t()) :: t()

Records one poll against the legacy count. A :timeout-shaped budget is unchanged.