Skip to main content
Version: 2.5

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:

  1. The idempotent public API — a client sends the Idempotency-Key HTTP 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.
  2. The programmatic wrapper — server-side code wraps a block in executeIdempotently (formerly dataliteIdempotency, 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", ... }
SituationResponse
First request with the keyExecuted normally; the response is stored
Retry — same key, same bodyThe stored response is replayed with the header Idempotency-Replay: true
Same key, different body422 Unprocessable Entity — the key was recycled for a different request
Concurrent duplicate while the first request still runsWaits 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. RequestMdcFilter takes 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-Key contract 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:

  1. Service switch — tsm.api.idempotency.enabled=true (off by default).

  2. 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 POST create operations and non-absolute PUT/PATCH. DELETE and absolute updates are idempotent by design.

  3. Client opt-in — the request carries the Idempotency-Key header.

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 422 row above.

4. What Is Stored and Replayed​

The first execution stores the response only when it makes sense to replay it:

Response statusStored?
2xxYes
Statuses listed in cacheable-error-statusesOnly when configured. Empty by default
401, 403, 5xxNot 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) or store-failed. A failing call waits for the Redisson timeouts once; after that the filter skips Redis for redis-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-seconds loses 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 409 until 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 @PreAuthorize and 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. Keep ttl as 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-Key header; the platform replays the stored response, rejects recycled keys with 422, and serializes concurrent duplicates with 409 + Retry-After.
  • Activation is deliberate on all sides: a service property, @Idempotent on 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.