# Idempotency

A retry from `@Resilience`, a gateway or a client can repeat a call. `@Idempotent` and an `IdempotencyStore` make sure a side effect still happens once.

`@Idempotent` applies to two kinds of calls:

- `@Tool` methods, through the tool dispatcher;
- `WEB_SERVICE` actions, through the web-service executor.

It has no effect on other action types.

## When to use it

Use it for calls whose side effect must not repeat:

- `send_email`: three retries must not send three emails;
- `create_issue`: three retries must not open three issues;
- `charge_card`: three retries must not charge three times.

Skip it for reads, such as `get_user` or `search_web`, and for calls that are meant to differ each time, such as `random_id`.

## `@Idempotent`

`@Idempotent` goes on the method or on its class. The method wins. It has four members.

| Member | Meaning | Default |
|---|---|---|
| `strategy` | How the key is derived from the call | `HASH_INPUT` |
| `ttlSeconds` | How long the store remembers the call | `3600` |
| `onCacheHit` | What a repeat of a remembered call gets | `RETURN_CACHED` |
| `cacheFailures` | Whether failed calls are remembered too | `false` |

### `KeyStrategy`

| Strategy | Key |
|---|---|
| `HASH_INPUT` | SHA-256 of the canonical JSON input. The same arguments give the same key. |
| `EXPLICIT` | The value of `idempotencyKeyFor(input)`. The class must implement `IdempotencyKeySupplier`. |
| `UUID` | A fresh key per call. It is sent upstream as `Idempotency-Key`, but nothing is deduplicated on the client. |
| `NONE` | No protection |

Keys are scoped to the tool or action name, so two tools can return the same explicit key without colliding.

### `RetryBehavior`

| Behavior | A repeat of a remembered call |
|---|---|
| `RETURN_CACHED` | Gets the remembered result |
| `RETURN_CACHED_IF_SUCCESS` | Gets the remembered result if the first call succeeded, and runs again if it failed |
| `FAIL_FAST` | Throws `IdempotencyException` |

## Tools

With `EXPLICIT`, the toolkit implements `IdempotencyKeySupplier`. The supplier must return a non-blank key, or the call fails with `IdempotencyException`.

<!-- java-contract: src/main/java/com/example/tnsai/docs/IdempotencyExample.java -->
```java
package com.example.tnsai.docs;

import com.tnsai.actions.Actions;
import com.tnsai.actions.executors.WebServiceExecutor;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.Tool;
import com.tnsai.annotations.ToolParam;
import com.tnsai.annotations.WebService;
import com.tnsai.enums.ActionType;
import com.tnsai.enums.HttpMethod;
import com.tnsai.enums.role.ParamType;
import com.tnsai.idempotency.IdempotencyKeySupplier;
import com.tnsai.idempotency.IdempotencyStore;
import com.tnsai.idempotency.Idempotent;
import com.tnsai.idempotency.KeyStrategy;
import com.tnsai.idempotency.PostgresIdempotencyStore;
import com.tnsai.idempotency.RetryBehavior;
import com.tnsai.models.role.RoleIdentity;
import com.tnsai.roles.Role;

import java.util.Locale;
import java.util.Map;
import javax.sql.DataSource;

public final class IdempotencyExample {
    private IdempotencyExample() {}

    // A toolkit that supplies its own key for EXPLICIT tools.
    public static final class IssueTools implements IdempotencyKeySupplier {

        @Tool(name = "create_issue", description = "Create an issue in the tracker")
        @Idempotent(strategy = KeyStrategy.EXPLICIT, ttlSeconds = 86_400)
        public String createIssue(@ToolParam(description = "Issue title") String title,
                                  @ToolParam(description = "Issue body") String body) {
            return "created: " + title;
        }

        // A rejected order stays rejected: cache the failure and refuse the repeat.
        @Tool(name = "place_order", description = "Place an order")
        @Idempotent(strategy = KeyStrategy.HASH_INPUT,
                    cacheFailures = true,
                    onCacheHit = RetryBehavior.FAIL_FAST)
        public String placeOrder(@ToolParam(description = "Product SKU") String sku,
                                 @ToolParam(description = "Quantity") int quantity) {
            return "ordered " + quantity + " x " + sku;
        }

        // Same title, same issue: body edits do not create a second one.
        @Override
        public String idempotencyKeyFor(Map<String, Object> input) {
            return ((String) input.get("title")).toLowerCase(Locale.ROOT);
        }
    }

    // A role supplies the key for its EXPLICIT WEB_SERVICE actions.
    public static final class MailRole extends Role implements IdempotencyKeySupplier {
        @Override
        public RoleIdentity getIdentity() {
            return new RoleIdentity("mail", "Send mail", "mail");
        }

        @ActionSpec(type = ActionType.WEB_SERVICE,
                    description = "Send an email",
                    webService = @WebService(
                        endpoint = "https://api.example.com/v1/emails",
                        method = HttpMethod.POST,
                        paramType = ParamType.BODY))
        @Idempotent(strategy = KeyStrategy.EXPLICIT, ttlSeconds = 86_400)
        public String sendEmail(String messageId, String to, String subject, String body) {
            throw Actions.dispatchedByFramework();
        }

        @Override
        public String idempotencyKeyFor(Map<String, Object> input) {
            return (String) input.get("messageId");
        }
    }

    // One durable store for both paths: tools and WEB_SERVICE actions.
    static Agent withStore(IdempotencyStore store) {
        Agent agent = AgentBuilder.create()
            .toolPojos(new IssueTools())
            .idempotencyStore(store)
            .build();
        agent.getAgentActionExecutor()
            .registerExecutor(ActionType.WEB_SERVICE, new WebServiceExecutor(store));
        return agent;
    }

    static IdempotencyStore postgres(DataSource dataSource) {
        PostgresIdempotencyStore store = new PostgresIdempotencyStore(dataSource);
        store.createTableIfMissing();
        return store;
    }
}
```

`place_order` shows failure caching. By default only successes are remembered, so a retry after a transient failure runs again. With `cacheFailures = true` and `FAIL_FAST`, a repeat of a rejected order throws at once and sends nothing upstream.

A tool that declares `idempotencyHint = REQUIRED` with a side effect must carry `@Idempotent` with `HASH_INPUT` or `EXPLICIT` and a positive `ttlSeconds`. Otherwise the call fails with `IllegalStateException`. On the default in-memory store, the dispatcher also logs a warning once. See [Long runs](../agents/long-runs.md) for an example.

A tool with a positive `timeoutMs` spends the same deadline on the store lookup and the body. See [Custom tools](custom.md#per-tool-deadlines).

## `WEB_SERVICE` actions

For a `WEB_SERVICE` action, the role implements `IdempotencyKeySupplier` when the strategy is `EXPLICIT`, as `MailRole` does above. The supplier is called twice per call, once for the header and once for the store, so it must be deterministic.

On `POST`, `PUT`, `PATCH` and `DELETE`, the request carries an `Idempotency-Key` header with the call's key. Services such as Stripe use it to deduplicate on their side. `GET`, `HEAD` and `OPTIONS` get no header, but a remembered result still short-circuits them.

## Stores

| Store | Use | Survives a restart | Shared across processes |
|---|---|---|---|
| `InMemoryIdempotencyStore` | The default. Tests and single-process apps. | No | No |
| `RedisIdempotencyStore` | Several instances behind a load balancer | Per your Redis durability settings | Yes |
| `PostgresIdempotencyStore` | Apps that already run Postgres and want durable records | Yes | Yes |

Each path has its own store. `AgentBuilder.idempotencyStore(store)` sets the store for tools. `WEB_SERVICE` actions keep their own in-memory store until you register a `WebServiceExecutor` built with yours, as `withStore` does above.

### `RedisIdempotencyStore`

`redis.clients:jedis` is an optional dependency of `tnsai-core`, so your build declares it. TnsAI is built against 8.0.2.

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

```java
RedisClient client = RedisClient.create(URI.create("redis://prod-redis:6379"));
IdempotencyStore store = new RedisIdempotencyStore(client);
// Or with a key prefix, when unrelated deployments share one Redis:
IdempotencyStore prefixed = new RedisIdempotencyStore(client, "myapp:idem:");
```

The store takes a `redis.clients.jedis.RedisClient`. Configure authentication, database, TLS, timeouts and pooling on the client. Your application owns the client and closes it at shutdown, and the store never closes it. `RedisCheckpointStore` takes the same client. To move from the old `JedisPool` constructors, see [Upgrade](../upgrade/index.md#0175-redis-stores-take-a-redisclient-instead-of-a-jedispool).

Redis expires entries itself (`SET … EX`). Entries are stored as JSON, so a typed result read back in another process comes back as maps and lists.

### `PostgresIdempotencyStore`

`PostgresIdempotencyStore` uses plain JDBC and needs no extra dependency. `createTableIfMissing()` suits development. In production, create the table with your migration tool:

```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);
```

A second constructor takes a different table name.

Reads skip expired rows, but the rows stay in the table. Call `purgeExpired()` on a schedule to delete them.

The store writes with a standard `UPDATE` then `INSERT`, so it also runs on H2 in PostgreSQL mode and on CockroachDB.

## Best practices

- **Pair it with `@Resilience(retry = …)`.** The retry repeats the call, and `@Idempotent` makes the repeat safe.
- **Match the TTL to the operation.** An issue can keep its key for a day. A notification may need only five minutes, after which a resend is deliberate.
- **Use `EXPLICIT` when one field is the identity.** `HASH_INPUT` gives a new key when any argument changes, including a timestamp or request id.
- **Use a durable store for payments and deletes.** The in-memory store forgets everything on restart, and a forgotten call runs again.

## Related

- [Custom tools](custom.md): the tools `@Idempotent` protects.
- [Actions](../agents/actions.md): `WEB_SERVICE` actions.
- [Long runs](../agents/long-runs.md): resumable runs that share the idempotency store.
- [Stripe: idempotent requests](https://docs.stripe.com/api/idempotent_requests): how a service uses the `Idempotency-Key` header.
- [IETF draft: the Idempotency-Key HTTP header](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header).
