# 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 a POJO `@Tool` also declares a positive `timeoutMs` (0.14.0,
[PR #164](https://github.com/TnsAI-Framework/TnsAI/pull/164) /
[TAN-2958](https://linear.app/tansuasici-workspace-1/issue/TAN-2958)), that
deadline covers idempotency key/store work as well as the tool body — one
budget, not two. See
[Custom Tools](custom-tools.md#per-tool-deadlines).

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

```java
@ActionSpec(
    type = ActionType.WEB_SERVICE,
    webService = @WebService(
        endpoint = "https://api.example.com/email",
        method = HttpMethod.POST,
        paramType = ParamType.BODY
    )
)
@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 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.

```java
@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:

```http
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`:

| 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):

```xml
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>7.5.0</version>
</dependency>
```

```java
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:

```java
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:

```sql
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:

```java
@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

- [Custom Tools](custom-tools.md) — authoring tools that idempotency attaches to.
- [Examples](examples.md) — `@ToolExample` for the LLM-facing surface.
- [Stripe — idempotent requests](https://docs.stripe.com/api/idempotent_requests) — the client-side primer for the HTTP `Idempotency-Key` header.
- [IETF draft — HTTP Idempotency-Key](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header) — the standardisation effort.
