Idempotency
Networks retry. A client that times out on a POST has no way to tell whether the server executed
the request or not — and a blind retry of a create operation produces a duplicate. tSM addresses
this on two levels:
- The idempotent public API — a client sends the
Idempotency-KeyHTTP header and a retry of the same request returns the stored response of the first execution instead of running the business logic again. This chapter describes that mechanism. - The programmatic wrapper — server-side code wraps a block in
executeIdempotently(formerlydataliteIdempotency, still available as a deprecated alias) with a server-derived key. It predates the HTTP header and remains the right tool for internal and service-to-service calls — see Redis — Idempotent Execution.
Both build on the same Redis coordination layer, and both are a best-effort protection: business operations must stay correct (typically via database constraints) even when the protection is skipped. They differ in how they react to a Redis problem (see section 5): the HTTP filter lets the request through unprotected, while the programmatic wrapper propagates a Redis failure to the caller (see Redis — Fail-Open).
1. Client Contract
A client opts in per request by sending the Idempotency-Key header with a unique value it
generates — typically a UUID (the approach follows the IETF draft The Idempotency-Key HTTP Header
Field). The client owns the key: a retry reuses the same key, a new operation gets a new
key.
POST /api/v1/tickets HTTP/1.1
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{ "name": "Outage at site 42", ... }
| Situation | Response |
|---|---|
| First request with the key | Executed normally; the response is stored |
| Retry — same key, same body | The stored response is replayed with the header Idempotency-Replay: true |
| Same key, different body | 422 Unprocessable Entity — the key was recycled for a different request |
| Concurrent duplicate while the first request still runs | Waits briefly, then 409 Conflict with a Retry-After header — retry after the first execution finishes |
The replay contains the status code, Content-Type, Location, and the body, so a retried create
returns the same status and the same entity as the original call. Other response headers
(Cache-Control, ETag, Content-Disposition, custom X-* headers) are not stored, and
correlation headers such as X-Request-Id belong to the new request.
The stored response expires after a TTL (24 hours by default), matching common retry windows of external integrations. After expiry a request with the same key executes again.
Why a dedicated header and not X-Request-Id
tSM already has X-Request-Id, but it is a logging and tracing identifier, not a client
operation identifier:
- It is documented as a unique id per HTTP request.
RequestMdcFiltertakes it from the header or generates one when it is missing, puts it into the log context, and echoes it in the response. It is also forwarded to downstream Feign calls and into published entity events. - A retry is a new HTTP request that should be distinguishable in the logs, so tooling (and
clients) are free to assign it a new
X-Request-Id. Treating the header as the deduplication key would make the protection depend on a value that infrastructure may regenerate, and would turn a correlation header into a business contract. - Deduplication needs a client that reuses one value across retries. A platform-generated id never
repeats, so only a client that deliberately reuses the header would be deduplicated — that is
the
Idempotency-Keycontract under a name that already means something else.
Idempotency-Key follows the IETF draft and is understood by common HTTP clients and API gateways.
X-Request-Id keeps its role: it correlates the retries in the logs, while Idempotency-Key links
them as one operation.
Why the stored response is replayed
A duplicate that is only rejected (for example with "already processed") leaves a client that timed
out without the result it was waiting for — a created entity's ID, Location, validation errors.
The client would have to look the result up separately and could not distinguish its own retry from
a conflicting request. Replaying the stored response makes a retry transparent: the client handles
it exactly like the first response.
Clients that cannot send a header are not covered by this mechanism. For them the programmatic wrapper with a server-derived key (see above) remains the protection, and the business operation should be protected by a database constraint.
2. Activation — Three Gates
A request is handled idempotently only when all three gates hold:
-
Service switch —
tsm.api.idempotency.enabled=true(off by default). -
Endpoint opt-in — the endpoint (or its whole controller) is annotated with
@Idempotent:@Idempotent
@PostMapping
@PreAuthorize("@tsmSecurityService.hasAny('Tick.Ticket.Add')")
override fun save(...): TicketPublic { ... }Only mutating endpoints that are not naturally idempotent need it — primarily
POSTcreate operations and non-absolutePUT/PATCH.DELETEand absolute updates are idempotent by design. -
Client opt-in — the request carries the
Idempotency-Keyheader.
Any gate off means the request is processed normally, with no idempotency semantics.
3. Key Namespace
Internally the client key is namespaced as:
idem:{tenant}:{caller}:{METHOD}:{SHA-256 of path, query string and Idempotency-Key}
- Tenant and caller identity (user ID, or the API-key ID for machine callers) are part of the key on purpose: the client key is not a secret, and without the identity a replay could return a response prepared for a different caller. Requests without a resolvable identity are executed without idempotency handling rather than sharing an anonymous namespace.
- Method, path, and query string isolate keys per operation — the same key sent to a different
endpoint (or with a different query, e.g.
?dryRun=true) is a different operation. Path, query, and the client key are hashed, so the Redis key has a fixed size and the client key appears neither in the Redis keys nor in the logs. (The filter's debug and warning logs do contain the request URI.) - Redis layout. Each key owns two entries: the stored response (a string with a native TTL) and
a lock entry
<key>:lock. Both are independent keys, so they spread over the slots of a Redis Cluster. - The request-body fingerprint (SHA-256) is stored with the response and guards against key
recycling — see the
422row above.
4. What Is Stored and Replayed
The first execution stores the response only when it makes sense to replay it:
| Response status | Stored? |
|---|---|
2xx | Yes |
Statuses listed in cacheable-error-statuses | Only when configured. Empty by default |
401, 403, 5xx | Not by default — they reflect the caller's current rights or a transient failure, so a retry should re-execute. The filter does not forbid them: if they are listed in cacheable-error-statuses, they are stored and replayed for the whole ttl, so do not list them |
| Propagated exceptions (also on the async re-dispatch) | No — a retry should re-execute |
| A response that did not reach the client in full (the client disconnected while it was written) | No — counted as executed-not-cached, a retry executes again |
Error responses are not stored by default because in tSM a 400 is also raised for failures that
depend on the current data (for example "a ticket with this ID already exists", constraint
violations). The request fingerprint only guarantees the same body, not the same data state, so a
stored error would be replayed for the whole TTL even after its cause was fixed. Enable a status
only for an endpoint whose 4xx depends on the request body alone, or tell clients to send a new
key after they correct the cause.
A response cut off by a client disconnect is never stored: a retry after a timeout is exactly the case the header exists for, and replaying a truncated body for the whole TTL would be worse than executing the operation again. The filter flushes the response to the client before it stores it, so a disconnect during the last flush is detected too.
Responses larger than maxCachedResponseBytes are not stored; the request still executes under the
per-key lock. A request with a body over maxRequestBodyBytes is different: it bypasses idempotency
handling entirely (no lock, no replay, no storage, counted as skipped-large-body).
5. Fail-Open Behaviour and Monitoring
The filter never fails a request because the protection is unavailable. The request executes without idempotency protection when:
- Redis is not active in the service (one warning at startup),
- a Redis call fails or times out while the stored response is read, the lock is acquired, or the
response is stored. The failure is logged as a warning and counted as
fail-open(read and lock) orstore-failed. A failing call waits for the Redisson timeouts once; after that the filter skips Redis forredis-failure-backoff(10 s by default), so an outage does not hold every servlet thread for the full timeout. The filter does not use the reconnecting Redisson helpers. The consequence is that it does not heal a degraded connection by itself: on a single-server deployment behind an HA proxy, where pooled connections can stay on a master that became read-only, the filter stays fail-open until the Redis client is replaced by another caller (the helpers described in Redis — Resilience) or the pod restarts. - the caller has no resolvable identity,
- the request body is form-encoded or multipart (buffering it would break the container's parameter parsing), or exceeds the size limit,
- a stored entry cannot be parsed (it is treated as a miss and the request executes again).
This is not identical to the programmatic wrapper executeIdempotently, which propagates a Redis
failure to the caller instead of running the action, and which proceeds without the lock when the
wait for it elapses. The filter answers 409 in that situation.
Every request that enters idempotency handling is counted in the tsm.api.idempotency.requests
counter, tagged with result — executed, replayed, conflict, mismatch, fail-open,
skipped-anonymous, skipped-large-body, store-failed, read-failed, unlock-failed,
executed-not-cached, handler-resolution-failed. Form and multipart requests, endpoints without
@Idempotent, and a request whose first execution threw an exception are not counted by the filter.
A non-zero fail-open, store-failed or unlock-failed rate on a service that is supposed to be
protected is worth an alert.
6. Configuration
tsm:
api:
idempotency:
enabled: true # default false
lock-wait-seconds: 5 # how long a concurrent duplicate waits before 409
lock-lease-seconds: 200 # per-key lock lease; must exceed the longest request
ttl: 24h # how long a stored response is replayed
max-request-body-bytes: 1048576 # larger requests are passed through unprotected
max-cached-response-bytes: 1048576 # larger responses execute but are not stored
cacheable-error-statuses: # error statuses stored in addition to 2xx; empty by default
redis-failure-backoff: 10s # Redis is skipped for this long after a failure
The full property list is in the Configuration Properties Reference.
7. Limits
- The lock lease bounds the protection window. A request running longer than
lock-lease-secondsloses the lock mid-flight, and a concurrent duplicate may execute in parallel. Keep the lease above the longest expected request duration. - An orphaned lock blocks the key until the lease expires. When an instance dies between
acquiring the lock and finishing, duplicates receive
409until the lease runs out. - Best-effort, not exactly-once. The response is stored after the business transaction committed. A process crash between the commit and the store, a failed store, a Redis outage, or an expired lease lets a retry execute the operation again. Endpoints must tolerate an occasional duplicate (a unique constraint over the business key, natural idempotency of the operation). A hard guarantee needs a record written in the same database transaction as the business change, which a generic servlet filter cannot do; external side effects need their own idempotency or an outbox.
- A replay does not re-run authorization. The filter answers from the cache without invoking
the controller, so
@PreAuthorizeand other per-resource checks are not evaluated again. The caller identity is part of the key, so only the same caller receives the stored response, but a permission revoked during the TTL does not stop the replay. Keepttlas short as the clients' retry window allows. - The lock is owned by the request. The lock holds a token unique per request and is released
by compare-and-delete, so it works across the async re-dispatch, and a duplicate arriving on the
same thread gets
409. A reconnect of the Redis client does not leave the key locked either.
Summary
- Clients make retries safe by sending an
Idempotency-Keyheader; the platform replays the stored response, rejects recycled keys with422, and serializes concurrent duplicates with409+Retry-After. - Activation is deliberate on all sides: a service property,
@Idempotenton the endpoint, and the header on the request. - Keys are namespaced by tenant and caller identity; the request body is fingerprinted.
- The mechanism is fail-open and observable via
tsm.api.idempotency.requests— correctness must never depend on it alone. - Related chapters: Redis (the coordination layer and the programmatic
executeIdempotently), Rate Limit Usage, Process Engine Transactions.