# `ReqManagedAgents.Providers.BedrockAgentCore.WaitBudget`
[🔗](https://github.com/cash-mckeeman/req_managed_agents/blob/v0.12.0/req_managed_agents/lib/req_managed_agents/providers/bedrock_agent_core/wait_budget.ex#L1)

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.

# `opt_key`

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

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

# `t`

```elixir
@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`

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

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

# `attempt_timeout`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `spend`

```elixir
@spec spend(t()) :: t()
```

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
