TnsAI
CapabilitiesTools

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:

FieldMeaningDefault
strategyHow the dedup key is derived from the call.HASH_INPUT
ttlSecondsHow long the cache remembers this call.3600 (1 hour)
onCacheHitWhat to do when a retry hits the cache.RETURN_CACHED
cacheFailuresWhether 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 overrides idempotencyKeyFor(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 honour Idempotency-Key headers; 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 throws IdempotencyException, 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/json

Stripe, 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:

StoreWhenSurvives restart?Cross-process?
InMemoryIdempotencyStoreDefault. Dev, tests, single-process production.NoNo
RedisIdempotencyStoreProduction with multiple instances.Yes (per Redis durability config)Yes
PostgresIdempotencyStoreProduction where you already have Postgres + want strong durability.YesYes

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-start

Production 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; @Idempotent tells 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 EXPLICIT for tools whose key is one stable input field. HASH_INPUT over 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

On this page