Lambda retries. Asynchronous invocations retry twice on error, SQS redelivers after the visibility timeout, EventBridge retries for up to 24 hours, and a network blip can deliver the same event twice. "Place the order" must still happen once.
Mayfly.Idempotency from the companion package
mayfly_aws gives a handler
exactly-once semantics backed by a DynamoDB table. It lives outside the core
package so the runtime stays free of :ssl; :inets/:ssl start with the
function (~20 ms once per execution environment).
# mix.exs
deps: [{:mayfly, "~> 1.0.0-rc"}, {:mayfly_aws, "~> 0.1"}]defmodule MyApp.Handler do
use Mayfly.Handler
@impl true
def handle(%{"orderId" => id} = event, ctx, _state) do
case Mayfly.Idempotency.run(event, fn -> place_order(event) end,
key_fun: &("order-" <> &1["orderId"]), context: ctx, ttl: 3600) do
{:ok, order} -> {:ok, %{status: "placed", order: order}}
{:ok, order, :replayed} -> {:ok, %{status: "replayed", order: order}}
{:error, :in_progress} -> {:error, %{errorType: "InProgress", errorMessage: "order #{id} is being processed"}}
{:error, reason} -> {:error, reason}
end
end
endHow it works
- Claim:
PutItemwithattribute_not_exists(id) OR expires_at < :now OR (status = INPROGRESS AND in_progress_until < :now)writesstatus: INPROGRESS. The in-progress claim is valid for the invocation's remaining time (fromcontext:), so a crashed environment cannot block the key forever. - Execute
fun.{:ok, result}→UpdateItemtoCOMPLETEDwith the JSON-encoded result andexpires_at = now + ttl. - Replay: a later call whose claim fails reads the record;
COMPLETEDreturns{:ok, result, :replayed}without runningfun;INPROGRESSreturns{:error, :in_progress}. - Release:
{:error, _}, raise, exit or throw delete the record and propagate, so the retry runsfunagain.
Verified on Lambda: identical replayed payloads (same placedAt, same random
id), record removed after a raise, parallel invocations yielding one
in_progress.
Table and permissions
aws dynamodb create-table --table-name idempotency \
--attribute-definitions AttributeName=id,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH --billing-mode PAY_PER_REQUEST
aws dynamodb update-time-to-live --table-name idempotency \
--time-to-live-specification Enabled=true,AttributeName=expires_at
IAM on the table: dynamodb:PutItem, GetItem, UpdateItem, DeleteItem.
Set MAYFLY_IDEMPOTENCY_TABLE on the function or pass table:.
Choosing the key
The default key is the SHA-256 of the JSON-encoded event. That is correct for
exact retries but treats any changed field (timestamps, request ids) as a new
request. Prefer a business key: key_fun: & &1["orderId"], or
key: "tenant:#{t}:order:#{id}". For SQS, use the message id or a field of
the body, not the whole record (receipt handles change on redelivery).
Costs and limits
One PutItem plus one UpdateItem per first execution, one GetItem per
replay; with on-demand billing that is fractions of a cent per thousand
requests. Results must be JSON-encodable and fit a DynamoDB item (400 KB);
store large results elsewhere and return a reference.