Current section

Files

Jump to
cyclium lib cyclium strategy retry.ex
Raw

lib/cyclium/strategy/retry.ex

defmodule Cyclium.Strategy.Retry do
@moduledoc """
Lightweight retry helper for strategies.
Tracks attempt counts inside the strategy's state map (under `:__retries`)
so strategies can declaratively retry failed steps without hand-rolling counters.
## Usage
alias Cyclium.Strategy.Retry
# On synthesis success — reset the counter
def handle_result(state, %{kind: :synthesis}, {:ok, result}) do
{:ok, state |> Retry.reset(:synthesis) |> Map.put(:assessment, result)}
end
# On synthesis failure — retry up to 3 times with 2s backoff
def handle_result(state, %{kind: :synthesis} = step, {:error, _}) do
case Retry.check(state, step, max_attempts: 3, backoff_ms: 2_000) do
{:retry, new_state} -> {:retry, new_state}
{:give_up, _attempts, new_state} -> {:abort, "synthesis_failed_after_retries"}
end
end
The strategy's `next_step/2` doesn't need special logic — when `handle_result`
returns `{:retry, state}`, the runner calls `do_loop` which calls `next_step`
again. The strategy should naturally re-emit the same step type based on its
phase/state (which hasn't changed, only `:__retries` was updated).
"""
@retry_key :__retries
@doc """
Evaluates whether a failed step should be retried.
Returns:
- `{:retry, updated_state}` — attempt recorded, caller should return `{:retry, state}` from `handle_result`
- `{:give_up, attempt_count, updated_state}` — max attempts exhausted, counter reset
## Options
- `:max_attempts` — total attempts including the original (default `3`)
- `:backoff_ms` — milliseconds to sleep before retry, `0` for immediate (default `0`)
- `:step_key` — key for tracking this retry series (default `step.kind`)
"""
def check(state, step, opts \\ []) do
max = Keyword.get(opts, :max_attempts, 3)
backoff = Keyword.get(opts, :backoff_ms, 0)
key = Keyword.get(opts, :step_key, step_key(step))
retries = Map.get(state, @retry_key, %{})
attempts = Map.get(retries, key, 0) + 1
if attempts < max do
if backoff > 0, do: Process.sleep(backoff)
new_retries = Map.put(retries, key, attempts)
{:retry, Map.put(state, @retry_key, new_retries)}
else
new_retries = Map.delete(retries, key)
{:give_up, attempts, Map.put(state, @retry_key, new_retries)}
end
end
@doc """
Resets retry tracking for a given key. Call on success to clear the counter
so a later failure of the same step kind gets fresh attempts.
"""
def reset(state, key) do
retries = Map.get(state, @retry_key, %{})
Map.put(state, @retry_key, Map.delete(retries, key))
end
@doc """
Resets all retry tracking.
"""
def reset_all(state) do
Map.delete(state, @retry_key)
end
defp step_key(%{kind: kind}), do: kind
defp step_key(step) when is_map(step), do: Map.get(step, :kind, :unknown)
end