Retry a failing step
This guide retries a flow step that fails now and then, with backoff, and gives it a time limit.
A step that calls something flaky can be retried before its failure fails the run. The retry policy goes on the edge into the step.
Before you start
Section titled “Before you start”- A pack on a runtime such as
kindgi dev, with Kindgi 0.1's CLI. - A step that's safe to run twice, as the caution in The policy explains.
Retry a step
Section titled “Retry a step”Ask your coding agent: "I want a flow that reserves an order's stock at the
warehouse, and tries again while the warehouse is busy." Kindgi's skills in
your pack tell it how (How it knows Kindgi).
For reference, the tool and the flow look like this. acme.reserve-stock
calls a warehouse that is busy for the first two calls per order, and the
flow's edge into the step retries it:
import { defineTool } from '@kindgi/sdk/define';import type { ToolId } from '@kindgi/sdk/types';import { z } from 'zod';
// The warehouse API is busy for the first two calls per order.const calls = new Map<string, number>();
const defined = defineTool({ id: 'acme.reserve-stock' as ToolId, description: 'Reserves the stock for an order at the warehouse.', version: '0.1.0', input: z.object({ orderId: z.string() }), output: z.object({ orderId: z.string(), reserved: z.boolean(), attempt: z.number().int() }), effects: [], handler: async ({ orderId }) => { const attempt = (calls.get(orderId) ?? 0) + 1; calls.set(orderId, attempt); if (attempt < 3) throw new Error('Warehouse busy (503)'); return { orderId, reserved: true, attempt }; },});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({ id: 'acme.reserve-order', version: '0.1.0', name: 'Reserve an order', description: 'Reserves the stock for an order, retrying while the warehouse is busy.', nodes: [{ id: 'reserve', kind: 'tool', ref: 'acme.reserve-stock' }], edges: [ { id: 'e1', from: '$start', to: 'reserve', policy: { retry: { maxAttempts: 4, delayMs: 500, backoff: 'exponential', maxDelayMs: 5_000 }, timeoutMs: 10_000, }, }, { id: 'e2', from: 'reserve', to: '$end' }, ],});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;from pydantic import BaseModel, Field
from kindgi import tool
# The warehouse API is busy for the first two calls per order.calls: dict[str, int] = {}
class ReserveInput(BaseModel): order_id: str = Field(alias="orderId")
class Reserved(BaseModel): order_id: str = Field(alias="orderId") reserved: bool attempt: int
@tool(id="acme.reserve-stock")def reserve_stock(input: ReserveInput) -> Reserved: """Reserves the stock for an order at the warehouse.""" attempt = calls.get(input.order_id, 0) + 1 calls[input.order_id] = attempt if attempt < 3: raise RuntimeError("Warehouse busy (503)") return Reserved(orderId=input.order_id, reserved=True, attempt=attempt)from kindgi import Flow
reserve_order = Flow( id="acme.reserve-order", version="0.1.0", name="Reserve an order", description="Reserves the stock for an order, retrying while the warehouse is busy.", nodes=[{"id": "reserve", "kind": "tool", "ref": "acme.reserve-stock"}], edges=[ { "id": "e1", "from": "$start", "to": "reserve", "policy": { "retry": {"maxAttempts": 4, "delayMs": 500, "backoff": "exponential", "maxDelayMs": 5_000}, "timeoutMs": 10_000, }, }, {"id": "e2", "from": "reserve", "to": "$end"}, ],)package acme.tools;
import com.kindgi.pack.Tool;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;
public final class ReserveStock { public record Input(String orderId) {}
public record Output(String orderId, boolean reserved, int attempt) {}
// The warehouse API is busy for the first two calls per order. private static final Map<String, Integer> CALLS = new ConcurrentHashMap<>();
public static final Tool<Input, Output> TOOL = Tool.define("acme.reserve-stock") .description("Reserves the stock for an order at the warehouse.") .version("0.1.0") .input(Input.class) .output(Output.class) .handler((input, ctx) -> { int attempt = CALLS.merge(input.orderId(), 1, Integer::sum); if (attempt < 3) throw new IllegalStateException("Warehouse busy (503)"); return new Output(input.orderId(), true, attempt); });
private ReserveStock() {}}package acme.flows;
import com.kindgi.pack.Flow;import java.util.Map;
public final class ReserveOrder { public static final Flow FLOW = Flow.define("acme.reserve-order") .version("0.1.0") .set("name", "Reserve an order") .set("description", "Reserves the stock for an order, retrying while the warehouse is busy.") .node(Map.of("id", "reserve", "kind", "tool", "ref", "acme.reserve-stock")) .edge(Map.of("id", "e1", "from", "$start", "to", "reserve", "policy", Map.of( "retry", Map.of("maxAttempts", 4, "delayMs", 500, "backoff", "exponential", "maxDelayMs", 5_000), "timeoutMs", 10_000))) .edge("e2", "reserve", "$end") .build();
private ReserveOrder() {}}package acme.tools
import com.kindgi.pack.scaladsl._import java.util.concurrent.ConcurrentHashMap
object ReserveStock { final case class Input(orderId: String) final case class Output(orderId: String, reserved: Boolean, attempt: Int)
// The warehouse API is busy for the first two calls per order. private val calls = new ConcurrentHashMap[String, Integer]()
val tool: Tool[Input, Output] = Tool[Input, Output]("acme.reserve-stock") .description("Reserves the stock for an order at the warehouse.") .version("0.1.0") .handler { (in, _) => val attempt: Int = calls.merge(in.orderId, 1, (a, b) => a + b) if (attempt < 3) throw new IllegalStateException("Warehouse busy (503)") Output(in.orderId, reserved = true, attempt) }}package acme.flows
import com.kindgi.pack.scaladsl._
object ReserveOrder { val flow: Flow = Flow("acme.reserve-order") .version("0.1.0") .set("name", "Reserve an order") .set("description", "Reserves the stock for an order, retrying while the warehouse is busy.") .node(Map("id" -> "reserve", "kind" -> "tool", "ref" -> "acme.reserve-stock")) .edge(Map("id" -> "e1", "from" -> "$start", "to" -> "reserve", "policy" -> Map( "retry" -> Map("maxAttempts" -> 4, "delayMs" -> 500, "backoff" -> "exponential", "maxDelayMs" -> 5000), "timeoutMs" -> 10000))) .edge("e2", "reserve", "$end") .build()}Run it:
pnpm exec kindgi runs start --flow=acme.reserve-order --input='{"orderId":"A-200"}'uv run kindgi runs start --flow=acme.reserve-order --input='{"orderId":"A-200"}'./kindgiw runs start --flow=acme.reserve-order --input='{"orderId":"A-200"}'./kindgiw runs start --flow=acme.reserve-order --input='{"orderId":"A-200"}'--flow is the flow, and --input its input. It prints the run, which
completed at the third attempt:
{ … "status": "completed", … "output": { "attempt": 3, "orderId": "A-200", "reserved": true }, …}The journal, which kindgi runs journal <run-id> prints, has each retry. It
shows the error that caused it and the wait before the next attempt:
{"sequence": 2, "kind": "step.started", "nodeId": "reserve", "payload": {"input": {"orderId": "A-200"}}, …}{"sequence": 3, "kind": "step.retry-scheduled", "nodeId": "reserve", "payload": {"nodeId": "reserve", "attempt": 1, "nextDelayMs": 500, "previousError": "handler-error: Tool \"acme.reserve-stock\" handler threw: … Warehouse busy (503)"}, …}{"sequence": 4, "kind": "step.retry-scheduled", "nodeId": "reserve", "payload": {"nodeId": "reserve", "attempt": 2, "nextDelayMs": 1000, "previousError": "handler-error: Tool \"acme.reserve-stock\" handler threw: … Warehouse busy (503)"}, …}{"sequence": 5, "kind": "step.completed", "nodeId": "reserve", "payload": {"output": {"attempt": 3, "orderId": "A-200", "reserved": true}}, …}The policy
Section titled “The policy”policy.retry:
maxAttempts: attempts in all, the first one included, at most 10.delayMs: the wait before the first retry, 0 by default.backoff: how the wait grows.fixed, the default: the same wait each time.linear:delayMs× the retry's number.exponential: doubling each time, 500, 1000, 2000 ms here. It needsmaxDelayMs, the longest wait.
When the last attempt fails, the step fails and so does the run, with the last error.
policy.timeoutMs limits each attempt. An attempt that takes longer is
stopped and counts as a failed attempt. Without retries, the step fails with
reason: "timeout":
{"sequence": 3, "kind": "step.failed", "nodeId": "reserve", "payload": {"reason": "timeout", "limitMs": 5, "message": "Handler for node \"reserve\" exceeded timeout of 5ms", "elapsedMs": 68}, …}That entry came from timeoutMs: 5, set that low to show it.
Where a policy applies
Section titled “Where a policy applies”A policy applies to the step its edge leads to, when that step has exactly one
incoming edge. A step that joins branches, with several incoming edges,
ignores the policies on them. The other policy fields, concurrencyKey and
priority, are in the flow schema.