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.
Quick Start
Section titled “Quick Start”import { handler } from '@venturekit/runtime';import { idempotencyMiddleware, createDefaultIdempotencyStore } from '@venturekit/runtime/patterns';
const store = await createDefaultIdempotencyStore();
export const main = handler( async (body) => { const order = await createOrder(body); return { data: order }; }, { middleware: [idempotencyMiddleware({ store })] },);Clients include an Idempotency-Key header:
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.
How It Works
Section titled “How It Works”- Extract idempotency key from the request (default:
Idempotency-Keyheader) - Namespace it by the caller —
<tenantId>:<userId>when a principal is present (see Key scoping) — so one client can never replay or block another client’s key - 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 4
- Save a
pendingrecord. If two requests race past step 3, the store’s conditional write settles it and the loser also gets409 Conflict(IDEMPOTENCY_CONFLICT), never a 500 - Execute the handler
- Update the record to
completedwith the response. If that write fails, the record is removed (logged) so the key does not staypendingfor the whole TTL — the handler’s response is still returned - On handler error: delete the record (allows retry)
Key scoping
Section titled “Key scoping”The key a client sends is stored under a namespace derived from ctx.tenant / ctx.user (principalIdempotencyScope). Two users sending the same Idempotency-Key: order-1 get two independent records — without this, a cached response (cookies included) could be replayed to the wrong caller, or one caller could lock another out with a pending record. Anonymous requests have no principal and are stored unscoped.
import { idempotencyMiddleware } from '@venturekit/runtime/patterns';
// default: scope = principalIdempotencyScope (tenant + user)idempotencyMiddleware({ store });
// a custom scope functionidempotencyMiddleware({ store, scope: (ctx) => ctx.tenant?.id ?? null });
// raw keys — only where every caller legitimately shares one cache,// e.g. an unauthenticated webhook receiver keyed on the provider's event ididempotencyMiddleware({ store, scope: false });Distributed Store (Production)
Section titled “Distributed Store (Production)”DynamoDB Store
Section titled “DynamoDB Store”import { idempotencyMiddleware, createDynamoDBIdempotencyStore } from '@venturekit/runtime/patterns';
// Table name resolution: explicit `tableName` → `VENTURE_IDEMPOTENCY_TABLE`// (injected by `vk deploy`) → `${VENTURE_PROJECT_NAME}-idempotency-${VENTURE_STAGE}`.// Throws if none resolves — there is no shared default.const store = await createDynamoDBIdempotencyStore();
const idempotency = idempotencyMiddleware({ store, ttlSeconds: 86400 });vk deploy provisions the table (<project>-idempotency-<stage>, partition key pk, TTL attribute ttl) in the data stack of every project and injects VENTURE_IDEMPOTENCY_TABLE into every Lambda, so each project and stage has its own key space. Pass tableName only to point at a table you created yourself.
Auto-Selecting Store
Section titled “Auto-Selecting Store”createDefaultIdempotencyStore() picks the right backend automatically:
- Lambda: DynamoDB (distributed, correct with multiple instances)
- Local dev: In-memory
import { createDefaultIdempotencyStore } from '@venturekit/runtime/patterns';
const store = await createDefaultIdempotencyStore();Configuration
Section titled “Configuration”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,});Key Extractors
Section titled “Key Extractors”import { headerKeyExtractor, bodyFieldKeyExtractor } from '@venturekit/runtime/patterns';
// From a header (default)headerKeyExtractor('Idempotency-Key')
// From a body fieldbodyFieldKeyExtractor('requestId')Required Mode
Section titled “Required Mode”When required: true, requests without an idempotency key receive a 400 Bad Request:
{ "error": { "code": "MISSING_IDEMPOTENCY_KEY", "message": "Idempotency-Key header is required" }}With Saga Pattern
Section titled “With Saga Pattern”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 } from '@venturekit/runtime';import { idempotencyMiddleware, saga, createDefaultIdempotencyStore, createDefaultSagaStore } from '@venturekit/runtime/patterns';
const idempotencyStore = await createDefaultIdempotencyStore();const sagaStore = await createDefaultSagaStore();
const createOrderSaga = saga('create-order', steps, { store: sagaStore });
export const main = handler( 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 }; }, { middleware: [idempotencyMiddleware({ store: idempotencyStore, required: true })] },);DynamoDB Table Schema
Section titled “DynamoDB Table Schema”Projects deployed with vk deploy need nothing here — the data stack creates <project>-idempotency-<stage> and the app stack grants the Lambdas read/write on it. If you run the store against a table you manage yourself (and pass it as tableName or VENTURE_IDEMPOTENCY_TABLE), it needs:
- Partition key:
pk(String) - TTL attribute:
ttl(Number)
aws dynamodb create-table \ --table-name myapp-idempotency-prod \ --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 myapp-idempotency-prod \ --time-to-live-specification "Enabled=true, AttributeName=ttl"Related
Section titled “Related”- Saga Pattern — distributed transactions with compensation
- Lambda Invocation — calling other functions
- Middleware — rate limiting, error handling