It runs once.
Give a unit of work a key. The first caller acquires a lease, runs under a heartbeat and stores the result. Every duplicate gets that stored result back.
An idempotency engine for Java. Apache 2.0.
<dependency>
<groupId>io.github.josipmusa</groupId>
<artifactId>idempotency-spring-boot-starter</artifactId>
<version>0.4.0</version>
</dependency>implementation("io.github.josipmusa:idempotency-spring-boot-starter:0.4.0")On a listener
@Idempotent(key = "#event.id()")@KafkaListener(topics = "orders")void on(OrderPlaced event) { // Runs once per event id.}A redelivery is recognised and skipped instead of running again.
On an HTTP endpoint
@PostMapping("/payments")@Idempotentpublic Payment pay(@RequestBody PaymentRequest req) { // A duplicate gets the stored response. return payments.charge(req);}No key named: the client's Idempotency-Key header is already the key.
Without Spring
var ctx = IdempotencyContext .builder("orders", event.id()) .waitTimeout(Duration.ZERO) .build();var outcome = engine.execute(ctx, () -> handler.handle(event));// Executed, Replayed or InFlight.No Spring anywhere. The engine is a plain object you call.
How it works
01
Acquire
A record is identified by a scope and a key together, never by the key alone. The key identifies the attempt; the scope names the unit of work it belongs to.
02
Run under a heartbeat
Every acquisition carries a lease. The heartbeat fires at lease / 2, so an action that legitimately runs longer than its lease keeps it rather than having it stolen mid-flight.
03
Store
The result is encoded and recorded. A record is absent, IN_PROGRESS or COMPLETE; there is no failed state. Releasing deletes the row, so a failed attempt leaves no trace and the next caller sees a key that was never used.
04
Replay
A concurrent duplicate waits inside tryAcquire for the holder to finish and only gives up once wait elapses, which is why a duplicate arriving mid-flight usually gets the real result rather than an error.
An action that dies without releasing leaves an expired lease, which the next tryAcquire steals atomically.
The whole change
Name the key with a SpEL expression over the parameters. waitTimeout = "PT0S" turns away a redelivery that arrives while the first is still running, instead of parking the consumer thread.
@Idempotent(key = "#event.id()", waitTimeout = "PT0S")
@KafkaListener(topics = "orders")
void on(OrderPlaced event) {
// Runs once per event id, however
// often the broker redelivers.
}The key is the client's header, so the annotation needs nothing. The payment provider should still get an idempotency key of its own.
@PostMapping("/payments")
@Idempotent
public ResponseEntity<Payment> pay(
@RequestBody PaymentRequest req) {
// A duplicate gets the stored response.
return ResponseEntity.ok(
payments.charge(req));
}IdempotencyEngine.execute is the entire API. What comes back is a sealed Outcome you switch on.
var outcome = engine.execute(
ctx, () -> handler.handle(event));
switch (outcome) {
// ran now, or ran before under this key
case Outcome.Executed<Void> e -> { }
case Outcome.Replayed<Void> r -> { }
// someone else holds the key right now
case Outcome.InFlight<Void> f ->
consumer.nack(f.retryAfter());
}What it prevents
You need this if callers retry and a duplicate would cause a real problem.
Money charged twice
A payment retried by an impatient client or a gateway timeout.
Two orders shipped
Resource provisioning or order creation called again after a network blip.
A consumer reprocessing
An at-least-once broker redelivering after a rebalance.
Four outcomes over HTTP
Request
POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000the handler’s own response
Idempotent-Replayed: true
Unprocessable Entity
Retry-After: 30
The filter stores whatever your handler returns, including 4xx and 5xx, as long as the handler returns normally. A handler that throws is different: the engine releases the lease, which deletes the record, and the next request runs the handler again. If you want a failed request to be retriable, throw.
The alternatives
Your own processed_events table
A row and a unique constraint deduplicate. They do not give you a lease, a heartbeat that holds it while a slow action runs, a concurrent duplicate that waits for the real result instead of failing, or an atomic steal of a dead owner’s lease. Those are the parts that are hard to get right, and they are what the storage SPI’s contract is.
@Cacheable
A cache is keyed on arguments and is allowed to miss. An idempotency record is keyed on a client-chosen key and must not. A cache has no notion of an in-flight first execution, so two concurrent duplicates both run.
A workflow platform
Temporal and its neighbours will do this, and much more, in exchange for a new runtime, a new programming model and a new operational surface. This is a dependency and a store.
What this is not
This is not an exactly-once guarantee for arbitrary downstream side effects. Lease fencing protects the idempotency record, not the third-party charge your action made just before the process died. This library makes your work safe to retry; it cannot make someone else’s endpoint safe to retry for you.
No reactive support
The HTTP adapter is built on OncePerRequestFilter, and the engine's execute is blocking.
No tenant isolation
Within a scope, two callers using the same key share idempotency state. Prefix keys at the application level where that matters.
Redis Cluster is not supported
Standalone and Sentinel master-replica connections work.
Not a distributed lock
It is not something you can borrow for general use.
The versions it runs on, and the ones it does not, are on the specification sheet.
Add the dependency
<dependency>
<groupId>io.github.josipmusa</groupId>
<artifactId>idempotency-spring-boot-starter</artifactId>
<version>0.4.0</version>
</dependency>implementation("io.github.josipmusa:idempotency-spring-boot-starter:0.4.0")