Base module for workflow steps that call an LLM API.
Extends Baton.Worker with LLM-appropriate defaults:
- Configurable
timeout/1— defaults to Baton's configured ten-minute cap, overridable per worker with the:timeoutoption (in milliseconds). LLM calls can run long, so workers that need more time should explicitly declare a generous cap — for exampleuse Baton.LLMWorker, timeout: :timer.minutes(5). :llmqueue — dedicated queue so LLM jobs don't compete with fast background jobs for concurrency slots- Jitter backoff — spreads retries across a window to avoid thundering herd against the LLM API rate limiter when multiple jobs fail simultaneously
- Idempotency — inherited from
Baton.Worker; a retried job that already stored a result returns it immediately without re-calling the API - Automatic stats recording — if your result map includes an
"llm_usage"key, it is stripped out and written toworkflow_step_statsautomatically
Usage
defmodule MyApp.Workers.Summarize do
use Baton.LLMWorker
@impl true
def perform_workflow(%Oban.Job{args: args} = job) do
start = System.monotonic_time(:millisecond)
case MyApp.LLM.complete(build_prompt(args)) do
{:ok, response} ->
latency = System.monotonic_time(:millisecond) - start
{:ok, %{
# Your actual result — passed to downstream steps
text: response.text,
# Picked up automatically by LLMWorker, stripped from result,
# written to workflow_step_stats. Never seen by downstream steps.
llm_usage: %{
model: response.model,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_read_tokens: response.usage.cache_read_input_tokens,
cache_write_tokens: response.usage.cache_creation_input_tokens,
latency_ms: latency
}
}}
{:error, %{status: 429}} ->
{:snooze, 30}
{:error, reason} ->
{:error, reason}
end
end
endOverriding defaults
All Oban.Worker options can still be overridden at the use site:
use Baton.LLMWorker,
max_attempts: 5,
timeout: :timer.minutes(10),
priority: 1Queue config
config :baton, Oban,
queues: [default: 20, llm: 5]Backoff behaviour
Exponential backoff with ±50% uniform jitter, driven by how many times the job has genuinely failed:
- 1st failure → retry in ~8–24s
- 2nd failure → retry in ~12–34s
- 3rd failure → retry in ~21–63s
- 4th failure → retry in ~40–118s
Failures, not attempts. Baton waits on dependencies by snoozing, and Oban
counts a snooze as an attempt — a step deep in a sequential fan-out can
reach attempt 70 before it first executes. Reading attempt here would
price that step's first failure as its 71st and defer the retry for days.
See Baton.Backoff.
A flow node overrides this curve entirely by declaring its own
retry_backoff_seconds (Baton.Flow.NodeSpec) — backoff/1 checks
Baton.Backoff.node_backoff/1 first and only falls back to the jittered
curve above when the node left it unset. This is the exponential curve
applied to every LLM flow node before that existed, and still the
default for a job whose failure might mean something is actually wrong
(a truncation, a malformed response). A node whose guards resample often
against cheap, expected misses is the case for opting out of it.
Summary
Functions
Exponential backoff with uniform jitter.
Functions
@spec jittered_backoff(Oban.Job.t() | pos_integer()) :: pos_integer()
Exponential backoff with uniform jitter.
Given a job, the curve is driven by how many times it has genuinely failed,
not by attempt — Baton's dependency waiting snoozes, and a snooze
increments attempt without the job having run. See Baton.Backoff.
The integer form is the raw curve, exposed so it can be tested directly.