Failure-and-retry agent. handle_error/3 decides whether to retry.
A retry is a delayed event: the agent schedules a timer, goes idle,
and handle_event/2 returns {:prompt, retry_text, state} when the
timer fires. The retry decision lives on agent state: attempt count,
accumulated errors, and a configurable cap.
When to reach for this
You expect transient failures (rate limits, network blips, flaky backends) and want the agent to absorb them without the manager having to notice and retry. The retry logic benefits from being stateful -- you want to count attempts, track the sequence of errors, apply backoff, and give up after a cap.
The pattern hinges on two gen_agent primitives: handle_error/3
sees every failed turn, and handle_event/2 has the same return
shape as handle_response/3, so returning {:prompt, text, state}
from the timer event self-chains a retry turn.
What it exercises in gen_agent
handle_error/3plushandle_event/2as a retry primitive: the error handler schedules, the event handler re-prompts.- Attempt counting and error accumulation on agent state.
Giving-up semantics via
{:halt, %{state | phase: :failed}}whenmax_attemptsis reached.- Backoff via a timer that calls
GenAgent.notify/2. The agent process never sleeps, sostatus/1,poll/2,interrupt/1andstop/1stay responsive during the wait. A callback that sleeps would block them, and astop/1during a sleep longer than the 5 second child shutdown timeout would kill the process beforeterminate_agent/2andterminate_session/1run. - Error classes.
:interrupted(frominterrupt/1) and:timeout(from the watchdog) halt instead of retrying. These apply to an attempt that is running. While the agent waits out a backoff it is idle andinterrupt/1is a no-op, so it does not cancel the pending retry. Send the:cancel_retryevent instead (see "Using it"). - Cancel-safe delay. The pending timer is tracked on state with
a token. A cancelled or stale timer is ignored, and
terminate_agent/2cancels it.
The pattern
One callback module. No facade needed -- the manager just starts the agent and polls status.
defmodule Retry.Agent do
use GenAgent
defmodule State do
defstruct [
:task,
:max_attempts,
:agent_name,
:base_backoff_ms,
:result,
:timer,
:token,
phase: :running,
attempts: 0,
errors: []
]
end
@impl true
def init_agent(opts) do
state = %State{
task: Keyword.fetch!(opts, :task),
max_attempts: Keyword.get(opts, :max_attempts, 3),
# GenAgent strips :name before init_agent/1, so the timer needs
# the name passed again to address the agent.
agent_name: Keyword.fetch!(opts, :agent_name),
base_backoff_ms: Keyword.get(opts, :base_backoff_ms, 1000)
}
system = "You are a persistent assistant. Answer concisely in 1-2 sentences."
backend_opts =
[
system: system,
max_tokens: Keyword.get(opts, :max_tokens, 100)
] ++ Keyword.get(opts, :backend_opts, [])
{:ok, backend_opts, state}
end
@impl true
def handle_response(_ref, response, %State{} = state) do
new_attempts = state.attempts + 1
text = String.trim(response.text)
{:halt,
%{
state
| result: text,
attempts: new_attempts,
phase: :succeeded
}}
end
# Work that was stopped on purpose is not retried.
@impl true
def handle_error(_ref, :interrupted, %State{} = state),
do: give_up(state, :interrupted, :interrupted)
def handle_error(_ref, :timeout, %State{} = state),
do: give_up(state, :timeout, :timed_out)
def handle_error(_ref, reason, %State{} = state) do
new_attempts = state.attempts + 1
new_state = %{state | attempts: new_attempts, errors: state.errors ++ [reason]}
if new_attempts < state.max_attempts do
{:noreply, schedule_retry(new_state)}
else
{:halt, %{new_state | phase: :failed}}
end
end
# The timer fired. Ignore it unless it is the one currently pending.
@impl true
def handle_event({:retry, token}, %State{phase: :waiting, token: token} = state) do
retry_prompt = "The previous attempt failed. Retry the task: #{state.task}"
{:prompt, retry_prompt, %{state | phase: :running, timer: nil, token: nil}}
end
def handle_event({:retry, _stale}, %State{} = state), do: {:noreply, state}
# Abandon a retry that is waiting out its backoff.
def handle_event(:cancel_retry, %State{phase: :waiting} = state) do
{:halt, %{cancel_timer(state) | phase: :cancelled}}
end
def handle_event(_event, %State{} = state), do: {:noreply, state}
@impl true
def terminate_agent(_reason, %State{} = state) do
cancel_timer(state)
:ok
end
defp give_up(state, reason, phase) do
{:halt,
%{
state
| attempts: state.attempts + 1,
errors: state.errors ++ [reason],
phase: phase
}}
end
# Exponential backoff: base, 2x base, 4x base, ...
defp schedule_retry(%State{} = state) do
backoff_ms = state.base_backoff_ms * Integer.pow(2, state.attempts - 1)
token = make_ref()
{:ok, timer} =
:timer.apply_after(backoff_ms, GenAgent, :notify, [state.agent_name, {:retry, token}])
%{state | phase: :waiting, timer: timer, token: token}
end
defp cancel_timer(%State{timer: nil} = state), do: state
defp cancel_timer(%State{timer: timer} = state) do
:timer.cancel(timer)
%{state | timer: nil, token: nil}
end
endUsing it
{:ok, _pid} = GenAgent.start_agent(Retry.Agent,
name: "retry-haiku",
agent_name: "retry-haiku",
backend: GenAgent.Backends.Anthropic,
task: "write a haiku about persistence",
max_attempts: 5
)
# Kick off the first attempt.
{:ok, _ref} = GenAgent.tell("retry-haiku",
"write a haiku about persistence")
# Wait for phase in [:succeeded, :failed, :interrupted, :timed_out,
# :cancelled] and read the result. While phase is :waiting the agent
# is idle and answers status/1 normally.
%{agent_state: state} = GenAgent.status("retry-haiku")
IO.inspect(%{
phase: state.phase,
attempts: state.attempts,
errors: state.errors,
result: state.result
})
GenAgent.stop("retry-haiku")To abandon a retry that is waiting out its backoff, send
GenAgent.notify("retry-haiku", :cancel_retry). interrupt/1 cannot
do this: the agent is idle during the backoff, so the interrupt is
ignored and the timer still starts another attempt. :cancel_retry
halts with phase: :cancelled and cancels the timer. A timer message
already in flight is ignored because its token no longer matches.
Testing without burning tokens
Use a local backend instead of a real one. It implements
GenAgent.Backend, fails the first N prompts with a synthetic 429
error, and then succeeds. Retry.Agent forwards :backend_opts
to it, so no credentials or network are needed.
defmodule Retry.FlakyBackend do
@behaviour GenAgent.Backend
@impl true
def start_session(opts) do
{:ok,
%{
calls: :counters.new(1, []),
fail_first: Keyword.fetch!(opts, :fail_first),
observer: Keyword.get(opts, :observer)
}}
end
@impl true
def prompt(%{calls: calls, fail_first: fail_first, observer: observer} = session, prompt) do
:counters.add(calls, 1, 1)
n = :counters.get(calls, 1)
if observer, do: send(observer, {:prompt, n, prompt, System.monotonic_time(:millisecond)})
if n <= fail_first do
{:error, {:http_error, 429, %{"error" => %{"type" => "rate_limit_error"}}}}
else
{:ok, [GenAgent.Event.new(:result, %{text: "Persistence."})], session}
end
end
@impl true
def terminate_session(%{observer: observer}) do
if observer, do: send(observer, :session_terminated)
:ok
end
endStart the agent with a short base_backoff_ms:
{:ok, _pid} = GenAgent.start_agent(Retry.Agent,
name: "retry-test",
agent_name: "retry-test",
backend: Retry.FlakyBackend,
task: "write a haiku",
max_attempts: 5,
base_backoff_ms: 10,
backend_opts: [fail_first: 2, observer: self()]
)
{:ok, _ref} = GenAgent.tell("retry-test", "write a haiku")The agent fails twice, waits 10 ms and 20 ms, then succeeds with
phase: :succeeded and attempts: 3. test/guides/retry_test.exs
compiles both modules from this guide and also covers interruption,
the watchdog, cancellation, and stop during a backoff.
Variations
- Exponential backoff with jitter. Add random jitter to the
timer delay in
schedule_retry/1:backoff_ms + :rand.uniform(500). Avoids thundering-herd when many agents retry simultaneously. - Narrower error classes. The guide already halts on
:interruptedand:timeout. Add clauses tohandle_error/3so only{:http_error, 429, _}and{:http_error, 503, _}retry and every other reason halts. - Retry budget. Instead of a count cap, a time budget: halt
if
System.monotonic_time() - state.started_atexceeds N seconds. More natural for "best effort within a deadline." - Different prompt on retry. The retry prompt could incorporate the error, e.g. "The previous attempt failed with: #{inspect(reason)}. Try a different approach." -- useful when the failure is prompt-shaped rather than transport-shaped.
- Retry with a different backend. If the first backend errors three times, swap to a fallback backend. Requires the callback module to track which backend it started with and to restart the session mid-run, which is more work than the minimal pattern shown here.