Skip to content

demo: idempotency tech-debt — falsy responses + Redis None handling#1

Open
sudsali wants to merge 2 commits into
developfrom
demo-broken-idempotency
Open

demo: idempotency tech-debt — falsy responses + Redis None handling#1
sudsali wants to merge 2 commits into
developfrom
demo-broken-idempotency

Conversation

@sudsali
Copy link
Copy Markdown
Owner

@sudsali sudsali commented May 31, 2026

Demo PR for the Shadow PR-review bot.

Why this PR exists

This is a reverted version of aws-powertools/powertools-lambda-python#8176 — a real merged fix from upstream Powertools. By opening it as a fresh PR against this fork's `develop`, the Shadow bot reviews the diff and (if the bot is working) flags the bugs the human reviewer also caught.

What the diff does

Re-introduces three known issues from the pre-aws-powertools#8176 codebase:

  1. idempotency/base.py:170 — replaces `STATUS_CONSTANTS["INPROGRESS"]` with the hardcoded literal `"INPROGRESS"`. Drift risk if the constants dict is renamed.
  2. idempotency/base.py:299 — changes `response is not None` to plain `response` (truthy check). Functions returning `0`, `False`, `""`, `[]` will be persisted as `None`, defeating idempotency replay.
  3. idempotency/persistence/redis.py:338-339 — wraps `item.get(...)` in `str(...)`, producing the literal string `"None"` when fields are missing instead of an empty string.

Expected Shadow output

The bot should post inline comments calling out at least bugs 2 and 3 (real correctness regressions). Bug 1 may surface as DESIGN/NIT depending on Critic verdict.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Generated by AI (model: us.anthropic.claude-opus-4-7, prompt: 11fb14dc) — may not be fully accurate. Reply if this doesn't help.
Review cost: $0.53 (Investigator $0.28 + Critic $0.23 + Reporter $0.01)

# way of retrieving the existing record after a failed conditional write operation.
record = exc.old_data_record or self._get_idempotency_record()
if is_replay and record is not None and record.status == STATUS_CONSTANTS["INPROGRESS"]:
if is_replay and record is not None and record.status == "INPROGRESS":
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DESIGN: Hardcoding the literal "INPROGRESS" here couples the replay-resume branch to an implementation detail of the constants table. All other call sites (base.py:253, 256; persistence/base.py:239, 280, 313; dynamodb.py:246; redis.py:421, 429, 481) uniformly use STATUS_CONSTANTS["INPROGRESS"]. This inconsistency creates drift risk: if the constants dict is refactored or renamed, this branch alone is not updated.

datarecord.py:14 defines STATUS_CONSTANTS = MappingProxyType({"INPROGRESS": "INPROGRESS", ...}); grep showed 14 other call sites use STATUS_CONSTANTS["..."] while only base.py:170 (post-diff) uses the bare literal.

Refutation trail (why this survived the Critic's disprove pass)

Hypothesis (Investigator): Replacing STATUS_CONSTANTS["INPROGRESS"] with the literal "INPROGRESS" introduces drift risk and inconsistency — if the constant value ever changes, this branch silently breaks; meanwhile every other call site (lines 253, 256, persistence/base.py:239, 280, 313, dynamodb.py:246, redis.py:421, 429, 481) uses the constant.

Disprove attempt (Critic): Read datarecord.py:14 — STATUS_CONSTANTS = MappingProxyType({"INPROGRESS": "INPROGRESS", ...}). Today the literal equals the constant value, so behavior is unchanged. grep_codebase confirmed all sibling sites use the constant indirection.

The Critic's default verdict is OVERTURNED. UPHELD findings are those it tried — and failed — to refute.

else:
try:
serialized_response: dict = self.output_serializer.to_dict(response) if response is not None else None
serialized_response: dict = self.output_serializer.to_dict(response) if response else None
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BUG: Falsy-but-valid responses (0, False, "", [], {}) are silently lost. The idempotent replay returns None instead of the actual return value, breaking any handler whose contract includes a falsy return (e.g., counters returning 0, predicates returning False, list endpoints returning []). Restore the explicit response is not None check.

base.py:299 (post-diff) ... if response else None; base.py:300 always calls save_success(result=serialized_response); datarecord.py:98 response_json_as_dict() returns None when response_data is empty/None; base.py:274 replay returns None when response_dict is falsy.

Refutation trail (why this survived the Critic's disprove pass)

Hypothesis (Investigator): Changing response is not None to response causes falsy-but-valid return values (0, False, "", [], {}) to be persisted as None, so on replay the user receives None instead of the original falsy result — a correctness regression in idempotent replay.

Disprove attempt (Critic): Read base.py:282-307. The else branch always calls save_success(result=serialized_response). With if response else None, falsy responses skip serialization and persist result=None. Read base.py:273-274 — replay path does response_dict = data_record.response_json_as_dict() then from_dict(response_dict) if response_dict else None, so a None-saved record returns None on replay regardless of the original falsy value.

The Critic's default verdict is OVERTURNED. UPHELD findings are those it tried — and failed — to refute.

@sudsali sudsali force-pushed the demo-broken-idempotency branch from e180869 to de11b9b Compare May 31, 2026 20:51
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Generated by AI (model: us.anthropic.claude-opus-4-7, prompt: 11fb14dc) — may not be fully accurate. Reply if this doesn't help.
Review cost: $0.40 (Investigator $0.22 + Critic $0.17 + Reporter $0.01)

in_progress_expiry_timestamp=in_progress_expiry_timestamp,
response_data=item.get(self.data_attr, ""),
payload_hash=item.get(self.validation_key_attr, ""),
response_data=str(item.get(self.data_attr)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BUG: When a Redis idempotency record is missing data_attr or validation_key_attr, str(item.get(...)) returns the literal string "None" rather than "". DataRecord.response_json_as_dict() will then call json.loads("None"), raising JSONDecodeError on replay; payload-hash validation will compare the literal string "None" to actual hashes. Restore the previous defaults: item.get(self.data_attr, "") and item.get(self.validation_key_attr, ""). (Regression of issue #8090.)

redis.py:338-339 (post-diff): response_data=str(item.get(self.data_attr)), payload_hash=str(item.get(self.validation_key_attr)). datarecord.py:98: return json.loads(self.response_data) if self.response_data else None"None" is truthy, so json.loads("None") is invoked and fails. The PR also deletes test_item_to_datarecord_conversion_missing_optional_attributes which was the dedicated regression test.

Refutation trail (why this survived the Critic's disprove pass)

Hypothesis (Investigator): Wrapping item.get(self.data_attr) and item.get(self.validation_key_attr) in str(...) produces the literal string "None" when the attribute is missing, breaking DataRecord.response_json_as_dict() (which then attempts json.loads("None")JSONDecodeError) and corrupting payload-hash comparisons.

Disprove attempt (Critic): Read redis.py lines 331-341 — item.get(...) with no default returns None when key absent; str(None) == "None". Read datarecord.py:89-98 — response_json_as_dict returns json.loads(self.response_data) if self.response_data else None. The string "None" is truthy, so it falls into json.loads("None") which raises json.JSONDecodeError (JSON spec uses null, not None). Likewise payload_hash="None" is now a non-empty string that will be used in equality checks. The previous code used item.get(..., "") which gave the empty string and shortcut both code paths correctly. The removed regression test (test_item_to_datarecord_conversion_missing_optional_attributes) was specifically a guard for issue #8090 that this exact problem.

The Critic's default verdict is OVERTURNED. UPHELD findings are those it tried — and failed — to refute.

@sudsali sudsali force-pushed the demo-broken-idempotency branch from de11b9b to b7507b1 Compare June 1, 2026 01:58
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found.


Generated by AI (model: us.anthropic.claude-opus-4-7, prompt: dfd36ba9) — may not be fully accurate. Reply if this doesn't help.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant