WaitForIt usage rules

Copy Markdown View Source

Waiting on asynchronous or remote work, using syntax built on Elixir's own control-flow constructs. Most useful in tests that must wait for concurrent activity, and anywhere processes coordinate.

{:ok, user} = WaitForIt.match_wait({:ok, %User{}}, Repo.fetch(User, id), timeout: 2_000)

require WaitForIt or import WaitForIt before using any of it — the five waiting forms are macros.

The one rule

On timeout, each form behaves exactly as its built-in Elixir counterpart would on a final evaluation in which nothing matched.

That is the whole design. There is nothing WaitForIt-specific to memorise: a case_wait that times out raises CaseClauseError for the same reason a case does; a with_wait returns the last unmatched value for the same reason a with does.

FormWaits untilNative counterpartOn timeout (no else)
wait/2an expression is truthytruthinessreturns the last falsy value
match_wait/3an expression matches a pattern (and binds out of it)=raises MatchError
case_wait/3one of several clauses matchescaseraises CaseClauseError
cond_wait/2one of several expressions is truthycondraises CondClauseError
with_wait/3several composed waits all succeedwithreturns the last value

Two consistent additions:

  • An else clause (on case_wait, cond_wait, with_wait) turns a timeout into a value.
  • A ! variant of every form (wait!/2, match_wait!/3, …) replaces whatever the non-bang form would do with a uniform WaitForIt.TimeoutError.

Options

Every form takes the same ones:

OptionDefaultMeaning
:timeout5_000total ms to wait before giving up, or :infinity
:interval100ms between re-evaluations, or a WaitForIt.Backoff function
:pre_wait0ms to wait before the first evaluation
:signal—disable polling; re-evaluate only when this signal arrives

Use :interval. :frequency is a deprecated alias kept for compatibility and slated for removal in a future major — do not write it in new code.

The traps

Waiting blocks the calling process — never wait inside a GenServer callback

A wait is a polling loop in the caller's own process. While it runs, that process does nothing else.

def handle_call(:fetch, _from, state) do
  # ❌ the entire server is blocked for up to 5 seconds; every other caller queues behind this
  {:ok, v} = WaitForIt.match_wait({:ok, _}, remote_fetch(), timeout: 5_000)
  {:reply, v, state}
end

Worse, the defaults collide: WaitForIt's default :timeout is 5000ms and GenServer.call/3's default timeout is also 5000ms, so the caller gives up at almost exactly the moment the wait does — you get a confusing :timeout exit from the caller rather than the wait's own timeout behaviour.

Wait in the process that can afford to block: the caller, a Task, or a test. If a server must wait, do it in a spawned task and handle_info the result.

Signals are node-local

signal/1 dispatches through a local Registry, so a signal sent on one node never reaches a waiter on another. In a distributed application, a wait blocked on signal: :thing will sit there until its timeout while the producer on another node happily signals into the void.

Polling has no such limitation — it re-evaluates the expression, and the expression can consult anything (a database, a replicated table, a :global process). If the condition can be changed from another node, poll.

Never write a catch-all clause in case_wait or cond_wait

This is the single most common mistake, and it silently disables the waiting.

# ❌ `_` matches on the very first evaluation, so this never waits at all
WaitForIt.case_wait Repo.get(Job, id) do
  %Job{status: :done} -> :finished
  _ -> :not_yet
end

# ✅ `else` runs only on timeout
WaitForIt.case_wait Repo.get(Job, id), timeout: 10_000 do
  %Job{status: :done} -> :finished
else
  _ -> :gave_up
end

The same applies to a final true -> in cond_wait. A clause that always matches halts the wait on the first evaluation; else is evaluated only when the wait gives up.

The expression is re-evaluated, so its side effects must be repeatable

A waitable expression runs an indeterminate number of times. An idempotent expression is useless here — it either halts immediately or never halts — so it is expected that the value changes between evaluations, and any side effect must be safe to repeat. Do not put a POST, an insert, or a counter increment in a waitable expression.

timeout: :infinity removes all timeout behaviour

Not just "waits longer". Because such a wait can never time out: a ! variant never raises, an else clause never runs, and until/2 never returns {:timeout, last_value}. The process blocks until the condition is met or it dies. Reach for it only where something else bounds the wait — a supervised process, a Task with its own timeout, or a caller that can shut it down.

A bare variable pattern never waits

match_wait, a <~ clause, and assert_eventually's pattern = expression form all test the pattern before binding it. A bare variable matches anything, so the wait halts on its first evaluation and hands back whatever it saw — usually the nil you were waiting for it to stop being:

# ❌ halts immediately; `pid` is nil
assert_eventually pid = Registry.whereis(:thing)

# ✅ wait for truthiness, or guard the pattern
pid = WaitForIt.wait!(Registry.whereis(:thing))
pid = WaitForIt.match_wait!(p when is_pid(p), Registry.whereis(:thing))

This is the same mistake as a catch-all clause in case_wait, in the one place the compiler cannot warn about it.

refute_eventually means never, not "eventually not"

refute_eventually(x) asserts that x never becomes truthy within the window. To wait for something to become false — a metric that gets cleaned up, a process that gets reaped — the assertion is assert_eventually(not x).

with_wait uses two different arrows

WaitForIt.with_wait on(
  {:ok, account} <~ {load_account(token), timeout: 2_000},   # WAITS for the match
  {:ok, balance} <- fetch_balance(account)                    # one-shot, exactly like `with`
) do
  {:ok, balance}
else
  not_ready -> {:error, {:timed_out, not_ready}}
end

<~ is wait-for-match; <- behaves exactly as in a native with. Per-clause options go in a tuple: {expr, opts}. Note the on(...) wrapper — it is the one place the "looks like the native construct" resemblance breaks.

"The following clause will never match" is telling the truth

On Elixir 1.20+ you may see the type checker flag a match_wait/with_wait pattern as unreachable. Waiting changes values over time; it does not change types. If the expression's inferred type cannot produce the pattern, the wait can only ever time out, and the warning has found a real bug.

The usual innocent cause is a test stub narrow enough for the compiler to pin down (defp pending, do: :pending infers exactly :pending). Widen it rather than silencing the warning:

defp pending, do: Process.get(:__unset__, :pending)   # same value, type is now dynamic()

Choosing a form

match_wait/3 is the one to reach for by default when waiting on a tagged result — it waits and binds in one expression.

until/2 is the functional counterpart of wait/2, for when the condition is computed at runtime or built dynamically and a macro cannot serve. It takes a zero-arity function and returns a tagged result, so success and timeout are never ambiguous:

case WaitForIt.until(fn -> Repo.get(Post, id) end, timeout: :timer.seconds(5)) do
  {:ok, post} -> post
  {:timeout, _last} -> raise "post #{id} never appeared"
end

until!/2 returns the bare value and raises WaitForIt.TimeoutError instead.

Polling and backoff

Polling is the default: re-evaluate every :interval ms. :interval also accepts a 1-arity function of the attempt number, which is how you back off against a struggling dependency:

WaitForIt.wait(Service.ready?(), interval: WaitForIt.Backoff.exponential(start: 50, max: 2_000, jitter: 0.1))

Signal-based waiting removes the polling loop entirely — a waiter blocks until it receives a named signal telling it to re-evaluate:

# consumer
WaitForIt.wait(Buffer.count() >= 4, signal: :buffer_filled)

# producer, after changing the condition
Buffer.put(item)
WaitForIt.signal(:buffer_filled)

A signal does not mean the condition is now satisfied — only that waiters should re-check. Both sides must agree on the signal name, and both must be on the same node.

In tests

Prefer WaitForIt.Test's assertions over Process.sleep/1. They wait, re-evaluate, and on timeout fail with an ordinary ExUnit.AssertionError carrying the source expression and the last value seen:

defmodule MyApp.SomeTest do
  use ExUnit.Case
  use WaitForIt.Test

  test "the user is eventually confirmed" do
    assert_eventually {:ok, %User{confirmed: true}} = Repo.fetch(User, user_id)
  end
end

assert_eventually/2 (truthy or pattern = expr binding form), refute_eventually/2, and assert_always/2 — the last for asserting something stays true for the duration rather than becomes true.

The waiting macros work in tests too when you want their exact return values or timeout semantics; wait/2 returns its value and drops straight into an assert.

Telemetry

Every wait emits [:wait_for_it, :wait, :start | :stop | :exception]. The :stop event reports the duration, the number of evaluations, and whether the wait :matched or hit a :timeout — which is how you find waits that are quietly timing out, or polling far more than they need to.

Mind that refute_eventually and assert_always succeed by timing out, so a passing assertion of either reports result: :timeout. They carry wait_context: %{construct: :refute_eventually | :assert_always}; exclude those, or a handler that reports on timeouts flags every passing assertion.

Deprecated

WaitForIt.V1 emits compile-time deprecation warnings and will be removed in 3.0. Do not write new code against it.