Skip to content

Bug: Idempotency completes or deletes the wrong record when the wrapped function mutates its input #5715

Description

@svozza

Expected Behavior

The record acquired at the start of an idempotent operation should be the record that is completed on success or deleted on failure. The key and validation hash should be fixed when the operation acquires its record, regardless of what the wrapped function does to its arguments afterwards.

Current Behavior

IdempotencyHandler keeps a reference to the payload it hashes, and BasePersistenceLayer recomputes the idempotency key and validation hash from that payload on every call. If the wrapped function mutates its input, for example by normalising fields or adding a processed flag, saveInProgress() hashes the original payload and saveSuccess() or deleteRecord() hash the mutated one, so they target a different key.

Consequences with the default whole-event hash:

  • On success, the completed result is written under a key no caller will ever look up. The original record stays INPROGRESS. A retry with the same event throws IdempotencyAlreadyInProgressError until the in-progress deadline passes, and once the record expires the side effect can run again.
  • On failure, cleanup deletes the wrong key and the original in-progress record is left behind until it expires.
  • With payload validation enabled, mutating a validated field changes the hash stored at completion, so an identical retry can fail validation.

This applies to makeIdempotent, the decorator, and the Middy middleware, since all three pass the event object by reference. Mutating the event in a handler is common enough that the framework should not depend on callers avoiding it.

Code snippet

import assert from 'node:assert/strict';
import {
  IdempotencyConfig,
  makeIdempotent,
} from '@aws-lambda-powertools/idempotency';
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';

const persistenceStore = new DynamoDBPersistenceLayer({
  tableName: process.env.IDEMPOTENCY_TABLE_NAME ?? 'idempotency',
});

const processOrder = makeIdempotent(
  async (order: { id: string; amount: number; normalized?: boolean }) => {
    order.normalized = true; // mutates the object used for hashing
    return { processed: order.id };
  },
  {
    persistenceStore,
    config: new IdempotencyConfig({ expiresAfterSeconds: 3600 }),
  }
);

const first = await processOrder({ id: 'order-1', amount: 10 });

// Same event again should replay the stored result.
// Currently throws IdempotencyAlreadyInProgressError: the completed result was
// saved under the hash of { id, amount, normalized: true }, while the record
// for { id, amount } is still INPROGRESS.
const second = await processOrder({ id: 'order-1', amount: 10 });
assert.deepEqual(second, first);

Steps to Reproduce

  1. Wrap a function with makeIdempotent using the default configuration, so the whole event is hashed.
  2. Inside the function, add or change a property on the event object.
  3. Invoke it once, then inspect the table: one record is INPROGRESS under the hash of the original event, and a COMPLETED record exists under the hash of the mutated event.
  4. Invoke it again with an identical event. It throws IdempotencyAlreadyInProgressError instead of returning the stored result.
  5. As a control, have the function clone the event before mutating it. Both invocations return the same result and only one record exists.

Possible Solution

Bind record identity to the operation rather than recomputing it on every persistence call. Two approaches:

  • Compute the idempotency key and validation hash once in IdempotencyHandler when the record is acquired, and pass them to the subsequent saveSuccess() and deleteRecord() calls. This is the same direction as Bug: Idempotency operations sharing a persistence store complete under the wrong key prefix #5707, which pinned the key prefix per operation, and would let the prefix re-application added there be replaced by an explicit key.
  • Alternatively, snapshot the payload before invoking the wrapped function, for example with structuredClone, and hash the snapshot throughout. Simpler, but it copies every event and does not cover the shared-prefix case.

The first is recommended.

Powertools for AWS Lambda (TypeScript) version

2.35.0

AWS Lambda function runtime

22.x

Packaging format used

npm

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

idempotencyThis item relates to the Idempotency Utilitypending-releaseThis item has been merged and will be released soon

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions