Idempotency
When a tool gets retried — by @Resilience(retry = …), by an upstream gateway, by a flaky network — the framework needs a way to make sure the side effect happens once. @Idempotent plus an IdempotencyStore is that mechanism.
This page covers when to reach for it, what the surface looks like, and which store fits which deployment.
When to use it
Idempotency protection earns its keep on actions whose side effect shouldn't repeat:
send_email(to, subject, body)— three retries shouldn't mean three emails.create_github_issue(title, body)— three retries shouldn't mean three issues.charge_card(amount)— definitely not three charges.append_to_log(msg)— duplicate entries are a debugging nightmare.
It's overhead for read-only operations (getUser, searchWeb) — skip
it there. It's also wrong for intentionally non-idempotent operations
(generateRandomId, currentTime).
The annotation
@ActionSpec(type = ActionType.WEB_SERVICE, endpoint = "https://api.example.com/email")
@Idempotent(strategy = KeyStrategy.HASH_INPUT, ttlSeconds = 86400)
public EmailResult sendEmail(String to, String subject, String body) {
return null;
}Three knobs:
| Field | Meaning | Default |
|---|---|---|
strategy | How the dedup key is derived from the call. | HASH_INPUT |
ttlSeconds | How long the cache remembers this call. | 3600 (1 hour) |
onCacheHit | What to do when a retry hits the cache. | RETURN_CACHED |
cacheFailures | Whether to cache failed outcomes too. | false |
KeyStrategy
HASH_INPUT— SHA-256 of the canonicalised input. Works when the input is the natural identity ("send THIS exact email").EXPLICIT— your tool overridesidempotencyKeyFor(input)and returns whatever string makes sense ("issue-" + title.normalize()). Use this when one input field is the de-facto identity.UUID— fresh random key per call. Propagated to upstream services that honourIdempotency-Keyheaders; client-side dedup doesn't fire because each call has a unique key.NONE— no protection, default for actions not annotated.
RetryBehavior
RETURN_CACHED— second call returns the cached outcome.RETURN_CACHED_IF_SUCCESS— second call returns cached only if the original succeeded; failed calls fall through and retry.FAIL_FAST— second call throwsIdempotencyException, surfacing the duplicate to the caller.
A worked example — state-machine
The getNextQuestion() action that drives a quiz advances a cursor on
every call. A retry that's actually a re-issue needs to skip the
side effect; a retry that's a fresh question needs to run.
@ActionSpec(
type = ActionType.LLM,
description = "Returns the next quiz question for the session, or DONE."
)
@Idempotent(strategy = KeyStrategy.EXPLICIT, ttlSeconds = 60)
public String getNextQuestion(String sessionId) {
return cursorAdvance(sessionId);
}
@Override
public String idempotencyKeyFor(Map<String, Object> input) {
// Per-turn key — the same sessionId in the same minute returns
// the same answer, but a fresh turn (new turn id passed by the
// orchestrator) gets a fresh key and runs.
return "quiz:" + input.get("sessionId") + ":" + input.get("turnId");
}HTTP Idempotency-Key header propagation
For @ActionSpec(type = WEB_SERVICE) calls on idempotent actions, the
framework injects:
POST /api/v1/emails HTTP/1.1
Idempotency-Key: 01JFVT8M7KP9X3QNJB5T9VRYC0-a3b2c1
Content-Type: application/jsonStripe, SendGrid, Twilio, GitHub all honour this header and dedup server-side. If the upstream doesn't, the client-side store still catches the duplicate before the request goes out.
Picking a store
The framework ships three implementations of IdempotencyStore:
| Store | When | Survives restart? | Cross-process? |
|---|---|---|---|
InMemoryIdempotencyStore | Default. Dev, tests, single-process production. | No | No |
RedisIdempotencyStore | Production with multiple instances. | Yes (per Redis durability config) | Yes |
PostgresIdempotencyStore | Production where you already have Postgres + want strong durability. | Yes | Yes |
RedisIdempotencyStore
Optional dependency (redis.clients:jedis — declared <optional> on
tnsai-core; consumers add it themselves):
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>7.5.0</version>
</dependency>JedisPool pool = new JedisPool("redis://prod-redis:6379");
IdempotencyStore store = new RedisIdempotencyStore(pool);
// Or with a custom key prefix when sharing one Redis cluster across
// unrelated deployments:
IdempotencyStore store = new RedisIdempotencyStore(pool, "myapp:idem:");TTL is enforced server-side via SET … EX …, so stale entries never
need a client-side sweep. Entries are JSON-encoded; a typed POJO
cached in process A and read in process B comes back as a Map/List
mosaic — re-marshal at the call site if you need the original type.
PostgresIdempotencyStore
No new dependency — uses standard JDBC:
DataSource ds = configureDataSource();
PostgresIdempotencyStore store = new PostgresIdempotencyStore(ds);
store.createTableIfMissing(); // dev / quick-startProduction deployments typically run schema migration via Flyway /
Liquibase rather than calling createTableIfMissing(). The DDL the
store expects:
CREATE TABLE idempotency_entries (
idempotency_key VARCHAR(512) PRIMARY KEY,
entry_json TEXT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE INDEX idx_idempotency_expires_at ON idempotency_entries (expires_at);Expiry is lazy — every get() filters on expires_at > now(). Run
store.purgeExpired() periodically (cron, scheduled job) to keep the
table from growing unboundedly.
The implementation uses ANSI-portable UPDATE-then-INSERT (no
dialect-specific ON CONFLICT / MERGE INTO), so it also works on
H2 in PostgreSQL mode for tests, and CockroachDB / any
Postgres-compatible engine in production.
Which one?
- Single-process app, dedup window seconds-to-minutes →
InMemoryIdempotencyStore. - Multiple framework instances behind a load balancer →
RedisIdempotencyStore. Hottest, cheapest dedup. - Operationally already on Postgres, value durability over latency →
PostgresIdempotencyStore.
Failure caching
Default: only successes are cached. A retry of a failed call falls through and re-attempts. That's usually what you want for transient failures.
Set cacheFailures = true for deterministic failures — calls that
will keep failing the same way no matter how many times you retry:
@Idempotent(strategy = KeyStrategy.HASH_INPUT,
cacheFailures = true,
onCacheHit = RetryBehavior.FAIL_FAST)
public Order placeOrder(OrderRequest req) { … }A duplicate placeOrder with the same content gets IdempotencyException
on the second call, no upstream traffic, immediate fail. Useful when
the failure itself is the dedup signal ("this order was already rejected
for fraud — don't re-submit").
Best practices
- Pair with
@Resilience(retry = …). The retry tells the framework to retry;@Idempotenttells it how to retry safely. - Match TTL to the operation's natural window. A "create issue" call's dedup window can sensibly be 24h (you wouldn't want to re-create the same issue tomorrow either); a "send notification" might be 5 minutes (after that, a re-send is intentional).
- Use
EXPLICITfor tools whose key is one stable input field.HASH_INPUTover the full input is conservative but produces a fresh key when any field changes — including fields that shouldn't define identity (request_id, current_timestamp). - Document why a tool is or isn't idempotent at the annotation:
@Idempotent(description = "Sets order to fixed status — safe to retry").
See also
- Custom Tools — authoring tools that idempotency attaches to.
- Examples —
@ToolExamplefor the LLM-facing surface. - Stripe — idempotent requests — the client-side primer for the HTTP
Idempotency-Keyheader. - IETF draft — HTTP Idempotency-Key — the standardisation effort.