@@ -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,20 +196,28 @@ 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+ # Do not retry — store failure state so the agent is notified via interrupt.
202+ if self ._has_successful_signing (event ):
203+ error_msg = body .get ("error" , "unknown error" ) if body and isinstance (body , dict ) else "unknown error"
211204 logger .warning (
212- "Received 402 after payment retry for tool %s — treating as payment failure" ,
205+ "Received 402 after successful signing for tool %s — post- payment failure: %s " ,
213206 event .tool_use .get ("name" , "unknown" ),
207+ error_msg ,
214208 )
215- error_msg = self ._extract_payment_error_message (body )
216- self ._store_payment_failure_state (event , PaymentError (f"Payment failed after retry: { error_msg } " ))
209+ self ._store_payment_failure_state (event , PaymentError (f"Payment rejected after signing: { error_msg } " ))
210+ return
211+
212+ # Check if signing retry limit has been reached
213+ if self ._check_payment_retry_limit (event ):
214+ logger .warning ("Payment signing retry limit reached. Processing skipped." )
217215 return
218216
217+ # Increment before attempt so limit is enforced even on exception
218+ self ._increment_payment_retry_count (event )
219+
219220 # Validate tool input before processing payment
220- tool_input = event .tool_use .get ("input" , {})
221221 if not handler .validate_tool_input (tool_input ):
222222 logger .error ("Tool input validation failed, cannot apply payment header" )
223223 self ._store_payment_failure_state (event , Exception ("Tool input validation failed" ))
@@ -232,10 +232,11 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
232232 self ._store_payment_failure_state (event , Exception ("Failed to apply payment header" ))
233233 return
234234
235+ # Mark that signing succeeded for this tool use — if we get another 402
236+ # after this retry, we know it's a server-side rejection, not a signing failure.
237+ self ._mark_successful_signing (event )
238+
235239 # 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.
239240 event .retry = True
240241 self ._reset_interrupt_retry_count (event )
241242 logger .info ("Set retry flag to re-execute tool with payment credentials" )
@@ -295,63 +296,37 @@ def _increment_payment_retry_count(self, event: AfterToolCallEvent) -> None:
295296 "Payment retry attempt %d/%d for tool use %s" , retry_count + 1 , self .MAX_PAYMENT_RETRIES , tool_use_id
296297 )
297298
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.
299+ def _has_successful_signing (self , event : AfterToolCallEvent ) -> bool :
300+ """Check if we previously signed a payment successfully for this tool use.
308301
309302 Args:
310303 event: The after tool call event
311- body: The extracted response body (may be None)
312304
313305 Returns:
314- True if this is a post-payment failure that should be propagated as an interrupt
306+ True if signing was previously successful (meaning this 402 is a server-side rejection)
315307 """
316308 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 )
321-
322- if retry_count <= 1 :
323- return False
309+ signed_key = f"payment_signed_{ tool_use_id } "
310+ return event .invocation_state .get (signed_key , False )
324311
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
312+ def _mark_successful_signing (self , event : AfterToolCallEvent ) -> None :
313+ """Mark that signing succeeded for this tool use.
337314
338- return False
315+ Called after generate_payment_header and apply_payment_header both succeed,
316+ right before setting event.retry. If a subsequent 402 is received,
317+ _has_successful_signing will return True indicating the failure is server-side.
339318
340- @staticmethod
341- def _extract_payment_error_message (body : Optional [Dict [str , Any ]]) -> str :
342- """Extract a human-readable error message from a 402 response body.
319+ Note: payment_signed_*, payment_retry_count_*, and payment_failure_* keys are
320+ intentionally not cleared. invocation_state is scoped to a single agent
321+ invocation and is discarded by Strands when the invocation ends, so these
322+ per-tool-use markers do not accumulate across invocations.
343323
344324 Args:
345- body: The extracted response body (may be None)
346-
347- Returns:
348- Error message string, or "unknown error" if not extractable
325+ event: The after tool call event
349326 """
350- if body and isinstance (body , dict ):
351- error = body .get ("error" )
352- if isinstance (error , str ) and error :
353- return error
354- return "unknown error"
327+ tool_use_id = event .tool_use .get ("toolUseId" , "unknown" )
328+ signed_key = f"payment_signed_{ tool_use_id } "
329+ event .invocation_state [signed_key ] = True
355330
356331 def _store_payment_failure_state (self , event : AfterToolCallEvent , exception : Exception ) -> None :
357332 """Store payment failure information in invocation state for agent to handle.
0 commit comments