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 } 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:

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. 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
  3. 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
  4. Save a pending record. If two requests race past step 3, the store’s conditional write settles it and the loser also gets 409 Conflict (IDEMPOTENCY_CONFLICT), never a 500
  5. Execute the handler
  6. Update the record to completed with the response. If that write fails, the record is removed (logged) so the key does not stay pending for the whole TTL — the handler’s response is still returned
  7. On handler error: delete the record (allows retry)

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 function
idempotencyMiddleware({ 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 id
idempotencyMiddleware({ store, scope: false });
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.

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();
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/patterns';
// 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 } 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 })] },
);

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)
Terminal window
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"