fix: stop retrying after successful payment signing is rejected by merchant#492
Conversation
…rver
Previously, when a signed payment was rejected (e.g., insufficient balance),
the plugin would store failure state and trigger interrupt-based retries.
This was unnecessary since a server-side rejection after successful signing
is not recoverable by retrying.
Changes:
- Add payment_signed_{toolUseId} flag to track successful signing
- After successful signing + retry, if server returns 402 again, stop immediately
- Signing failures still retry up to MAX_PAYMENT_RETRIES
- Move retry count increment to after successful signing attempt
- Remove _is_post_payment_failure (replaced by _has_successful_signing)
- Preserve interrupt behavior: post-payment failure stores state and triggers interrupt so downstream consumers are notified - Remove dead code: _extract_payment_error_message (unreachable) - Remove TestExtractPaymentErrorMessage (tested removed method) - Clarify comment on retry counter increment - Add test: signing flag takes precedence over retry limit - Remove emojis from new integration test logger output
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #492 +/- ##
=======================================
Coverage ? 89.40%
=======================================
Files ? 83
Lines ? 7633
Branches ? 1143
=======================================
Hits ? 6824
Misses ? 514
Partials ? 295
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| self._increment_payment_retry_count(event) | ||
|
|
||
| # Validate tool input before processing payment | ||
| tool_input = event.tool_use.get("input", {}) |
There was a problem hiding this comment.
Nit: tool_input is already fetched at line 177 (tool_input = event.tool_use.get("input", {})) and is unchanged since. The fetch on line 221 is redundant.
| def _mark_successful_signing(self, event: AfterToolCallEvent) -> None: | ||
| """Mark that signing succeeded for this tool use. | ||
|
|
||
| return False | ||
|
|
||
| @staticmethod | ||
| def _extract_payment_error_message(body: Optional[Dict[str, Any]]) -> str: | ||
| """Extract a human-readable error message from a 402 response body. | ||
| Called after generate_payment_header and apply_payment_header both succeed, | ||
| right before setting event.retry. If a subsequent 402 is received, | ||
| _has_successful_signing will return True indicating the failure is server-side. | ||
|
|
||
| Args: | ||
| body: The extracted response body (may be None) | ||
|
|
||
| Returns: | ||
| Error message string, or "unknown error" if not extractable | ||
| event: The after tool call event | ||
| """ | ||
| if body and isinstance(body, dict): | ||
| error = body.get("error") | ||
| if isinstance(error, str) and error: | ||
| return error | ||
| return "unknown error" | ||
| tool_use_id = event.tool_use.get("toolUseId", "unknown") | ||
| signed_key = f"payment_signed_{tool_use_id}" | ||
| event.invocation_state[signed_key] = True |
There was a problem hiding this comment.
Nit: payment_signed_* flag is never cleared.
Once _mark_successful_signing sets the flag, it lives in invocation_state for the rest of the invocation. Same applies to payment_retry_count_* and payment_failure_* (pre-existing). For long-lived agent sessions with many paid tool calls, invocation_state accumulates keys without bound. Either clear the flag once the failure is delivered via before_tool_call's interrupt path, or add a comment confirming invocation_state is intentionally per-invocation and cleanup is not required.
| @@ -710,3 +710,300 @@ def test_plugin_with_agent_name_initializes_payment_manager(self): | |||
| assert agent is not None | |||
There was a problem hiding this comment.
Can you clean these up please?
Inline imports inside test methods violate project convention.
CLAUDE.md: "Always place imports at the top of files — never use inline imports within functions or methods."
Inline imports to hoist:
- Line 731:
from unittest.mock import MagicMock - Line 763:
from unittest.mock import MagicMock, patch - Line 828:
from unittest.mock import MagicMock, patch - Line 830:
from bedrock_agentcore.payments.manager import PaymentError - Line 894:
from unittest.mock import MagicMock, patch - Line 954:
from unittest.mock import MagicMock, patch
Move all of these to the module's top-level imports.
| @pytest.mark.integration | ||
| class TestPostPaymentFailureFlow: | ||
| """Hook-level integration tests for post-payment failure behavior. | ||
|
|
||
| Exercises the plugin's after_tool_call hook directly to verify the | ||
| retry logic end-to-end without requiring an LLM. | ||
| """ |
There was a problem hiding this comment.
Mislabeled "integration" tests.
These tests live in tests_integ/, are tagged @pytest.mark.integration, and the docstring says "integration tests" — but they fully mock PaymentManager and never hit AWS. CI runners that filter -m "not integration" will skip them, and developers may assume they need credentials to run them.
Either:
- Move the class to
tests/as unit tests, or - Update the class docstring (lines 717–721) to make explicit that these are mock-driven hook-flow scenarios that require no AWS credentials.
- plugin.py: drop redundant tool_input re-fetch (already bound at line 177) - plugin.py: document that invocation_state markers are intentionally per-invocation and do not need explicit cleanup - test_plugin_integration.py: hoist unittest.mock and PaymentError imports to module top, per project convention - test_plugin_integration.py: clarify TestPostPaymentFailureFlow docstring to flag it as mock-driven and credential-free
Previously, when a signed payment was rejected (e.g., insufficient balance), the plugin would store failure state and trigger interrupt-based retries. This was unnecessary since a server-side rejection after successful signing is not recoverable by retrying.
Changes:
Issue #, if available:
Description of changes:
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.