Expected Behaviour
When two concurrent Lambda invocations use the same idempotency key with RedisCachePersistenceLayer (or CachePersistenceLayer), and the first invocation is still genuinely running, the second invocation should be rejected with IdempotencyItemAlreadyExistsError — not proceed to execute the underlying function.
Current Behaviour
If the in-progress record's in_progress_expiry_timestamp is None, the second invocation incorrectly treats the first (still-running) invocation's record as an abandoned "orphan" and overwrites it, then proceeds to execute the function — even though the first invocation is still actively running.
in_progress_expiry_timestamp is None whenever remaining_time_in_millis was not provided when save_inprogress was called (persistence/base.py), which happens whenever IdempotencyConfig.register_lambda_context() was never called. Concretely, this is the normal, default state for idempotent_function: unlike the idempotent() handler decorator (which does call config.register_lambda_context(context)), idempotent_function's decorate() never calls register_lambda_context() at all. So any typical use of @idempotent_function(...) — e.g. to make a helper function idempotent inside an SQS batch loop — hits this gap by default.
Root cause
aws_lambda_powertools/utilities/idempotency/persistence/redis.py, _put_in_progress_record:
idempotency_record = self._get_record(data_record.idempotency_key)
if idempotency_record.status == STATUS_CONSTANTS["COMPLETED"] and not idempotency_record.is_expired:
raise IdempotencyItemAlreadyExistsError
if (
idempotency_record.status == STATUS_CONSTANTS["INPROGRESS"]
and idempotency_record.in_progress_expiry_timestamp
and idempotency_record.in_progress_expiry_timestamp > int(now.timestamp() * 1000)
):
raise IdempotencyItemAlreadyExistsError
# Reaching this point indicates that the idempotency record found is an orphan record...
raise IdempotencyPersistenceConsistencyError
The INPROGRESS guard requires in_progress_expiry_timestamp to be truthy. When it's None (the common idempotent_function case above), the guard is skipped entirely regardless of whether the record is genuinely, actively in progress — execution falls straight through to IdempotencyPersistenceConsistencyError, which is caught and leads to an unconditional overwrite with no NX guard:
except IdempotencyPersistenceConsistencyError:
with self._acquire_lock(name=item["name"]):
self.client.set(name=item["name"], value=encoded_item, ex=ttl)
By contrast, persistence/dynamodb.py's conditional-write expression requires attribute_exists(#in_progress_expiry) before allowing an expired-in-progress reclaim — a missing attribute correctly blocks the competing writer there instead of granting a reclaim. The Redis layer's None-is-falsy check inverts this safety property: it fails open instead of closed.
Notably, persistence/base.py's save_inprogress already logs a warning for exactly this situation:
UserWarning: Couldn't determine the remaining time left. Did you call register_lambda_context on IdempotencyConfig?
...but nothing downstream actually fails closed on it for the Redis/Cache persistence layer.
Concrete reproduction
- A function is decorated with
@idempotent_function(persistence_store=RedisCachePersistenceLayer(...)), without a config that has register_lambda_context() called (the default/common case for this decorator).
- Invocation A calls
save_inprogress → Redis SET key NX succeeds → begins e.g. a payment charge that takes several seconds.
- A retried/duplicate invocation B with the same idempotency key calls
save_inprogress while A is still running → SET key NX fails (key exists) → fetches A's record: status=INPROGRESS, in_progress_expiry_timestamp=None.
- The
COMPLETED check fails; the INPROGRESS-with-valid-expiry check fails because None is falsy (even though A is actively running) → falls through to IdempotencyPersistenceConsistencyError → treated as an orphan.
- B acquires the orphan-reclaim lock and does
client.set(..., ex=ttl) with no nx, unconditionally overwriting A's still-live INPROGRESS record.
save_inprogress returns normally for B → the underlying function executes a second time, concurrently with A. E.g., a customer is charged twice.
Possible Solution
When status == INPROGRESS and in_progress_expiry_timestamp is None, treat the record as still in progress (fail closed) rather than falling through to the orphan-reclaim path — mirroring the DynamoDB persistence layer's attribute_exists requirement. I have a fix + regression test ready and will open a PR referencing this issue.
Steps to Reproduce
See "Concrete reproduction" above — reproducible via unit test against RedisCachePersistenceLayer._put_in_progress_record() using a DataRecord with status=INPROGRESS and in_progress_expiry_timestamp=None.
Powertools for AWS Lambda (Python) version
latest (develop, v3.34.0)
AWS Lambda function runtime
3.12
Debugging logs
N/A — logic bug reproducible via unit test, not runtime-log-dependent.
Expected Behaviour
When two concurrent Lambda invocations use the same idempotency key with
RedisCachePersistenceLayer(orCachePersistenceLayer), and the first invocation is still genuinely running, the second invocation should be rejected withIdempotencyItemAlreadyExistsError— not proceed to execute the underlying function.Current Behaviour
If the in-progress record's
in_progress_expiry_timestampisNone, the second invocation incorrectly treats the first (still-running) invocation's record as an abandoned "orphan" and overwrites it, then proceeds to execute the function — even though the first invocation is still actively running.in_progress_expiry_timestampisNonewheneverremaining_time_in_milliswas not provided whensave_inprogresswas called (persistence/base.py), which happens wheneverIdempotencyConfig.register_lambda_context()was never called. Concretely, this is the normal, default state foridempotent_function: unlike theidempotent()handler decorator (which does callconfig.register_lambda_context(context)),idempotent_function'sdecorate()never callsregister_lambda_context()at all. So any typical use of@idempotent_function(...)— e.g. to make a helper function idempotent inside an SQS batch loop — hits this gap by default.Root cause
aws_lambda_powertools/utilities/idempotency/persistence/redis.py,_put_in_progress_record:The
INPROGRESSguard requiresin_progress_expiry_timestampto be truthy. When it'sNone(the commonidempotent_functioncase above), the guard is skipped entirely regardless of whether the record is genuinely, actively in progress — execution falls straight through toIdempotencyPersistenceConsistencyError, which is caught and leads to an unconditional overwrite with noNXguard:By contrast,
persistence/dynamodb.py's conditional-write expression requiresattribute_exists(#in_progress_expiry)before allowing an expired-in-progress reclaim — a missing attribute correctly blocks the competing writer there instead of granting a reclaim. The Redis layer'sNone-is-falsy check inverts this safety property: it fails open instead of closed.Notably,
persistence/base.py'ssave_inprogressalready logs a warning for exactly this situation:...but nothing downstream actually fails closed on it for the Redis/Cache persistence layer.
Concrete reproduction
@idempotent_function(persistence_store=RedisCachePersistenceLayer(...)), without a config that hasregister_lambda_context()called (the default/common case for this decorator).save_inprogress→ RedisSET key NXsucceeds → begins e.g. a payment charge that takes several seconds.save_inprogresswhile A is still running →SET key NXfails (key exists) → fetches A's record:status=INPROGRESS,in_progress_expiry_timestamp=None.COMPLETEDcheck fails; theINPROGRESS-with-valid-expiry check fails becauseNoneis falsy (even though A is actively running) → falls through toIdempotencyPersistenceConsistencyError→ treated as an orphan.client.set(..., ex=ttl)with nonx, unconditionally overwriting A's still-liveINPROGRESSrecord.save_inprogressreturns normally for B → the underlying function executes a second time, concurrently with A. E.g., a customer is charged twice.Possible Solution
When
status == INPROGRESSandin_progress_expiry_timestamp is None, treat the record as still in progress (fail closed) rather than falling through to the orphan-reclaim path — mirroring the DynamoDB persistence layer'sattribute_existsrequirement. I have a fix + regression test ready and will open a PR referencing this issue.Steps to Reproduce
See "Concrete reproduction" above — reproducible via unit test against
RedisCachePersistenceLayer._put_in_progress_record()using aDataRecordwithstatus=INPROGRESSandin_progress_expiry_timestamp=None.Powertools for AWS Lambda (Python) version
latest (
develop, v3.34.0)AWS Lambda function runtime
3.12
Debugging logs
N/A — logic bug reproducible via unit test, not runtime-log-dependent.