Skip to content

Idempotency

The idempotency middleware ensures that duplicate requests with the same key return the same response without re-executing the handler. This is critical for payment processing, order creation, and any operation that must not be repeated.

import { handler, idempotencyMiddleware, createDefaultIdempotencyStore } from '@venturekit/runtime';
const store = await createDefaultIdempotencyStore();
export const main = handler({
middleware: [idempotencyMiddleware({ store })],
fn: async (body) => {
const order = await createOrder(body);
return { data: order };
},
});

Clients include an Idempotency-Key header:

Terminal window
curl -X POST https://api.example.com/orders \
-H "Idempotency-Key: order-abc-123" \
-d '{"item": "widget", "qty": 2}'

The first request executes normally. Any subsequent request with the same key returns the cached response without calling the handler again.

  1. Extract idempotency key from the request (default: Idempotency-Key header)
  2. Check the store for an existing record:
    • Completed: return the cached response immediately
    • Pending: return 409 Conflict (another request is in flight)
    • Not found: proceed to step 3
  3. Save a pending record
  4. Execute the handler
  5. Update the record to completed with the response
  6. On handler error: delete the record (allows retry)
import { idempotencyMiddleware, createDynamoDBIdempotencyStore } from '@venturekit/runtime';
const store = await createDynamoDBIdempotencyStore({
tableName: 'venturekit-idempotency', // default
});
const idempotency = idempotencyMiddleware({ store, ttlSeconds: 86400 });

createDefaultIdempotencyStore() picks the right backend automatically:

  • Lambda: DynamoDB (distributed, correct with multiple instances)
  • Local dev: In-memory
import { createDefaultIdempotencyStore } from '@venturekit/runtime';
const store = await createDefaultIdempotencyStore();
idempotencyMiddleware({
store,
// Custom key extractor (default: Idempotency-Key header)
keyExtractor: headerKeyExtractor('X-Request-Token'),
// TTL in seconds (default: 86400 = 24 hours)
ttlSeconds: 3600,
// Require idempotency key on all requests (default: false)
required: true,
});
import { headerKeyExtractor, bodyFieldKeyExtractor } from '@venturekit/runtime';
// From a header (default)
headerKeyExtractor('Idempotency-Key')
// From a body field
bodyFieldKeyExtractor('requestId')

When required: true, requests without an idempotency key receive a 400 Bad Request:

{
"error": {
"code": "MISSING_IDEMPOTENCY_KEY",
"message": "Idempotency-Key header is required"
}
}

The saga pattern has built-in idempotency via explicit IDs and step tracking. When combined with the idempotency middleware, you get end-to-end protection:

import { handler, idempotencyMiddleware, saga, createDefaultIdempotencyStore, createDefaultSagaStore } from '@venturekit/runtime';
const idempotencyStore = await createDefaultIdempotencyStore();
const sagaStore = await createDefaultSagaStore();
const createOrderSaga = saga('create-order', steps, { store: sagaStore });
export const main = handler({
middleware: [idempotencyMiddleware({ store: idempotencyStore, required: true })],
fn: async (body, ctx) => {
const result = await createOrderSaga.run(body, {
id: ctx.rawEvent.headers?.['idempotency-key'], // reuse as saga ID
traceId: ctx.trace?.traceId,
});
return { data: result.context };
},
});

The idempotency store table needs:

  • Partition key: pk (String)
  • TTL attribute: ttl (Number)
Terminal window
aws dynamodb create-table \
--table-name venturekit-idempotency \
--attribute-definitions AttributeName=pk,AttributeType=S \
--key-schema AttributeName=pk,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
aws dynamodb update-time-to-live \
--table-name venturekit-idempotency \
--time-to-live-specification "Enabled=true, AttributeName=ttl"