Retries are safe.Charges happen once.
Add Idempotency-Key support to any Elysia route. A retried request gets the original response instead of running your handler again.
Playground
Retries get the original response
Send a $42.00 payment, lose responses on the way back, click twice. The receipt prints a line only when the server really charges the card.
6d66…c0ddTimeline With key
The server finished the first time. The retries got the saved response.
- Requests sent
- 3
- Handler runs
- 1
- Charges
- 1
- Duplicates
- 0 duplicate charges
ACME COFFEE · SERVER LEDGER
- CHARGE$42.00card •••• 4242#109:41:00.440
TOTAL CHARGED$42.00
1 charge
6d66…c0ddState machine
Every key moves through four states
One record per key, scoped to the client that sent it. Each step is a single atomic update in the store, so two requests can never both take the lock.
New
No record for this key yet. The first request takes a lock and your handler runs.
- begin
In progress
The lock lives for
lockTimeout. The same key meanwhile gets409withRetry-After: 1.- 2xx to 4xx
Completed
The response is kept for
retention. The same request gets it back withIdempotent-Replayed: true; a different body gets422.- From In progress: 5xx or a throw
Released
The lock is removed and the client gets the original error. A retry runs the handler again.
Status codes
What each status tells the client
A replay carries the original status and body, plus Idempotent-Replayed: true. The plugin only ever adds these four.
| Status | Meaning | What the client should do |
|---|---|---|
| 400 | The key is missing on a route with required: true, or the key is invalid. | Send a valid key. Do not retry the same request as is. |
| 409 | The first request with this key has not finished yet. | Wait for Retry-After seconds, then retry with the same key. |
| 422 | The key was already used for a different body, path, or method. | A client bug: use a new key for a new operation. |
| 503 | The store failed, and failureMode is 'closed' (the default). | Wait for Retry-After seconds, then retry with the same key. |
Usage
One line protects a route
Every example is copied from the README. Keys are kept once per client, for 24 hours by default.
Quick start
import { Elysia } from 'elysia'
import { idempotency } from 'elysia-idempotency'
let charges = 0
const app = new Elysia()
.use(idempotency())
.post('/charges', ({ body }) => ({ charge: ++charges, body }))
.listen(3000)Require a key
import { Elysia } from 'elysia'
import { idempotency } from 'elysia-idempotency'
const orders = new Elysia({ prefix: '/orders' })
.use(idempotency({ required: true, prefix: 'orders' }))
.post('/', () => ({ created: true }))
.patch('/:id', ({ params }) => ({ updated: params.id }))
const app = new Elysia()
.post('/search', () => ({ results: [] }))
.use(orders)Redis
import { RedisClient } from 'bun'
import { Elysia } from 'elysia'
import { RedisStore, idempotency } from 'elysia-idempotency'
const redis = new RedisClient(process.env.REDIS_URL ?? 'redis://localhost:6379')
const store = new RedisStore({ client: redis })
const app = new Elysia()
.use(idempotency({ store }))
.post('/orders', () => ({ created: true }))
.onStop(() => redis.close())Cookie-session identity
import { Elysia } from 'elysia'
import { idempotency } from 'elysia-idempotency'
const sessions = new Map<string, { readonly userId: string }>()
const app = new Elysia()
.use(
idempotency({
identity: ({ elysia, ip }) => {
const sessionId = elysia.cookie.session?.value
const session = typeof sessionId === 'string' ? sessions.get(sessionId) : undefined
if (session !== undefined) return `user:${session.userId}`
return ip === null ? 'anonymous' : `ip:${ip}`
},
}),
)
.post('/posts', () => ({ created: true }))Use with elysia-rate-limiter
import { Elysia } from 'elysia'
import { idempotency } from 'elysia-idempotency'
import { rateLimit } from 'elysia-rate-limiter'
const app = new Elysia()
.use(rateLimit({ limit: 20, window: '1m' }))
.use(idempotency())
.post('/orders', () => ({ created: true }))Engine in a queue consumer
import { IdempotencyEngine } from 'elysia-idempotency'
interface OrderPaid {
readonly messageId: string
readonly orderId: string
}
const engine = new IdempotencyEngine({ retention: '7d', lockTimeout: '5m' })
const shipOrder = async (orderId: string): Promise<void> => {
console.log(`shipping ${orderId}`)
}
export const onOrderPaid = async (message: OrderPaid): Promise<'done' | 'duplicate' | 'busy'> => {
const fingerprint = Bun.CryptoHasher.hash('sha256', JSON.stringify(message), 'hex')
const outcome = await engine.begin({ scope: 'order-paid', key: message.messageId, fingerprint })
if (outcome.kind === 'replay' || outcome.kind === 'mismatch') return 'duplicate'
if (outcome.kind === 'in-progress') return 'busy'
try {
await shipOrder(message.orderId)
await engine.complete(outcome.lease, { status: 200, headers: [], body: null, bodyOmitted: false })
return 'done'
} catch (error) {
await engine.release(outcome.lease)
throw error
}
}Questions
Questions developers ask
Does this replace unique constraints?
No. The plugin stops the same request from running twice. It cannot stop two different requests (two keys, or no key at all) from creating the same order, and it cannot help once a record expires or the store is wiped. Keep unique constraints and transactions in your database, and treat this plugin as the layer that makes client retries safe.
Why not PUT or DELETE?
By default the plugin covers POST and PATCH, the methods that usually create or change something each time they run. PUT and DELETE are meant to be idempotent already. If yours are not, add them with the methods option. GET, HEAD, and OPTIONS cannot be added.
How long are keys kept?
A completed response is replayed for retention, 24 hours by default and up to 30 days. An unfinished lock lives for lockTimeout, 1 minute by default. After that, the same key counts as a new request, as the “Next day” scenario shows.
What happens with more than one server?
The default MemoryStore lives inside one process, so a retry that lands on another instance runs the handler again. Use RedisStore: every update is a compare-and-set Lua script, so two identical requests on different instances still never both run the handler.
What if my handler is slower than lockTimeout?
The lock expires while the handler is still running, so a retry can run the handler a second time. Only one of the two responses is stored. Set lockTimeout comfortably above your slowest handler.
Why does a 500 not get stored?
A 5xx or a thrown error means the work did not finish. Storing it would replay the failure forever, even after the database comes back. So the plugin releases the key and the retry runs again, as in the “Server hiccup” scenario. To store a final error, return it with a 4xx such as status(402, ...).