Idempotency Pipeline
Introduction
Section titled “Introduction”Use the Idempotency Pipeline to prevent the same Command from being processed more than once for the same request ID.
When idempotency is enabled, Xeno.JS:
- checks whether the request has already been processed;
- returns the stored result when one is available;
- otherwise acquires an idempotency lock;
- executes the command;
- stores a successful result for subsequent requests;
- releases the lock when the command fails or throws an exception.
Idempotency is available for Commands only. It is not added to the Query pipeline.
Before you start
Section titled “Before you start”You need:
- an
AppBuilderinstance; - a Command and its handler;
- a configured cache;
- a network request context that provides a
requestId; - the command executed through the Xeno.JS mediator.
If you are creating the Command itself, see Create a Command.
If you need to configure the cache used by Xeno.JS, see the cache documentation.
Enable idempotency
Section titled “Enable idempotency”Configure commandBus.idempotency through addPipeline():
const app = new AppBuilder() .addPipeline((config) => { config.commandBus.idempotency = { lockTtlSeconds: 30, processedTtlSeconds: 60, } }) .build()You do not need to instantiate or register IdempotencyPipeline manually.
Once commandBus.idempotency is defined, Xeno.JS adds the idempotency behavior to the Command pipeline.
Configure the lock TTL
Section titled “Configure the lock TTL”Use lockTtlSeconds to control how long an idempotency lock remains in the cache.
const app = new AppBuilder() .addPipeline((config) => { config.commandBus.idempotency = { lockTtlSeconds: 30, processedTtlSeconds: 60, } }) .build()The value is expressed in seconds.
The value must be a positive integer.
For example:
lockTtlSeconds: 30means that the lock can remain in the cache for up to 30 seconds if it is not explicitly released earlier.
The lock is released automatically by the pipeline when:
- the command returns a failed
Result; - command execution throws an exception.
A successful command is marked as processed instead of explicitly releasing the lock.
Configure the processed-result TTL
Section titled “Configure the processed-result TTL”Use processedTtlSeconds to control how long the result of a successfully processed command remains available.
const app = new AppBuilder() .addPipeline((config) => { config.commandBus.idempotency = { lockTtlSeconds: 30, processedTtlSeconds: 300, } }) .build()The value is expressed in seconds and must be a positive integer.
While the processed result is still available, another request with the same requestId receives the stored result instead of executing the command again.
Use the request ID
Section titled “Use the request ID”Idempotency is based on the request’s requestId.
The same request ID identifies the same idempotent operation.
For example, if a command is executed with:
requestId = "request-123"and the command completes successfully, a subsequent execution using the same request ID can return the stored result without invoking the command handler again.
The request ID must therefore remain stable when a client intentionally retries the same operation.
Do not generate a new request ID for every retry if the intention is to retrieve the result of the original operation.
What happens when a request was already processed?
Section titled “What happens when a request was already processed?”When Xeno.JS finds a processed result for the request ID, it returns that result immediately.
The command pipeline does not execute the command again.
Conceptually:
Request │ ▼Has this request already been processed? │ ├── Yes ──► Return stored result │ └── No │ ▼ Acquire lock │ ▼ Execute command │ ▼ Store successful resultThis is the main behavior provided by the Idempotency Pipeline.
What happens when another request is processing the same ID?
Section titled “What happens when another request is processing the same ID?”If the request has not been processed yet but another execution already holds the idempotency lock, the second execution cannot acquire the lock.
Xeno.JS returns a failed Result containing a conflict error.
The command handler is not executed by the second request.
This prevents two concurrent executions with the same request ID from processing the command simultaneously.
What happens when the command succeeds?
Section titled “What happens when the command succeeds?”When the command returns a successful Result, Xeno.JS stores the result using the configured processedTtlSeconds.
For example:
const result = Result.ok({ id: user.id,})The successful payload is stored.
A subsequent request with the same request ID can receive:
{ id: user.id,}without executing the command again.
What happens when the command returns a failure?
Section titled “What happens when the command returns a failure?”A failed command result is not stored as the processed result.
For example:
return Result.fail( AppError.create({ code: 'USER_NOT_FOUND', message: 'User not found', status: 404, name: 'CreateUserCommand', }),)The idempotency lock is released and the failed result is returned.
A later request with the same request ID can therefore attempt the command again.
This is different from a successful execution, where the result remains available for the configured processed-result TTL.
What happens when the command throws?
Section titled “What happens when the command throws?”If command execution throws an exception, Xeno.JS releases the idempotency lock and rethrows the exception.
The exception is not stored as a processed result.
This means that a later request using the same request ID can attempt the command again.
For example:
const result = await mediator.send(command)If the handler throws:
Command execution │ ▼ exception │ ▼ release lock │ ▼ rethrow exceptionConfigure both TTLs
Section titled “Configure both TTLs”A typical configuration explicitly sets both TTL values:
const app = new AppBuilder() .addPipeline((config) => { config.commandBus.idempotency = { lockTtlSeconds: 30, processedTtlSeconds: 300, } }) .build()Use:
lockTtlSecondsfor the lifetime of an in-progress idempotency lock;processedTtlSecondsfor the lifetime of a successful command result.
Both values must be positive integers.
If they are omitted, Xeno.JS uses the built-in defaults.
Complete example
Section titled “Complete example”The following example shows the relevant application configuration:
import { AppBuilder } from '@xeno-js/core'
const app = new AppBuilder() .addPipeline((config) => { config.commandBus.idempotency = { lockTtlSeconds: 30, processedTtlSeconds: 300, } }) .addServices((services) => { services.addTransient('CREATE_USER_HANDLER', (container) => { return new CreateUserHandler( container.resolve('USER_REPOSITORY'), ) }) }) .build()Your command can then be executed normally through the mediator:
const result = await mediator.send( new CreateUserCommand({ email: 'user@example.com', name: 'John Doe', }),)The idempotency pipeline operates around the command execution without requiring the handler to implement locking or result storage itself.
Idempotency applies to Commands
Section titled “Idempotency applies to Commands”Idempotency is configured under:
config.commandBus.idempotencyIt is therefore part of the Command pipeline.
It does not apply to:
config.queryBusQueries have different execution semantics and are not processed through the idempotency pipeline.
If you need query result caching, see Caching.
Idempotency requires a cache
Section titled “Idempotency requires a cache”The built-in idempotency store uses the configured Xeno.JS cache to store:
- active idempotency locks;
- successfully processed command results.
Make sure a cache implementation is configured before enabling idempotency.
If the cache is not available, application configuration cannot construct the idempotency store required by the pipeline.
Troubleshooting
Section titled “Troubleshooting”The command is executed more than once
Section titled “The command is executed more than once”Check:
config.commandBus.idempotencyis configured;- the command is executed through the Xeno.JS mediator;
- the requests use the same
requestId; - a cache implementation is configured;
- the processed-result TTL has not expired.
Remember that using a different request ID represents a different idempotent operation.
The second request returns a conflict
Section titled “The second request returns a conflict”This can happen when the first request is still processing the command.
Check:
- whether the first request is still running;
- whether
lockTtlSecondsis appropriate for the command duration; - whether the same request ID is being used concurrently.
A request that cannot acquire the existing lock does not execute the command.
The command can be executed again after a failure
Section titled “The command can be executed again after a failure”This is expected behavior.
Failed Result values are not stored as processed results. The lock is released so that a later request can retry the operation.
The same request is processed again after some time
Section titled “The same request is processed again after some time”Check processedTtlSeconds.
Once the stored successful result expires, a later request with the same request ID can be processed again.
Choose a processed-result TTL that matches how long your application needs to recognize repeated requests as duplicates.
Idempotency configuration throws an error
Section titled “Idempotency configuration throws an error”Check that:
lockTtlSecondsprocessedTtlSecondsare positive integers.
Values such as these are invalid:
lockTtlSeconds: 0processedTtlSeconds: -1lockTtlSeconds: 1.5Related documentation
Section titled “Related documentation”- Application Overview
- Create a Command
- Create a Command Handler
- Configure Concurrency
- Enable Caching
- Handle Exceptions
Support Us
Section titled “Support Us”Xeno.JS is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section
