WaitForIt

Hex.pm Documentation

Various ways of waiting for things to happen.

WaitForIt lets you wait on the results of asynchronous or remote operations using intuitive, familiar syntax built on Elixir's own control-flow constructs (if, case, cond, and with). It is equally at home coordinating concurrent processes in production code and taming flaky timing in tests.

# Wait until a record shows up, and bind it directly:
{:ok, user} = WaitForIt.match_wait({:ok, %User{}}, Repo.fetch(User, id), timeout: 2_000)

Elixir provides several language and standard library features — such as Process.sleep/1, receive/1/after, and Task.async/1/Task.await/2 — that can be used to implement waiting, but they are inconvenient for the purpose. WaitForIt builds on top of them to provide convenient, expressive facilities for waiting on specific conditions. This is most obviously useful in tests that must wait for concurrent or asynchronous activity to complete, but it is just as useful anywhere concurrent processes coordinate their activity — asynchronous event handling, producer-consumer processes, and time-based activity.

The five waiting forms are macros, so require WaitForIt or import WaitForIt in any module that uses them.

New to the library? Getting started covers installation and goes from mix deps.get to your first wait, and your first test assertion, in a few minutes.

The five forms of waiting

Form Waits until… Looks like
wait/2 an expression is truthy a bare expression
match_wait/3 an expression matches a pattern (binding out of it) a <- clause
case_wait/3 an expression matches one of several clauses a case expression
cond_wait/2 one of several expressions is truthy a cond expression
with_wait/3 several composed waits all succeed a with expression

Each form has a ! variant (wait!/2, match_wait!/3, …) that raises WaitForIt.TimeoutError on timeout instead of returning a falsy value or raising the matching built-in error.

wait

wait/2 waits until a given expression evaluates to a truthy value.

# Wait up to one minute for a file to exist, then print its contents.
if WaitForIt.wait(File.exists?("data.csv"), timeout: :timer.minutes(1)) do
IO.puts(File.read!("data.csv"))
else
IO.warn("Stopped waiting for the file to exist")
end

match_wait

match_wait/3 waits until a given expression matches a given pattern, and binds out of it. It is the most convenient form when waiting for a tagged result such as {:ok, value}.

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

case_wait

case_wait/3 waits until a given expression matches one of the given case clauses. It looks and acts like a case/2 expression, except that it can take an optional else clause.

WaitForIt.case_wait(File.stat("data"), timeout: :timer.seconds(30)) do
{:ok, %File.Stat{type: :directory}} ->
File.write!("data/greeting.txt", "Hello, world!")
else
{:ok, %File.Stat{type: type}} ->
IO.warn("Expected 'data' to be a directory but its type is #{inspect(type)}")
{:error, reason} ->
IO.warn("Could not stat 'data': #{inspect(reason)}")
end

cond_wait

cond_wait/2 waits until any one of the given expressions evaluates to a truthy value. It looks and acts like a cond/1 expression, except that it can take an optional else clause.

WaitForIt.cond_wait(timeout: :timer.seconds(10), interval: 500) do
File.exists?("data/process.json") -> IO.puts("Processing...")
NaiveDateTime.utc_now().second == 0 -> IO.puts("Top of the minute!")
else
IO.warn("Stopped waiting since neither condition ever became truthy")
end

with_wait

with_wait/3 composes several waits in a pipeline. It looks and acts like a with/1 expression, except that its <~ clauses wait until their expression matches.

WaitForIt.with_wait on(
{:ok, account} <~ {load_account(token), timeout: 2_000},
{:ok, balance} <~ fetch_balance(account)
) do
{:ok, balance}
else
not_ready -> {:error, {:timed_out, not_ready}}
end

until (functional API)

The five forms above are macros, which is what gives them their case/cond/with-flavored syntax. When the condition is computed at runtime or built dynamically, the functional until/2 is a better fit: it is the non-macro counterpart of wait/2, taking a zero-arity function that it re-invokes until it returns a truthy value.

Unlike wait/2, which returns the bare truthy/falsy value, until/2 returns a tagged result ({:ok, value} or {:timeout, last_value}) so that success and timeout are always unambiguous — the idiomatic shape for a functional API, where there is no native construct to mirror.

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

It accepts the same options as the macro forms, and has an until!/2 variant that raises WaitForIt.TimeoutError on timeout (returning the bare value on success).

Options

All forms of waiting accept the same options:

Option Default Description
:timeout 5_000 total time to wait, in milliseconds, before giving up (or :infinity to wait indefinitely)
:interval 100 polling interval, in milliseconds, between re-evaluations (alias: :frequency)
:pre_wait 0 delay before the first evaluation, in milliseconds
:signal disable polling and re-evaluate only when the named signal is received

See the Polling-based waiting and Signal-based waiting sections below for the :interval and :signal options. The :interval option may also be a WaitForIt.Backoff function for exponential or custom backoff.

Waiting indefinitely {: .info}

timeout: :infinity waits until the condition is met, however long that takes. Because such a wait can never time out, none of the timeout behavior applies: a ! variant never raises, an else clause never runs, and until/2 never returns {:timeout, last_value}. The wait blocks its process until the condition is met or the process dies, so 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 the process down.

:frequency is now :interval {: .info}

The :frequency option has been renamed to :interval, which more accurately describes a time value in milliseconds. :frequency continues to work as an alias and is slated for removal in a future major version. If both are given, :interval takes precedence.

Timeout behavior

There is really only one rule to learn here:

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. A case_wait that times out raises CaseClauseError for the same reason a case does when no clause matches; a with_wait that times out returns the last unmatched value for the same reason a with does; and so on. If you already know how the native construct behaves when its expression doesn't match, you already know how the waiting form behaves when it gives up — there is nothing WaitForIt-specific to memorize.

Two consistent additions sit on top of that rule:

The table below is reference, not new rules — each row is just the rule above made concrete:

Construct Native counterpart Counterpart when nothing matches On timeout (no else) With else Bang variant
wait/2 truthiness / polling if (yields the falsy value) returns the last falsy value (no else) TimeoutError
match_wait/3 = (match operator) raises MatchError raises MatchError (no else) TimeoutError
case_wait/3 case raises CaseClauseError raises CaseClauseError evaluates else TimeoutError
cond_wait/2 cond raises CondClauseError raises CondClauseError evaluates else TimeoutError
with_wait/3 with returns the unmatched value returns the last value evaluates else TimeoutError

The forms differ from one another only because their native counterparts differ — =, case, cond, and with themselves disagree about whether an unmatched value raises or is returned. WaitForIt deliberately inherits that behavior rather than papering over it, so that each form stays a faithful, waiting version of the construct you already reach for.

with_wait and the <~ clause {: .info}

with_wait/3 is the one form with a wrinkle, because its <~ (wait-for-match) clauses add something with has no equivalent for. A <~ clause that never matches before the timeout flows to the else clause if one is present (otherwise the last value is returned), and with_wait!/3 raises WaitForIt.TimeoutError for it. Ordinary <- clauses behave exactly as they do in a native with. See the Composing waits guide for details.

Waitable expressions and waiting conditions

A waitable expression is any Elixir expression that can be evaluated one or more times to produce a value. A waiting condition decides, from that value, whether to keep waiting or to halt with a result. For wait/2 the waiting condition is implicit (the truthiness of the expression); for case_wait/3 and with_wait/3 it is the case clauses or <~ patterns; for cond_wait/2 it is the truthiness of each branch.

Because a waitable expression is re-evaluated until its waiting condition is met, idempotent expressions are of little use — they would either halt immediately or never halt. It is expected that the value may change on each re-evaluation, and that evaluation may have side effects. Any such side effects must be safe to repeat, since the expression may be evaluated an indeterminate number of times while waiting.

Polling-based waiting

By default, WaitForIt polls: it re-evaluates the waitable expression at a fixed interval until the waiting condition is met or the timeout elapses. The interval is controlled by the :interval option (default 100 ms; the legacy alias :frequency is also accepted).

The :interval option may also be a 1-arity function of the attempt number, which enables backoff strategies — for example, polling less aggressively as time goes on so as not to hammer a struggling dependency. See WaitForIt.Backoff for ready-made strategies such as exponential backoff with jitter.

Signal-based waiting

Signal-based waiting removes the polling loop: instead of re-checking on a timer, a waiter blocks until it receives a signal telling it to re-evaluate. Opt in with the :signal option, naming a signal (any term, typically an atom), and have the code that changes the condition call signal/1.

Imagine a producer-consumer problem in which a consumer waits for items to appear in a buffer while a separate producer occasionally places items in the buffer:

# CONSUMER process
WaitForIt.wait(Buffer.count() >= 4, signal: :buffer_filled)
# PRODUCER process — after putting some things in the buffer, signal waiters
Buffer.put(item)
WaitForIt.signal(:buffer_filled)

Both sides share the same signal name, which binds the producer to the consumer. A signal does not mean the condition is now satisfied — only that waiters should re-evaluate. The wait halts when its condition is met, or continues until the next signal or the timeout.

See the Polling vs signaling guide for guidance on choosing between the two modes.

Telemetry

Every wait emits :telemetry events under the [:wait_for_it, :wait] prefix — :start, :stop, and :exception — so you can observe how long waits take, how many evaluations they require, and how often they time out. The :stop event reports the wait duration, the number of evaluations, and whether the wait :matched or hit a :timeout.

See the Telemetry guide for the full measurement and metadata reference, plus examples of attaching handlers and wiring up Telemetry.Metrics.

Using WaitForIt in tests

Tests are one of the most common places to wait on asynchronous work, and they get a dedicated module. WaitForIt.Test provides assert_eventually/2, refute_eventually/2, and assert_always/2, which wait and re-evaluate exactly as the forms above do but, on timeout, fail with a regular ExUnit.AssertionError carrying the source expression and the last value seen. The waiting forms themselves work in tests too, when you want their precise return values or timeout semantics.

See the Waiting in tests guide for a full walkthrough.

A note on "catch-all" clauses

It is common to include "catch-all" clauses in normal case/2 and cond/1 expressions — a final _ clause, or a final always-truthy true condition. When using case_wait/3 and cond_wait/2, avoid such catch-all clauses: because they always match, they would halt the wait on the very first evaluation. Use an else clause instead, which is only evaluated on timeout and lets you customize the behavior and return value when a wait gives up.

Installation

Add wait_for_it to your dependencies in mix.exs:

def deps do
[
{:wait_for_it, "~> 2.6"}
]
end

Then run mix deps.get. WaitForIt's only dependency is :telemetry. Its application starts a small supervision tree used by signal-based waiting; polling needs nothing started.

The waiting macros read best without parentheses, and WaitForIt exports formatter rules that keep mix format from adding them. Add WaitForIt to import_deps in your .formatter.exs:

[
import_deps: [:wait_for_it],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

Where to go from here

Full documentation is on HexDocs. The guides read well in order, and each one ends with a link to the next:

License

Apache License 2.0. See LICENSE.