DEV Community

Cover image for Idempotent Tools in Node: Safe Retries When the Model Calls Twice
Gabriel Anhaia
Gabriel Anhaia

Posted on

Idempotent Tools in Node: Safe Retries When the Model Calls Twice


An agent turn fails after the tool ran. The network dropped between
your charge_card call succeeding and the response reaching the
model. Your retry logic does what retry logic does: it replays the
turn.

The card is charged twice.

Nothing in that sequence is a bug in the ordinary sense. The tool
worked. The retry worked. What was missing is the property that makes
replaying safe — and in an agent loop there are three separate places
a replay can originate, which is more than most retry code accounts
for.

Where the duplicate comes from

Your own retry. A 500 or a timeout from the model provider, and
you re-send the same messages. If the previous attempt's tool ran
before the failure, it runs again.

The model calling twice. It gets a tool result it finds
unconvincing — empty, ambiguous, an error it reads as transient — and
issues the same call again in the next turn. This one is not a bug
anywhere; it is the model doing what a person would do.

Resume after a crash. The process dies mid-turn. A durable agent
restarts from its last checkpoint, which is before the tool call, and
replays it.

All three produce the same shape: the same logical operation
attempted more than once. The fix is not to prevent duplicates but to
make them harmless.

The type-level split

Start by making the two kinds of tool distinguishable to the
compiler, because most of them do not need any of this machinery.

type ReadTool<S extends z.ZodType, R> = {
  kind: "read";
  name: string;
  schema: S;
  run: (a: z.infer<S>, ctx: Ctx) => Promise<R>;
};

type WriteTool<S extends z.ZodType, R> = {
  kind: "write";
  name: string;
  schema: S;
  run: (a: z.infer<S>, ctx: Ctx & { idem: string }) => Promise<R>;
};

export type AnyTool = ReadTool<any, any> | WriteTool<any, any>;
Enter fullscreen mode Exit fullscreen mode

The asymmetry is deliberate: a write handler receives idem in its
context, a read handler does not. That single difference means a
write tool cannot be written without the author noticing there is an
idempotency key to use, and a read tool cannot accidentally depend on
one.

const search = { kind: "read", /* ... */ } satisfies ReadTool<...>;
const charge = { kind: "write", /* ... */ } satisfies WriteTool<...>;
Enter fullscreen mode Exit fullscreen mode

Retrying a read is free. Retrying a write is the whole problem. Now
tool.kind tells you which you are holding.

The key must be the operation, not the attempt

The obvious key is the tool_use_id from the model's block. It is
convenient and it is wrong for the second failure mode.

tool_use_id identifies this attempt. When the model issues the
same call again in a later turn, it is a new block with a new id — so
an id-keyed dedupe sees two different operations and lets both
through.

Key on what the operation is:

import { createHash } from "node:crypto";

export function idemKey(
  runId: string,
  toolName: string,
  args: unknown,
): string {
  const canonical = JSON.stringify(args, Object.keys(args as object).sort());
  const h = createHash("sha256")
    .update(`${runId}\0${toolName}\0${canonical}`)
    .digest("hex")
    .slice(0, 32);
  return `idem:${h}`;
}
Enter fullscreen mode Exit fullscreen mode

runId scopes it to one agent run, so the same user charging the
same amount tomorrow is a different operation. Sorted keys make the
hash stable regardless of property order. And the null separators
stop ("ab", "c") and ("a", "bc") colliding.

Scoping to the run is a decision worth making consciously. Scope too
narrowly and legitimate repeats within a run get blocked. Scope too
broadly and a user genuinely buying the same thing twice is refused.
Run-scoped is the right default for agents; adjust where your domain
says otherwise.

The same logical operation arriving from three sources, collapsing to one idempotency key.

The store

Three states, not two. "In flight" is the one that gets forgotten,
and it is what protects you against concurrent duplicates rather than
sequential ones.

export type Entry =
  | { status: "running"; startedAt: number }
  | { status: "done"; result: unknown }
  | { status: "failed"; error: string };

export interface IdemStore {
  claim(key: string, ttlMs: number): Promise<Entry | null>;
  complete(key: string, result: unknown, ttlMs: number): Promise<void>;
  fail(key: string, error: string): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

claim returns null if it won the claim and you should execute, or
the existing entry if someone got there first. On Redis that is one
atomic operation:

export class RedisIdem implements IdemStore {
  constructor(private redis: Redis) {}

  async claim(key: string, ttlMs: number) {
    const running: Entry = { status: "running", startedAt: Date.now() };
    const ok = await this.redis.set(
      key, JSON.stringify(running), "PX", ttlMs, "NX",
    );
    if (ok === "OK") return null;
    const raw = await this.redis.get(key);
    return raw ? (JSON.parse(raw) as Entry) : null;
  }

  async complete(key: string, result: unknown, ttlMs: number) {
    const done: Entry = { status: "done", result };
    await this.redis.set(key, JSON.stringify(done), "PX", ttlMs);
  }

  async fail(key: string, error: string) {
    await this.redis.del(key);
  }
}
Enter fullscreen mode Exit fullscreen mode

SET NX PX is the atomic claim — set only if absent, with an
expiry. Read-then-write would leave a window where two workers both
see nothing and both proceed.

fail deletes rather than recording the failure, so a genuinely
transient error can be retried. If you want failures to stick,
record them and decide by error class; deleting is the safer default.

The wrapper

export function idempotent<S extends z.ZodType, R>(
  t: WriteTool<S, R>,
  store: IdemStore,
  ttlMs = 24 * 60 * 60 * 1000,
): WriteTool<S, R> {
  return {
    ...t,
    async run(args, ctx) {
      const key = idemKey(ctx.runId, t.name, args);

      const existing = await store.claim(key, ttlMs);
      if (existing?.status === "done") {
        return existing.result as R;
      }
      if (existing?.status === "running") {
        throw new OperationInFlight(t.name);
      }

      try {
        const result = await t.run(args, { ...ctx, idem: key });
        await store.complete(key, result, ttlMs);
        return result;
      } catch (err) {
        await store.fail(key, String(err));
        throw err;
      }
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Returning the cached result on a duplicate — rather than an error —
is what makes this invisible to the model. It asked for the charge,
it gets the charge result. It never learns there was a retry, which
is correct: the operation happened exactly once and the model's view
of the world is accurate.

Applying it is a line in the registry:

const TOOLS = [
  search,
  idempotent(charge, store),
  idempotent(sendEmail, store),
] as const;
Enter fullscreen mode Exit fullscreen mode

Pass the key downstream

The wrapper stops your duplicate execution. It does not stop a
duplicate at the payment provider if your process dies between their
API succeeding and your complete call.

That is why idem is in the write handler's context — hand it to the
downstream system that has its own idempotency support:

const charge: WriteTool<typeof ChargeArgs, Receipt> = {
  kind: "write",
  name: "charge_card",
  schema: ChargeArgs,
  async run({ amount, currency, customerId }, ctx) {
    const res = await stripe.paymentIntents.create(
      { amount, currency, customer: customerId },
      { idempotencyKey: ctx.idem },
    );
    return Receipt.parse(res);
  },
};
Enter fullscreen mode Exit fullscreen mode

Now the guarantee holds across a crash in the gap. Most payment and
messaging APIs accept a key like this; using yours rather than
generating a fresh one is what chains the two layers together.

The idempotency key threaded from the agent loop through to the downstream provider.

Testing it

The test is more valuable than the implementation, because the
implementation looks obviously correct and the bug is in the
sequencing.

it("executes once when the same call arrives twice", async () => {
  let calls = 0;
  const t = idempotent(
    { kind: "write", name: "t", schema: Args,
      run: async () => { calls++; return { id: "r1" }; } },
    store,
  );

  const a = await t.run({ amount: 100 }, ctx);
  const b = await t.run({ amount: 100 }, ctx);

  expect(calls).toBe(1);
  expect(b).toEqual(a);
});

it("rejects a concurrent duplicate", async () => {
  const [x, y] = await Promise.allSettled([
    t.run({ amount: 100 }, ctx),
    t.run({ amount: 100 }, ctx),
  ]);
  const fulfilled = [x, y].filter((r) => r.status === "fulfilled");
  expect(fulfilled).toHaveLength(1);
});
Enter fullscreen mode Exit fullscreen mode

Sequential and concurrent are different paths through the store. A
naive read-then-write implementation passes the first test and fails
the second, which is exactly the failure that only appears under
load.

The rule

Mark every tool that changes something. Give the operation an
identity derived from what it does, not from which attempt this is.
Claim before executing, cache the result, and hand the key onward.

Then a retry is a retry rather than a second charge.


If this was useful

AI That Acts covers tools
that touch the real world — the read/write distinction, idempotency,
error results, and the guards that make an agent's side effects
something you can reason about.

AI That Acts — Tool Calling in TypeScript

Crash-resume, where this matters most, is book four. The full series
is at xgabriel.com/ai-in-typescript.

Top comments (0)