The payments recipe for idempotency is well worn: hash the request body, store the hash, reject anything whose hash you have seen before. I was wiring that into an agent harness whose tool list includes charge_card. The harness had no tool-call logging yet, so with no retry traffic to check against I replayed it offline against a corpus of retried calls I wrote by hand. It caught 12 of 96.
The bad assumption is in the first step. An HTTP client retries the same bytes it sent the first time. When a tool call times out and the harness re-prompts the model, the model generates the arguments again: key order shuffles, 4200 comes back as 4200.0, "USD" comes back as "usd", an optional field omitted last turn arrives as an explicit null. Same intent, different bytes, different hash, and the second charge sails straight through.
What you’ll build
A tool-call idempotency keyer in four escalating versions, and a replay harness that scores each one on two axes that pull against each other:
- Recall: does the retry of a call derive the same key as the original?
- False merges: do two calls that must stay distinct collapse onto one key?
The setup is an offline replay against a hand-written corpus. Nothing was deployed to Bedrock or DynamoDB, no live model was called, and the dollar figures are arithmetic over published list prices.
What you need
- Python 3.10, stdlib
json,hashlib,re,unicodedata tiktokenfor token counting:pip install tiktoken --break-system-packages- Published August 2026 list prices for the cost section: Claude Sonnet on Amazon Bedrock at $3 per million input tokens and $15 per million output tokens; DynamoDB on-demand at $1.25 per million write request units
Where the key goes
The keyer sits between the model’s tool-call block and the tool executor, before any side effect fires.
model turn N
┌──────────────┐ tool_use block ┌──────────────────┐
│ LLM │ ────────────────► │ derive_key() │
└──────────────┘ └─────────┬────────┘
▲ │
│ tool_result ▼ conditional write
│ ┌───────────────────────────┐
│ │ idempotency store │
│ │ PutItem if_not_exists │
│ └─────┬───────────────┬─────┘
│ seen │ │ new
│ ▼ ▼
│ ┌───────────────┐ ┌───────────────┐
├─────────────────────┤ replay stored │ │ execute tool │
│ │ result │ │ (side effect) │
│ └───────────────┘ └───────┬───────┘
│ │
└───────────────────────────────────────────────┘
persist result, then return
PutItem with attribute_not_exists(pk) either succeeds (you own this call, go execute it) or throws, in which case someone already ran it and you replay the stored result.
Building the keyer
Step 1: the corpus. Twelve base tool calls across six tools (charge_card, create_ticket, send_email, book_slot, refund, provision_vm). Eight mutations, each modelling one class of argument drift I expect from a re-prompted model: key reordering, int-to-float, case folding, dropping nulls, adding a null field, timestamp sub-second precision, doubled whitespace, and numbers emitted as strings. Cross them and you get 96 retry pairs. The original side always uses default json.dumps; the retry side rotates through three serializer settings, because indentation drifts between turns too.
Then 24 pairs that must not merge: 12 where one value genuinely changed, and 12 where the agent deliberately issues the byte-identical call twice.
Step 2: strategy A, hash the bytes. The imports here cover all four listings.
import json, hashlib, re, unicodedata
def _h(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:32]
def key_raw(tool, raw, ctx=None):
return _h(tool + "|" + raw)
Step 3: strategy B, canonical JSON. Parse, sort keys, tight separators, hash that. Serialization drift and key order stop mattering. Value drift still does.
def key_canonical(tool, raw, ctx=None):
obj = json.loads(raw)
return _h(tool + "|" + json.dumps(obj, sort_keys=True, separators=(",", ":")))
Step 4: strategy C, canonicalize then normalize values. Coerce 4200.0 to 4200, parse numeric strings back to numbers, casefold string values, NFC-normalize and collapse whitespace, truncate timezone-suffixed ISO-8601 timestamps to the second, and drop null-valued keys so {"cc": null} and {} agree.
ISO = re.compile(
r"^(\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2})(?:\.\d+)?([Zz]|[+-]\d{2}:\d{2})$")
def _norm(v): # branches are exhaustive for json.loads output
if v is None or isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return int(v) if float(v).is_integer() else float(v)
if isinstance(v, str):
s = re.sub(r"\s+", " ", unicodedata.normalize("NFC", v).strip())
if (m := ISO.match(s)):
head = m.group(1)[:10] + "T" + m.group(1)[11:]
tz = m.group(2)
return head + ("Z" if tz in ("Z", "z") else tz)
if re.fullmatch(r"-?\d+", s):
return int(s)
if re.fullmatch(r"-?\d+\.\d+", s):
f = float(s)
return int(f) if f.is_integer() else f
return s.casefold()
if isinstance(v, list):
return [_norm(x) for x in v]
return {k: _norm(x) for k, x in v.items() if x is not None}
def key_normalised(tool, raw, ctx=None):
obj = {k: _norm(v) for k, v in json.loads(raw).items() if v is not None}
return _h(tool + "|" + json.dumps(obj, sort_keys=True, separators=(",", ":")))
Step 5: strategy D, scope the key. Hash C’s output together with the run ID and the step index of the agent loop. A retry sits at the same step of the same run; a deliberate repeat sits at a later step.
def key_scoped(tool, raw, ctx=None):
ctx = ctx or {}
inner = key_normalised(tool, raw)
return _h(f"{ctx.get('run_id','-')}|{ctx.get('step','-')}|{inner}")
What broke
Strategy C came in at 94 of 96 on the first run. Both misses were book_slot under the case-folding mutation, which had lowercased the whole timestamp: 2026-08-11T09:00:00Z became 2026-08-11t09:00:00z. My ISO regex demanded a capital T and Z, so the mutated side failed to match, fell through to the generic string branch, and got casefolded. The original side matched and came back with capitals. Two rules in one function, and the casefold rule silently disabled the timestamp rule. Accepting either case took C from 94/96 to 96/96. That was a missing case in my spec rather than a slip of the keyboard, and the way to find the rest of them is to test each rule against every other rule’s output.
The regex still only fires on timestamps carrying a timezone suffix: naive 2026-08-11T09:00:00.000 and 2026-08-11T09:00:00 derive different keys.
The second failure is structural. Strategy C merged all 12 deliberate-repeat pairs. Two identical charge_card calls for the same customer and amount are indistinguishable from a retry when arguments are all you look at, and no normalization fixes that, because the deciding information lives in the harness’s control flow. D drops those false merges to 0 by putting the step index in the key, which requires your harness to expose a stable step counter.
Worse, the numeric-string rule collides zero-padded identifiers: {"zip": "01234"} and {"zip": "1234"} derive the same key under C, which puts every padded order ID and phone extension you pass as a string at risk. My 12 value-changed pairs never probe this, so the clean 0/12 column below is weaker evidence than it looks.
The numbers
96 retry pairs, 24 distinct pairs, sha256 truncated to 128 bits, Python 3.10.12 on a 4-vCPU Linux container. Whole harness: 1.8 s wall clock.
| Keyer | Retries caught | FM: value changed | FM: deliberate repeat | µs/key |
|---|---|---|---|---|
| A raw-bytes hash | 12/96 (12.5%) | 0/12 | 12/12 | 0.31 |
| B canonical JSON | 51/96 (53.1%) | 0/12 | 12/12 | 2.08 |
| C canonical + normalize | 96/96 (100%) | 0/12 | 12/12 | 4.84 |
| D = C + (run_id, step) | 96/96 (100%) | 0/12 | 0/12 | 5.23 |
Timings are the median of seven runs of 20,000 derivations each; they are hardware-specific, so re-measure on yours. Going from A to D costs 4.92 µs per tool call, against a tool invocation that includes at least one network round trip.
For the money, I counted a representative charge_card round trip with tiktoken. One caveat: o200k_base is OpenAI’s encoding, and Anthropic publishes no offline tokenizer I could substitute. The counts below are a stand-in, the true Claude counts will differ, and every dollar figure that follows is an order-of-magnitude estimate rather than a billing prediction. On that encoding, the tool_use block is 74 tokens billed as output and the tool_result block is 124 tokens billed as input. At $15/M out and $3/M in, one duplicate costs $0.001482 on first billing. Both blocks then sit in the transcript and get re-sent as input on every later turn, so at 10 further turns it reaches $0.007422.
Scale that to 1 million tool calls at a 3% duplicate rate and the wasted token spend is $222.66, against $1.25 for a DynamoDB conditional write on every single call. That $222.66 is an extrapolation: the duplicate rate and the 10-turn transcript depth are inputs I chose, and I have not observed either in production. Sweeping 1–5% duplicates and 5–20 further turns, the waste-to-store ratio runs from 35:1 to 534:1. Every point in that band says buy the store, but do not quote my middle number as yours.
What I’d take away
Canonical JSON is the obvious fix and it only got 53% of my retries, because the drift that matters is in the values, not the formatting. Budget for a value normalizer.
Then accept a hard ceiling: retry and deliberate repeat are indistinguishable from the payload alone. If your framework does not hand you a stable (run_id, step) pair, close that gap first; no amount of normalizer work substitutes for it.
Every rule you add widens the surface where rules interact and where identifiers get mangled. The zero-padding collision cost me nothing only because my corpus was too thin to expose it.
Where I’d go next
Two directions. First, add the missing tool-call logging and replace my hand-written mutations with pairs captured under induced timeouts; my corpus encodes guesses about how models drift and I want to know which are wrong. Second, put a TTL on the store, since a record that lives forever will eventually block a legitimate call landing on the same step index of a re-run.
Try it against your own traffic
The four keyers above are complete; paste them into a scratch file and they run. The corpus has to be yours. Log every tool call your harness emits with its (run_id, step), induce some timeouts, and pull the pairs where the same step fired twice. Run those through key_canonical and key_normalised and see how far apart the recall numbers land. If your gap is narrower than my 53% to 100%, tell me what your harness does differently.
Prompted by ByteByteGo’s A Detailed Guide to Idempotency, Delivery Semantics, and Deduplication (30 July 2026). Pricing from Amazon Bedrock and Amazon DynamoDB.
Leave a Reply