Every entry carries the message the library actually prints, quoted verbatim under a heading that says what went wrong - so the page is scannable, and a search for the exact string still lands on the right section.
Most of these are startup failures by design: a misconfigured annotation on a rarely-hit consumer would otherwise stay invisible until the day that consumer receives traffic.
By symptom
| What you are seeing | Go to |
|---|---|
| The application will not start | Startup failures |
| It starts, but duplicates still run | No store is active, then the checklist |
| Every idempotent call fails with a 500 | The records table could not be queried |
| The records table keeps growing | Purging is enabled but scheduling is not |
| A failed request keeps returning the same error | An error response is replayed for hours |
A duplicate got 204 No Content |
Completed through a non-HTTP path |
| Two different requests shared a result | Two different requests were treated as the same |
| Records vanished | After a Redis failover |
| Consumers stopped making progress | Consumer threads are parked |
| A batch fails on a key it already processed | The same key twice in one transaction |
| Transactional calls stall waiting for a connection | The connection pool is too small |
Startup failures
A demanded store could not be built
idempotency.store-typeis jdbc but no store could be built. Add the matching provider dependency (io.github.josipmusa) and make sure exactly one DataSource bean is available.
You demanded a store and the conditions for building it were not met. Either the provider
dependency is missing, or there is no DataSource bean, or there is more than one - the
JDBC store is built from a single DataSource and will not guess between two.
With several data sources, declare the store yourself against the one you want. See JDBC.
The store cannot complete inside a caller’s transaction
idempotency.completion-modeis join-transaction, but the configured idempotency store (<store class>) cannot complete inside a caller’s transaction. Use a store that can, such as the JDBC one, or setidempotency.completion-mode=autonomous.
Only the JDBC store supports joined completion. The Redis and in-memory stores report that they cannot, and asking for it anyway fails the context rather than silently downgrading to autonomous completion. Asking for it on a single annotation fails the same way, with a message naming the method:
@Idempotent(completion = "join-transaction")on<Class>.<method>cannot be honoured: the configured idempotency store cannot complete inside a caller’s transaction. Use a store that can, such as the JDBC one, or drop the attribute to complete autonomously.
An IdempotentAdvisor is declared as a bean
IdempotentAdvisor must not be registered as a bean: an auto-proxy creator would apply it at an order that ties with the transaction advisor’s. Register IdempotentBeanPostProcessor instead - the starter already does.
Remove the bean. With the starter there is nothing to replace it with; without the starter,
register IdempotentBeanPostProcessor instead. The post-processor places the idempotency
advice inside the bean’s own transaction, which is what
joined completion and a completion that waits for the
commit both depend on. An @EnableTransactionManagement(order = ...) kept from 0.4.x is
harmless and no longer needed.
A codec is required on a value-returning method
@Idempotent(codec = ...)is required on<method>: it returns<type>, and a duplicate call has to be given that value back. Name aPayloadCodecbean that encodes it, or make the method void.
A value-returning method needs somewhere for the value to be stored, and the library does not
guess at a serialisation format. Write a codec, or make
the method void if a duplicate genuinely needs nothing back.
A key is required on an annotated method
@Idempotent(key = ...)is required on<method>: a method has no transport to take an idempotency key from, so the key must be an expression over its parameters, for example"#event.id()"
A method has no transport to take a key from, so the SpEL expression is required. On an HTTP
endpoint the opposite holds and key is rejected - the client’s header is the key. See
the annotation reference.
The purge cron expression is invalid
Invalid
idempotency.purge.cronvalue:'<cron>'
The value is not a valid Spring cron expression. The default is 0 0 * * * *, which is six
fields, not five - Spring cron expressions carry a leading seconds field.
Malformed durations and out-of-range values
Every duration on the annotation is ISO-8601, so thirty seconds is PT30S, not 30s.
A malformed one fails startup naming the attribute and the method:
Invalid
@Idempotent(ttl = "30s")on<Class>#<method>: not a valid ISO-8601 duration (e.g. “PT10S”, “PT5M”, “PT1H”)
Several bounds are validated at construction and name the value they received:
defaultTtl must be at least 1ms, defaultLeaseDuration must be at least 2ms,
inFlightStatus must be a 4xx or 5xx status, requestFingerprint must be a hex string.
Warnings worth treating as errors
No store is active, so nothing is deduplicated
No IdempotencyStore bean is present, so idempotency is inactive: no engine, no filter, and no request is deduplicated. Add a provider dependency, or declare a store bean, or set
idempotency.store-type=noneto silence this.
This is the one to check first if duplicates are reaching your handler. Under the default
store-type: auto, a missing provider or absent DataSource is a warning rather than a
failure, and the application starts and serves traffic with no idempotency at all.
Nothing else in the application looks wrong. Every request succeeds. The duplicates arrive later, in production.
auto deliberately does not fall back to the in-memory store - see
choosing a store. If this warning is acceptable in a given
environment, set store-type: none so the silence is a decision rather than an accident.
The selected store is logged at startup, so the positive confirmation is in the same log.
The records table could not be queried
The
idempotency_recordstable could not be queried (<SQL error>), so every idempotent call will fail until it exists. Create it from theidempotency-schema-postgresql.sqloridempotency-schema-mysql.sqlfile shipped in theidempotency-jdbcjar, or setidempotency.jdbc.initialize-schema=alwaysto let the store create it.
The default initialize-schema: embedded creates the table only on an embedded database, so
a real database never receives DDL from a library behind its owner’s back. Point your
migration tool at the shipped schema file. See JDBC.
Purging is enabled but scheduling is not
idempotency.purge.enabledis true but@EnableSchedulingwas not detected. Expired idempotency records will NOT be purged automatically. Add@EnableSchedulingto your application class, or setidempotency.purge.enabled=falseto suppress this warning.
The setting reads as on, the scheduler is never registered, and the table grows until someone
notices. Add @EnableScheduling, or set purge.enabled=false so the configuration matches
reality. See purging and retention.
Expected debug output
Leaving an annotated endpoint to the HTTP adapter
Leaving
@Idempotent<Class>.<method>to the HTTP adapter: it is a request mapping handler, so its key comes from the request header rather than its parameters
Logged at DEBUG, and nothing is wrong. The advisor that intercepts annotated methods deliberately skips endpoints so the filter and the advisor never guard the same call under two different keys.
Runtime behaviour that surprises people
A duplicate re-executed instead of replaying
Work through these in order:
- Is a store actually active? See the warning above. This is the common answer.
- Did the first attempt throw, or its transaction roll back? Releasing deletes the record, so the next caller sees a key that was never used, and a rollback releases the key just as an exception does. That is the intended contract - see the record lifecycle.
- Has the TTL elapsed? After
default-ttlthe record is purged and a retry is a fresh execution. - Is the scope what you expect? The default is
<simple class name>.<method name>, so renaming a class or method changes the scope and orphans existing records. See scope and key. - Did the completion fail? Under the starter’s default
completion-failure-policy: log-and-return, a store that refused the completion is logged and the caller still gets its result - the guarantee is lost for that one key. A joined completion is the exception: its failure always propagates, so the transaction cannot commit your writes without the record.
An error response is replayed for hours
The filter stores whatever the handler returns, including 4xx and 5xx, as long as the handler returns normally.
If you want a failed request to be retriable, throw. If you return an error status, you are telling the library that error is the final answer for that key.
A @ControllerAdvice that converts every exception into a ResponseEntity will make every
failure permanent for its key without anyone intending it. See
HTTP endpoints.
An HTTP duplicate got 204 No Content
The key was completed through a non-HTTP path, so there is no stored response to replay. The
record is COMPLETE and its payload is not an HTTP response. If both paths need to serve HTTP
clients, give them separate scopes.
Two different requests were treated as the same
Over HTTP this cannot happen with different bodies: the filter fingerprints the body and
answers 422. On an annotated method there is no fingerprint, so make the key expression
distinguish the two requests, or drive the engine directly and add
.fingerprint(...) to the context. Without one the library has no way to know the payloads
differ, and a caller that reuses a key for different work gets the first result back.
Note the asymmetry: two acquisitions clash only when both carry a fingerprint and the two differ. A stored record without one cannot be contradicted by an incoming request that has one, and the reverse also holds - both are duplicates, not mismatches. See fingerprints.
Records disappeared after a Redis failover
Redis replication is asynchronous, so a completed record acknowledged by the primary may not have reached a replica when the primary was lost. The Redis store can require replica acknowledgement on each mutation - see Redis.
Also confirm maxmemory-policy noeviction. Under any other policy Redis may evict a record to
make room, and the store cannot detect that this happened.
Consumer threads are parked
The default default-wait is PT10S, which suits a request thread and not a consumer. Set
waitTimeout = "PT0S" on consumer methods so a redelivery is declined rather than blocking a
thread from a small fixed pool. See leases and waiting.
The same key twice in one transaction reports in flight
A record is only complete once its transaction commits, so a second call with the same key
inside the same transaction does not replay the first. It waits out its waitTimeout and
reports in flight. If the method is itself @Transactional, that exception marks the shared
transaction rollback-only on its way out, and the whole batch fails at commit with
UnexpectedRollbackException even when the caller catches it. Declare
@Transactional(noRollbackFor = IdempotencyInFlightException.class) to keep the rest of the
batch. See joining your transaction.
Transactional calls stall on the connection pool
Inside a transaction, each @Idempotent call briefly needs a second pooled connection next to
the one its transaction holds. A pool no larger than the number of concurrent transactional
@Idempotent calls can starve until the pool’s own timeout gives up. Size the pool above that
number, or put a LazyConnectionDataSourceProxy in front of the DataSource. See
annotated methods.