Skip to content

Commit 34f9130

Browse files
author
Raju Ansari
committed
fix: stop retrying after successful payment signing is rejected by server
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)
1 parent b64a0d9 commit 34f9130

3 files changed

Lines changed: 430 additions & 130 deletions

File tree

src/bedrock_agentcore/payments/integrations/strands/plugin.py

Lines changed: 33 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,6 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
163163
)
164164
return
165165

166-
# Check if payment retry limit has been reached
167-
if self._check_payment_retry_limit(event):
168-
logger.warning("Payment processing retry limit has been reached. Processing skipped.")
169-
return
170-
171166
# Check if response is a 402 Payment Required
172167
if not hasattr(event, "result") or event.result is None:
173168
return
@@ -192,9 +187,6 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
192187

193188
logger.info("Detected 402 Payment Required response from tool: %s", event.tool_use.get("name", "unknown"))
194189

195-
# Increment retry count in invocation state
196-
self._increment_payment_retry_count(event)
197-
198190
# Build payment_required_request dict using handler methods
199191
headers = handler.extract_headers(event.result)
200192
body = handler.extract_body(event.result)
@@ -204,18 +196,24 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
204196
"body": body or {},
205197
}
206198

207-
# If we already retried with payment credentials and still got a 402,
208-
# this is a post-payment failure (e.g., insufficient balance, invalid signature).
209-
# Propagate as an interrupt instead of retrying again to avoid infinite loops.
210-
if self._is_post_payment_failure(event, body):
199+
# If we previously signed successfully and still got a 402, the server
200+
# rejected the payment for a non-retryable reason (e.g., insufficient balance).
201+
# Stop processing — the existing 402 result is already structured for the LLM.
202+
if self._has_successful_signing(event):
211203
logger.warning(
212-
"Received 402 after payment retry for tool %s — treating as payment failure",
204+
"Received 402 after successful signing for tool %s — post-payment failure, not retrying",
213205
event.tool_use.get("name", "unknown"),
214206
)
215-
error_msg = self._extract_payment_error_message(body)
216-
self._store_payment_failure_state(event, PaymentError(f"Payment failed after retry: {error_msg}"))
217207
return
218208

209+
# Check if signing retry limit has been reached
210+
if self._check_payment_retry_limit(event):
211+
logger.warning("Payment signing retry limit reached. Processing skipped.")
212+
return
213+
214+
# Increment retry count for signing attempts
215+
self._increment_payment_retry_count(event)
216+
219217
# Validate tool input before processing payment
220218
tool_input = event.tool_use.get("input", {})
221219
if not handler.validate_tool_input(tool_input):
@@ -232,10 +230,11 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
232230
self._store_payment_failure_state(event, Exception("Failed to apply payment header"))
233231
return
234232

233+
# Mark that signing succeeded for this tool use — if we get another 402
234+
# after this retry, we know it's a server-side rejection, not a signing failure.
235+
self._mark_successful_signing(event)
236+
235237
# Set retry flag to re-execute the tool with payment credentials.
236-
# Do NOT reset the payment retry counter here — it must persist across
237-
# retries so that _is_post_payment_failure and _check_payment_retry_limit
238-
# can detect repeated 402s and break the loop.
239238
event.retry = True
240239
self._reset_interrupt_retry_count(event)
241240
logger.info("Set retry flag to re-execute tool with payment credentials")
@@ -295,47 +294,32 @@ def _increment_payment_retry_count(self, event: AfterToolCallEvent) -> None:
295294
"Payment retry attempt %d/%d for tool use %s", retry_count + 1, self.MAX_PAYMENT_RETRIES, tool_use_id
296295
)
297296

298-
def _is_post_payment_failure(self, event: AfterToolCallEvent, body: Optional[Dict[str, Any]]) -> bool:
299-
"""Check if this 402 response is a failure after we already retried with payment credentials.
300-
301-
A post-payment failure occurs when:
302-
1. We already sent a payment header (retry count > 0 before this increment), AND
303-
2. The 402 response body contains an error that is NOT the initial "payment required"
304-
(e.g., "invalid_exact_evm_insufficient_balance", "payment_rejected", etc.)
305-
306-
This prevents infinite loops where the plugin keeps signing and retrying
307-
against a server that keeps rejecting the payment for non-retryable reasons.
297+
def _has_successful_signing(self, event: AfterToolCallEvent) -> bool:
298+
"""Check if we previously signed a payment successfully for this tool use.
308299
309300
Args:
310301
event: The after tool call event
311-
body: The extracted response body (may be None)
312302
313303
Returns:
314-
True if this is a post-payment failure that should be propagated as an interrupt
304+
True if signing was previously successful (meaning this 402 is a server-side rejection)
315305
"""
316306
tool_use_id = event.tool_use.get("toolUseId", "unknown")
317-
payment_retry_key = f"payment_retry_count_{tool_use_id}"
318-
# retry count was already incremented before this check, so > 1 means
319-
# we already attempted at least one payment retry
320-
retry_count = event.invocation_state.get(payment_retry_key, 0)
307+
signed_key = f"payment_signed_{tool_use_id}"
308+
return event.invocation_state.get(signed_key, False)
321309

322-
if retry_count <= 1:
323-
return False
310+
def _mark_successful_signing(self, event: AfterToolCallEvent) -> None:
311+
"""Mark that signing succeeded for this tool use.
324312
325-
# If the body contains an error field that is NOT the initial "payment required",
326-
# this is a post-payment failure
327-
if body and isinstance(body, dict):
328-
error = body.get("error", "")
329-
if isinstance(error, str) and error.lower() not in ("", "payment required"):
330-
logger.info(
331-
"Post-payment failure detected for tool %s: error=%s (retry_count=%d)",
332-
tool_use_id,
333-
error,
334-
retry_count,
335-
)
336-
return True
313+
Called after generate_payment_header and apply_payment_header both succeed,
314+
right before setting event.retry. If a subsequent 402 is received,
315+
_has_successful_signing will return True indicating the failure is server-side.
337316
338-
return False
317+
Args:
318+
event: The after tool call event
319+
"""
320+
tool_use_id = event.tool_use.get("toolUseId", "unknown")
321+
signed_key = f"payment_signed_{tool_use_id}"
322+
event.invocation_state[signed_key] = True
339323

340324
@staticmethod
341325
def _extract_payment_error_message(body: Optional[Dict[str, Any]]) -> str:

0 commit comments

Comments
 (0)