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:
@Toolmethods, through the tool dispatcher;WEB_SERVICEactions, 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.
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 for an example.
A tool with a positive timeoutMs spends the same deadline on the store lookup and the body. See Custom tools.
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.
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>8.0.2</version>
</dependency>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.
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:
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@Idempotentmakes 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
EXPLICITwhen one field is the identity.HASH_INPUTgives 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: the tools
@Idempotentprotects. - Actions:
WEB_SERVICEactions. - Long runs: resumable runs that share the idempotency store.
- Stripe: idempotent requests: how a service uses the
Idempotency-Keyheader. - IETF draft: the Idempotency-Key HTTP header.