A network retry, a double-tapped "Pay" button, or a load balancer replaying a timed-out request all look identical to your server: a second POST that arrives after the first one already happened. Without protection, that second request charges the customer twice. One annotation fixes it:
@Idempotent
@PostMapping("/orders")
public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
return ResponseEntity.status(CREATED).body(orders.charge(request)); // orders: your existing service
}A duplicate request carrying the same Idempotency-Key header gets the original response
replayed — not a re-execution, not an error.
Written up on foojay.io if you want the full story behind the design decisions.
dependencies {
implementation("io.github.benhendayoussef:idempotency-spring-boot-starter:1.0.0") // use the latest published version
implementation("io.github.benhendayoussef:idempotency-store-redis:1.0.0")
}@Idempotent
@PostMapping("/orders")
public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) { ... }curl -X POST localhost:8080/orders -H "Idempotency-Key: $(uuidgen)" -d '{"amount":49.99}'Done. Run the same curl command again with the same key and you get the same response back,
with an Idempotent-Replay: true header, and the handler does not execute a second time.
See samples/sample-orders-api for a runnable demo
(docker compose up -d && ./gradlew :samples:sample-orders-api:bootRun, then ./demo.sh).
The JDBC store needs more setup than swapping one dependency — all of the following:
dependencies {
implementation("io.github.benhendayoussef:idempotency-spring-boot-starter:1.0.0") // use the latest published version
implementation("io.github.benhendayoussef:idempotency-store-jdbc:1.0.0")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
runtimeOnly("org.postgresql:postgresql")
}spring:
datasource:
url: jdbc:postgresql://localhost:5432/yourdb
username: ...
password: ...
sql:
init:
mode: always
schema-locations: classpath:db/idempotency/postgres.sql # or mysql.sql - both ship inside idempotency-store-jdbc
idempotency:
store: jdbc # required: AUTO never selects JDBC on its own, even with no Redis presentFor MySQL or MariaDB, change three things: the JDBC URL, the driver
(runtimeOnly("com.mysql:mysql-connector-j")), and the schema
(classpath:db/idempotency/mysql.sql). Nothing else — the dialect is detected from the DataSource
on first use, so idempotency.jdbc.dialect only needs setting to skip detection.
The same @Idempotent annotation and curl round trip from the Redis quickstart apply unchanged.
For one instance that stays up, with no Redis and no database, where losing state on restart is acceptable:
dependencies {
implementation("io.github.benhendayoussef:idempotency-spring-boot-starter:1.0.0")
implementation("io.github.benhendayoussef:idempotency-store-caffeine:1.0.0")
}idempotency:
store: caffeinePrefer this over store=memory for anything long-lived: the built-in memory store never evicts
expired entries, so it grows for the life of the process.
┌──────────────┐
request ───claim───►│ IN_PROGRESS │
└──────┬───────┘
│
┌───────────────┼─────────────────┐
│ │ │
handler returns handler throws handler throws
2xx or 4xx 5xx-ish 4xx-mapped exc
│ │ │
▼ ▼ ▼
┌───────────┐ ┌─────────┐ ┌───────────┐
│ COMPLETED │ │ RELEASED│ │ COMPLETED │
│ (replay) │ │ (delete)│ │ (replay) │
└───────────┘ └─────────┘ └───────────┘
The claim-or-read step is atomic (a single SETNX for Redis, an INSERT ... ON CONFLICT for
Postgres), which is what makes concurrent duplicates safe, not just sequential ones.
The failure policy is the non-obvious part. A 5xx / infrastructure failure releases the key —
the failure was transient, so a legitimate retry should re-execute, not replay a stale 500
forever. A 4xx is deterministic, so it's kept and replayed. Configurable via
idempotency.release-on.
Same key with a different request body is a client bug, not a replay: it gets a 422, never
a silent execution of the wrong payload.
| Property | Default | Notes |
|---|---|---|
idempotency.enabled |
true |
Master switch |
idempotency.mode |
aspect |
aspect |
idempotency.filter.replay-headers |
Content-Type, Location, ETag, Cache-Control |
Headers replayed verbatim in filter mode |
idempotency.store |
auto |
auto | redis | jdbc | caffeine | memory |
idempotency.default-ttl |
24h |
How long a completed response stays replayable. Overridable per-endpoint via @Idempotent(ttl = "...") |
idempotency.claim-ttl |
5m |
How long an in-flight claim is held before the holder is presumed dead. Must exceed your slowest handler — see below |
idempotency.header-name |
Idempotency-Key |
Overridable via @Idempotent(keyHeader = "...") |
idempotency.require-key |
false |
true rejects keyless requests with 400 instead of passing them through |
idempotency.on-conflict |
wait |
wait polls the in-flight request; fail_fast returns 409 immediately |
idempotency.wait-timeout |
5s |
Max poll time before a wait conflict gives up with 409 |
idempotency.scope |
global |
global | user | tenant | custom (register your own ScopeResolver). user and tenant need Spring Security on the classpath; without it, startup fails with an actionable message rather than at request time |
idempotency.tenant-claim |
tenant_id |
JWT claim read by the built-in TENANT resolver |
idempotency.on-missing-principal |
global |
What a user/tenant-scoped request does when it reaches the aspect with no resolvable authenticated principal: global falls back to the global namespace; skip executes unprotected; reject returns 400. Never a raw 500 |
idempotency.on-store-failure |
proceed |
proceed executes unprotected with a WARN; fail returns 503 |
idempotency.problem-details |
true |
Registers the built-in RFC 9457 exception advice |
idempotency.max-payload-size |
256KB |
Larger responses execute normally but aren't cached for replay |
idempotency.release-on |
five_xx,timeout |
Outcomes that release instead of complete the key. Note the underscore: Spring's relaxed binding needs five_xx, not 5xx |
idempotency.caffeine.maximum-size |
10000 |
Ceiling on entries held by the Caffeine store; LRU beyond it |
idempotency.redis.key-prefix |
idempotency: |
|
idempotency.jdbc.table-name |
idempotency_record |
|
idempotency.jdbc.dialect |
auto |
auto |
idempotency.jdbc.sweeper-enabled |
false |
The atomic claim already reclaims expired rows on the hot path; this is only for disk usage |
idempotency.jdbc.sweeper-interval |
15m |
|
idempotency.jdbc.join-transaction |
false |
Run the handler and the completion write in one shared transaction — exactly-once instead of at-least-once. Costs: non-@Transactional handlers get pulled into a transaction, and a handler's own @Transactional(timeout) stops applying. Read the caveats first |
idempotency.jdbc.on-silent-rollback |
return_response |
What a caller gets when a handler marks the shared transaction rollback-only and then returns success. return_response sends what the handler returned; fail returns 500, because the response describes data that was never committed. Only reachable with join-transaction=true |
idempotency.metrics.enabled |
true |
Publish counters to Micrometer when a MeterRegistry exists. No effect without one |
idempotency.tracing.enabled |
true |
Tag the request's span with idempotency.outcome when a Micrometer Tracing Tracer exists. No effect without one |
A key has two lifetimes, and conflating them is a trap worth understanding:
| Governs | Default | |
|---|---|---|
claim-ttl |
How long an in-flight claim is held before the holder is presumed dead | 5m |
default-ttl |
How long a completed response stays replayable | 24h |
Until 0.4 these were one value. That meant a process dying mid-request - a deploy, an OOM, a
scale-down - left its key IN_PROGRESS for the whole retention window. With the 24h default, every
retry of that request got a 409 for a day, and the sweeper could not help because it only
deletes rows that are already past expiry.
claim-ttlmust be longer than your slowest handler. If a claim expires while the request is still running, a concurrent duplicate reclaims the key and both execute - the exact failure this library exists to prevent. The 5-minute default is deliberately generous against typical proxy and load-balancer timeouts; raise it if you have handlers that legitimately run longer.
The lease is capped at the retention TTL in effect, so asking for a 30-second idempotency window never leaves a dead claim sitting for five minutes - and it can never hold a claim longer than 0.3 did.
| Redis | JDBC (Postgres / MySQL) | |
|---|---|---|
| Guarantee | At-least-once. If the process crashes between the business transaction committing and the completion record being written, the key stays IN_PROGRESS until TTL and a retry re-executes. |
At-least-once by default; exactly-once with idempotency.jdbc.join-transaction=true. Both modes are described below — read them before switching. |
| Speed | Fast — a single round trip per claim. | Slower — shares the datasource and transaction. |
| Setup | spring-boot-starter-data-redis |
A DataSource, idempotency.store=jdbc, and the schema for your database applied — see the JDBC quickstart above |
| Good for | The other 95% of use cases. | Payments and anything else where a replayed side effect is unacceptable, if you use it the way described below. |
Measured overhead (200 requests, 20 warmup iterations, real Redis/Postgres via Testcontainers on
Docker Desktop for Windows): p50 ~5–6.4ms / p99 up to ~40ms added by an @Idempotent endpoint
versus an identical unannotated one. An in-memory-store control (zero network I/O) measured only
~0.5ms p50 overhead with a negative p99 (within measurement noise) — the aspect's own cost
(fingerprinting, key composition, JSON (de)serialization, reflection) is sub-millisecond; the rest is
two real network round trips (claim + complete) plus whatever your Docker/host networking adds.
Expect closer to native-network latency (well under a millisecond of aspect overhead) against a
colocated, non-Dockerized Redis/Postgres in production. Don't be misled if you benchmark the same way
and see similar numbers on Docker Desktop — that's the container networking layer, not this library.
JdbcIdempotencyStore.complete() is @Transactional(propagation = Propagation.REQUIRED): it joins
an already-open transaction if one is active on the calling thread, and starts its own if not.
The aspect intentionally wraps outside @Transactional (so a pure replay never has to open a
database transaction at all), which means that by the time it calls complete() — always after
your handler has already returned — a @Transactional handler's own transaction has already
committed or rolled back. complete() then simply starts a new, independent transaction of its
own, so it does not commit atomically with your business data just because both are annotated.
Two commits, not one. Crash between them and the business data is committed with no completion record, so the next request with the same key re-executes. That is at-least-once, and it is what you get out of the box — same as Redis.
The exactly-once guarantee is still available without the property below, but only when you call
the store from inside a transaction you're already holding open — e.g. from a repository or service
method that both writes your business row and calls IdempotencyStore.complete() before returning.
@Idempotent + @Transactional on the same method does not give you atomicity by itself.
idempotency:
store: jdbc
jdbc:
join-transaction: true # default falseWith this on, the aspect opens a transaction around pjp.proceed() and the completion write, so
your handler's own @Transactional(REQUIRED) joins it rather than opening and closing its own
first. One physical commit covers both. There is no window to crash in, and a handler that rolls
back takes the completion record with it — a retry genuinely re-executes instead of replaying a
response that was never committed.
The claim still commits independently, before that transaction opens. It has to: if the
IN_PROGRESS row only became visible when your handler committed, every concurrent duplicate would
race straight past it.
Read this before switching it on. It changes how every @Idempotent method executes, not just
transactional ones:
- A handler with no
@Transactionalof its own now runs inside a transaction, holding a connection for its whole duration. Locking and connection-pool behaviour change. This is the main reason the property is opt-in. - A handler annotated
@Transactional(REQUIRES_NEW)opts out of joining, so atomicity does not hold for it: its own transaction commits or rolls back independently while the completion record follows the aspect's. That is whatREQUIRES_NEWmeans, and the library does not override it. - Your handler's own
@Transactional(timeout = ...)stops applying, because a joining participant cannot widen or narrow the transaction it joined. Configure a timeout on the transaction manager instead (spring.transaction.default-timeout). - The failure policy is unchanged. 5xx still releases the key; a 4xx is still kept and replayed. The 4xx record is written after the rollback in its own transaction, so a retry gets the same deterministic client error — but the business data that error described is gone. Don't build a 4xx body out of rows written in the same request.
- A handler that calls
setRollbackOnly()and then returns a success status is ambiguous, and you choose which half to believe withidempotency.jdbc.on-silent-rollback. The default,return_response, sends what the handler returned — the behaviour in every release so far. Set it tofailand the caller gets a 500 instead, on the grounds that a201 Createddescribing a row that rolled back is worse than an error. Either way the key is released, so a retry re-executes. Only reachable in joined mode: without it the handler rolls back its own transaction and the library never hears about it. - Replays still open zero transactions. The aspect answers from the store without calling
proceed(), so the transaction manager is never touched — the whole reason for the aspect ordering, and asserted directly in the test suite. - One connection per in-flight request, held for the handler's whole duration. The library never
nests a second transaction inside the first (asserted); the only exception is a handler that asks
for
REQUIRES_NEWitself. Size your connection pool for concurrent in-flight requests, not just for query time — see the measurement below. - Self-invocation bypasses the proxy here exactly as it does for
@Transactional: an inner call gets neither annotation's behaviour.
Measured cost of an undersized pool. 16 concurrent requests, distinct keys, at a handler with no
@Transactional of its own that takes 250ms for reasons of its own (an external call, an image
resize) — against a deliberately small 4-connection pool, real Tomcat and real Postgres, after
warmup:
| Mode | Median | Max | Wall clock for all 16 |
|---|---|---|---|
| Default | ~300–345ms | ~310–415ms | ~310–420ms |
join-transaction=true |
~835ms | ~1110–1215ms | ~1115–1215ms |
The unconstrained floor is 250ms. In the default mode the handler holds no connection while it runs,
so all 16 proceed in parallel and the batch lands near the floor. In joined mode each one occupies a
connection for its whole 250ms, so effective concurrency is capped at the pool size: 16 requests ÷ 4
connections × 250ms ≈ 1s, which is what the measurement shows. No requests failed in either mode
— the cost is latency, not errors, until the pool's connection-timeout is reached.
That ratio is a property of your pool size versus your concurrency, not a fixed penalty. A pool
sized at or above peak concurrent in-flight requests shows no such gap. Reproduce with
./gradlew :idempotency-spring-boot-starter:test --tests "*TxJoin*LoadTest*" -i.
If the property is true while the active store is Redis or in-memory, it logs a WARN and no-ops —
it will not break a service that inherits a shared profile. If the store is JDBC and there is no
PlatformTransactionManager, startup fails naming the property rather than surprising you at
runtime.
Nobody else publishes this table. If you only remember one thing from this README, it's that "idempotent" and "exactly-once" are not the same claim, and getting the latter from the JDBC store takes more than swapping in the dependency.
Being loud about these is what makes a library trustworthy:
- Captures the method return value, not raw HTTP bytes (see the design rationale below) —
anything the handler writes directly to
HttpServletResponseis not captured. - Does not work on streaming / SSE /
StreamingResponseBodyreturns. - Does not handle multipart bodies in the fingerprint.
- The JDBC store supports PostgreSQL and MySQL/MariaDB. Other engines need a new dialect.
store=memorynever evicts expired entries, so it grows for as long as the process lives. It is meant for tests and local development. For a single instance that stays up, usestore=caffeine, which has the same semantics plus real TTL eviction and a size ceiling.- WebFlux support covers
Monoonly, with a blocking store onboundedElasticandscope=global. See the WebFlux section. - AOP-based: self-invocation bypasses the proxy, same as
@Transactional. A startup check warns if@Idempotentis found on a non-public method. idempotency.scopedefaults toglobal(no Spring Security needed) precisely so the Quickstart boots with zero extra configuration.userandtenantneed Spring Security on the classpath; without it present at all, startup fails immediately with an actionable message rather than at request time. A specificuser/tenant-scoped request that reaches the aspect unauthenticated (e.g. a public endpoint on an app that also has protected ones) is handled peridempotency.on-missing-principal— never a raw 500.- Out of the box, both stores are at-least-once. The JDBC store's exactly-once guarantee does not
come for free from
@Idempotent+@Transactionalon the same method — it needsidempotency.jdbc.join-transaction=true, which has its own tradeoffs. See the two modes under Store comparison above. on-conflict=WAIT(the default) holds a servlet request thread per waiting duplicate for up towait-timeout. A burst of concurrent duplicates on the same key can measurably slow down unrelated requests on the same server while the pool is under pressure (observed: an unrelated endpoint's latency rose from typical sub-50ms to over a second during a 20-duplicate burst against an 8-thread pool in testing, though no requests actually failed at that scale). If you expect frequent duplicate bursts under a constrained thread pool, consideron-conflict=fail_fastfor the affected endpoints instead of the default.on-store-failure=proceed's request-latency bound is only as good as your store client's own configured timeout (spring.data.redis.timeout,spring.datasource.hikari.connection-timeout, etc.) — this library adds no timeout of its own. In testing against a store that accepted a connection but never responded, the actual observed request latency (~5s) was noticeably higher than the configured Lettuce command timeout (1s); investigate your own client's real timeout behavior in production rather than assuming it matches the configured number exactly.
Not a retry library. Not a rate limiter. Not a dedup layer for Kafka consumers. Scope creep kills small libraries — these are non-goals forever, not gaps to fill in a future release.
The request body is already parsed into method arguments by the time the handler runs, so the
fingerprint is free — no stream-consumption problems, no custom request-body-caching filter.
Storing the return object and re-serializing on replay is also more correct than storing raw
bytes: if the client's Accept header differs on the retry, byte replay would send the wrong
content type, while object replay goes through normal HttpMessageConverter negotiation.
The stored record's payloadType is an integrity check, never a deserialization hint.
Replay always deserializes into the type resolved from the live method signature, not from a
type name pulled out of the store — trusting a type name from a store is a classic
polymorphic-deserialization gadget vector.
docs/design-decisions.md— the architectural reasoning: why AOP over a servlet filter, the atomic claim, the failure policy, and what "exactly-once" does and doesn't mean here.
If your application already has a Micrometer MeterRegistry (adding spring-boot-starter-actuator
is enough), the starter publishes counters automatically. There is nothing to configure.
Everything lands on one counter, idempotency.requests, separated by an outcome tag:
outcome |
Meaning |
|---|---|
executed |
Handler ran; the response was stored for replay |
replayed |
A duplicate was answered from the store without executing |
replayed_after_wait |
A WAIT-policy duplicate blocked, then replayed the first call’s response |
released |
The key was released so a retry can re-execute (5xx, or a timeout) |
conflict |
A duplicate arrived while the first was in flight and got a 409 |
wait_timeout |
A WAIT-policy duplicate gave up waiting |
fingerprint_mismatch |
Same key, different request body - the 422 case |
missing_key |
No idempotency key on the request |
store_failure |
The store was unreachable |
principal_missing |
A user/tenant-scoped request had no resolvable principal |
One name with a tag rather than ten names is deliberate: it lets you write a replay rate as a single ratio, and an outcome added in a later version shows up in your existing queries instead of being invisible until you update them.
# Replay rate: the share of idempotent traffic served without re-executing
sum(rate(idempotency_requests_total{outcome="replayed"}[5m]))
/ sum(rate(idempotency_requests_total[5m]))
Set idempotency.metrics.enabled=false to keep the no-op implementation, or register your own
IdempotencyMetrics bean to route the same events somewhere else - the starter backs off from both.
A replay is the most confusing span in a distributed trace. The endpoint was called, it returned
201, it opened no transaction, issued no query, made no downstream call, and took two
milliseconds. That reads as a handler that silently did nothing.
If your application has a Micrometer Tracing Tracer — spring-boot-starter-actuator plus a
bridge such as micrometer-tracing-bridge-otel or -brave — the starter tags the request's own
span with what it did:
idempotency.outcome = replayed
The values are exactly the outcome tag values in the table above, so a Prometheus rate and a
trace-search filter select the same population.
The tag goes on the existing server span rather than in a child span of its own. A child span would put the answer one level down from where you are already looking, add a span to every request in the system to carry a single string, and still leave the trace list inexplicable to anyone scanning it. Nothing is added when the request is not sampled.
Set idempotency.tracing.enabled=false to leave traces untouched, or register your own
IdempotencyTracer bean to send the outcome somewhere else — an OpenTelemetry attribute, an MDC
entry, an audit trail. The starter backs off from both. This works in all three execution paths
(aspect, filter mode and WebFlux); on WebFlux it relies on Reactor context propagation, which Spring
Boot enables when tracing is configured.
Add idempotency-webflux and @Idempotent works on reactive handlers that return Mono:
dependencies {
implementation("io.github.benhendayoussef:idempotency-spring-boot-starter:1.0.0")
implementation("io.github.benhendayoussef:idempotency-webflux:1.0.0")
implementation("io.github.benhendayoussef:idempotency-store-redis:1.0.0")
}@Idempotent
@PostMapping("/orders")
public Mono<ResponseEntity<OrderResponse>> placeOrder(@RequestBody OrderRequest request) { ... }Nothing else to configure. The servlet and reactive aspects are mutually exclusive by construction, so an application gets exactly one.
One thing is genuinely better here: on-conflict=wait occupies no thread while it waits, because
the delay is a timer rather than a sleep. The thread-pool exhaustion documented under Limitations for
the servlet stack does not apply.
Three things to know before adopting it:
- Only
Monois advised. AFluxis a stream, and this library replays a single captured response - the same reason the servlet side does not support streaming. AFlux-returning handler passes through untouched, with a WARN at startup rather than silent half-support. - Stores are still blocking, so store calls are scheduled onto
boundedElastic. Correct, but an idempotent endpoint costs two thread handoffs a plain one does not. A reactive store SPI (R2DBC, reactive Redis) would remove that and is not in 0.4.0. idempotency.scopemust beglobal.userandtenantresolve the principal fromSecurityContextHolder, which is a ThreadLocal with no meaning on a reactive stack. Rather than quietly falling back to global - which would share idempotency keys across users - startup fails with an explanation.
idempotency.mode decides what gets stored and replayed. Both modes select endpoints the same
way - @Idempotent on the handler - and produce identical storage keys, so switching does not
orphan existing records.
aspect (default) |
filter |
|
|---|---|---|
| Captures | The handler return value, re-serialized on replay | The real HTTP response bytes |
Body written directly to HttpServletResponse |
Not captured | Replayed exactly |
| Response headers | Rebuilt from the return value | Replayed from an allowlist |
| Argument fingerprinting | Yes - same key, different body gets a 422 | No - see below |
| Runs | Inside the handler invocation | Outside the whole dispatch |
Use filter when the exact bytes matter: a handler that streams or writes its own response, a
content type negotiated at write time, or a header added by a filter further down the chain. Aspect
mode cannot see any of those, because it returns before the response is written at all.
Two things to know:
- No argument fingerprinting in filter mode. That check hashes resolved method arguments, which do not exist yet outside the dispatch. The equivalent would be hashing the request body, which means buffering every request - a real cost on every endpoint to serve one. So in filter mode a duplicate key with a different body replays the original response rather than getting a 422.
- Headers are an allowlist, not everything. Replaying
Set-Cookiewould hand a second caller the first caller's session. Add your own viaidempotency.filter.replay-headersif clients depend on them.
When a process dies mid-request its key stays IN_PROGRESS until the claim lease
expires, and retries get a 409 until it does. If you need that key back sooner, expose the
management endpoint:
management:
endpoints:
web:
exposure:
include: health,info,idempotency# What is holding this key?
curl "localhost:8080/actuator/idempotency?key=abc123&method=POST&route=/orders"
# {"storageKey":"...","found":true,"state":"IN_PROGRESS","createdAt":"..."}
# Let the customer retry now.
curl -X DELETE "localhost:8080/actuator/idempotency?key=abc123&method=POST&route=/orders"Pass the route pattern, not the request URI - /orders/{id}, not /orders/42 - and add
&namespace=alice for a user- or tenant-scoped endpoint. Get either wrong and you get a
valid-looking key that addresses nothing, which is why the response tells you plainly when it
evicted nothing.
The stored response body is deliberately not returned; the endpoint reports its size instead. That body is your application’s own response, frequently customer data.
This endpoint is sensitive. Evicting a key lets the next duplicate execute for real, so anyone who can reach it can defeat idempotency for a request they can name. Actuator exposes only
healthandinfoby default - secure it like any other management endpoint.
IdempotencyStore.find and release take the hashed storage key. IdempotencyKeys derives it, so
you can use the store SPI directly from your own admin tooling or tests:
String storageKey = IdempotencyKeys.storageKey("abc123", "POST", "/orders");
store.find(storageKey).ifPresent(record -> ...);It is the single definition of the key format, shared by the servlet, reactive and filter paths - so a key means the same thing on every stack.
Implement IdempotencyStore (four methods: claim, complete, release, find) and register
it as a @Bean — @ConditionalOnMissingBean means yours wins over the built-ins:
@Bean
IdempotencyStore idempotencyStore(/* ... */) {
return new DynamoDbIdempotencyStore(/* ... */);
}ScopeResolver decides the namespace that keeps one caller's key from colliding with another's.
Everything it needs arrives in the IdempotencyContext — it never reads ambient state, which is
what lets the same resolver work on any stack:
@Bean
ScopeResolver apiKeyScopeResolver() {
return new ScopeResolver() {
@Override public IdempotencyScope supports() { return IdempotencyScope.CUSTOM; }
@Override public String namespace(IdempotencyContext context) {
Object auth = context.authentication().orElse(null);
if (!(auth instanceof MyToken token)) {
// IllegalStateException specifically: it routes through
// idempotency.on-missing-principal instead of reaching the caller as a 500.
throw new IllegalStateException("no API key on this request");
}
return token.accountId();
}
};
}Other extension points: IdempotencyMetrics (wire up Micrometer or anything else),
IdempotencyTracer (send the per-request outcome to your own tracing or audit sink), and
IdempotencyObjectMapperCustomizer (register your application's Jackson modules on the starter's
internal payload mapper — it deliberately never reuses your app's own ObjectMapper).
From 1.0.0 this project follows semantic versioning, and the point of the 1.0 line is that the list below is a promise rather than an intention.
Covered — a breaking change here requires a new major version:
io.github.benhendayoussef.idempotency.api.** |
@Idempotent and its attributes, IdempotencyStore, ScopeResolver, IdempotencyContext, IdempotencyRecord, IdempotencyMetrics, IdempotencyTracer, IdempotencyKeys, the enums and the exception types |
| Configuration property names and meanings | Every idempotency.* key, its default, and what it does |
| The storage key format | The hash of namespace, method, route and client key. Changing it would orphan every record already in your store |
| The stored record format | A record written by 1.x stays readable by every later 1.x, so a rolling upgrade never strands in-flight keys |
| The actuator endpoint | Its id, parameters and response fields |
| Metric and trace names | idempotency.requests, its outcome tag values, and the idempotency.outcome span tag. Values may be added; existing ones will not change meaning |
| Autoconfiguration class names | People name these in spring.autoconfigure.exclude, so the names are API even though their @Bean methods are not |
Not covered — these change in any release:
- Every
internalpackage, in every module. Each one says so in its ownpackage-info.java, and a test enforces that they all do. If you are importing from one, you are outside the supported API — open an issue and say what you needed, because that is a gap worth closing properly. - The
@Beanmethod signatures inside the autoconfiguration classes. - Log message wording.
- Anything marked
@Deprecated(forRemoval = true), after the removal window below.
Deprecation. Anything being removed is deprecated in a minor release first, with
@Deprecated(since = "1.x", forRemoval = true) and a javadoc pointer to the replacement. It stays
for the remainder of the major version — a minor release never removes covered API.
Spring Boot. Both supported generations are exercised by the full test suite in CI on every push, and dropping one is a major-version change. Java 17 stays the baseline for all of 1.x.
Adding to an interface. IdempotencyMetrics and IdempotencyContext are designed so they can
grow: every method on them has, or can be given, a default body. New outcomes and new context
accessors will arrive in minor releases without breaking implementors. IdempotencyRecord is a
record and therefore cannot gain components — if its shape ever has to change, that is a new type,
not a modified one.
| Starter version | Spring Boot | Java |
|---|---|---|
| 1.0.x | 3.5.x and 4.1.x | 17+ |
| 0.4.x | 3.5.x and 4.1.x | 17+ |
| 0.3.x | 3.5.x and 4.1.x | 17+ |
| 0.2.x | 4.1.x (Spring Framework 7) | 17+ |
| 0.1.x | 4.1.x (Spring Framework 7) | 17+ |
From 0.3.0 there is one artifact for both Spring Boot generations - no -boot3 classifier and no
separate version line. Every Spring API the library uses exists in both, so it is compiled against the
lower bound and the full suite runs against both in CI on every push.
- 0.2 — ✅ Genuine exactly-once for the JDBC store via
idempotency.jdbc.join-transaction - 0.3 — ✅ Spring Boot 3 support, Micrometer metrics, MySQL/MariaDB store, Caffeine store,
WebFlux (
Monohandlers), andmode: filterfor byte-exact replay - 0.4 — ✅ Operability: a claim lease separate from the retention window,
IdempotencyKeysfor addressing a record from your own code, an actuator endpoint to inspect and evict one, and anidempotency.outcometag on the request's trace span - 1.0 — ✅ A stable, supported API. See API stability
- 1.1 — A reactive store SPI (R2DBC, reactive Redis), so WebFlux no longer schedules blocking
store calls onto
boundedElastic; GraalVM native image hints. Both additive - 1.2 — Kotlin coroutine support,
@Idempotenton@KafkaListener
Issues and PRs are very welcome. If you've shipped idempotency in production and have thoughts on the failure policy or the exactly-once limitation, open a discussion — that feedback directly shapes the roadmap.
Apache-2.0