Skip to content

Commit 102336f

Browse files
authored
fix(agent): Improving the retry semantics of ToolRetryInterceptor (#4794)
1 parent 5945549 commit 102336f

2 files changed

Lines changed: 184 additions & 22 deletions

File tree

spring-ai-alibaba-agent-framework/src/main/java/com/alibaba/cloud/ai/graph/agent/interceptor/toolretry/ToolRetryInterceptor.java

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public class ToolRetryInterceptor extends ToolInterceptor {
4545

4646
private static final Logger log = LoggerFactory.getLogger(ToolRetryInterceptor.class);
4747

48-
private final int maxRetries;
48+
private final int maxAttempts;
4949
private final Set<String> toolNames;
5050
private final Predicate<Exception> retryOn;
5151
private final OnFailureBehavior onFailure;
@@ -56,7 +56,7 @@ public class ToolRetryInterceptor extends ToolInterceptor {
5656
private final boolean jitter;
5757

5858
private ToolRetryInterceptor(Builder builder) {
59-
this.maxRetries = builder.maxRetries;
59+
this.maxAttempts = builder.maxAttempts;
6060
this.toolNames = builder.toolNames != null ? new HashSet<>(builder.toolNames) : null;
6161
this.retryOn = builder.retryOn;
6262
this.onFailure = builder.onFailure;
@@ -81,18 +81,17 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl
8181
}
8282

8383
Exception lastException = null;
84-
int attempt = 0;
8584

86-
while (attempt < maxRetries) {
85+
// maxAttempts counts the initial call plus retries and is always >= 1, so the tool is executed at least once and is never skipped.
86+
for (int attempt = 0; attempt < maxAttempts; attempt++) {
8787
try {
88-
ToolCallResponse response = handler.call(request);
89-
if (ToolCallResponse.SUCCESS_STATUS.equals(response.getStatus())) {
90-
return response;
91-
}
92-
else {
93-
// fail, continue run in while method
94-
throw new RuntimeException(response.getResult().toString());
95-
}
88+
ToolCallResponse response = handler.call(request);
89+
if (ToolCallResponse.SUCCESS_STATUS.equals(response.getStatus())) {
90+
return response;
91+
}
92+
// A non-success status is treated as a failure so it can be retried.
93+
String result = response.getResult() != null ? response.getResult() : "unknown error";
94+
throw new RuntimeException(result);
9695
}
9796
catch (Exception e) {
9897
lastException = e;
@@ -103,15 +102,15 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl
103102
throw e;
104103
}
105104

106-
if (attempt == maxRetries) {
107-
// Max retries reached
105+
// Last attempt failed: Stop the operation
106+
if (attempt >= maxAttempts - 1) {
108107
break;
109108
}
110109

111110
// Calculate delay
112111
long delay = calculateDelay(attempt);
113112
log.warn("Tool '{}' failed (attempt {}/{}), retrying in {}ms: {}",
114-
toolName, attempt + 1, maxRetries + 1, delay, e.getMessage());
113+
toolName, attempt + 1, maxAttempts, delay, e.getMessage());
115114

116115
try {
117116
Thread.sleep(delay);
@@ -120,22 +119,20 @@ public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandl
120119
Thread.currentThread().interrupt();
121120
throw new RuntimeException("Retry interrupted", ie);
122121
}
123-
124-
attempt++;
125122
}
126123
}
127124

128125
// All retries exhausted
129126
if (onFailure == OnFailureBehavior.RAISE) {
130-
throw new RuntimeException("Tool call failed after " + (maxRetries + 1) + " attempts", lastException);
127+
throw new RuntimeException("Tool call failed after " + maxAttempts + " attempts", lastException);
131128
}
132129
else {
133130
// Return error message as tool response
134131
String errorMessage = errorFormatter != null
135132
? errorFormatter.apply(lastException)
136-
: "Tool call failed after " + (maxRetries + 1) + " attempts: " + lastException.getMessage();
133+
: "Tool call failed after " + maxAttempts + " attempts: " + lastException.getMessage();
137134

138-
log.error("Tool '{}' failed after {} attempts: {}", toolName, maxRetries + 1, lastException.getMessage());
135+
log.error("Tool '{}' failed after {} attempts: {}", toolName, maxAttempts, lastException.getMessage());
139136
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), errorMessage);
140137
}
141138
}
@@ -164,7 +161,8 @@ public enum OnFailureBehavior {
164161
}
165162

166163
public static class Builder {
167-
private int maxRetries = 2;
164+
// Total number of attempts including the first call.
165+
private int maxAttempts = 3;
168166
private Set<String> toolNames;
169167
private Predicate<Exception> retryOn = e -> true; // Retry on all exceptions by default
170168
private OnFailureBehavior onFailure = OnFailureBehavior.RETURN_MESSAGE;
@@ -174,11 +172,29 @@ public static class Builder {
174172
private long maxDelayMs = 60000;
175173
private boolean jitter = true;
176174

175+
/**
176+
* Set the maximum number of attempts, including the first call. Must be >= 1.
177+
* @param maxAttempts total attempts (initial call plus retries)
178+
*/
179+
public Builder maxAttempts(int maxAttempts) {
180+
if (maxAttempts < 1) {
181+
throw new IllegalArgumentException("maxAttempts must be >= 1");
182+
}
183+
this.maxAttempts = maxAttempts;
184+
return this;
185+
}
186+
187+
/**
188+
* Set the maximum number of retries (excluding the first call).
189+
* @param maxRetries number of retries; total attempts will be {@code maxRetries + 1}
190+
* @deprecated use {@link #maxAttempts(int)} instead, which counts the initial call
191+
*/
192+
@Deprecated
177193
public Builder maxRetries(int maxRetries) {
178194
if (maxRetries < 0) {
179195
throw new IllegalArgumentException("maxRetries must be >= 0");
180196
}
181-
this.maxRetries = maxRetries;
197+
this.maxAttempts = maxRetries + 1;
182198
return this;
183199
}
184200

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.alibaba.cloud.ai.graph.agent.interceptors;
17+
18+
import com.alibaba.cloud.ai.graph.agent.interceptor.ToolCallRequest;
19+
import com.alibaba.cloud.ai.graph.agent.interceptor.ToolCallResponse;
20+
import com.alibaba.cloud.ai.graph.agent.interceptor.toolretry.ToolRetryInterceptor;
21+
22+
import java.util.HashMap;
23+
import java.util.concurrent.atomic.AtomicInteger;
24+
25+
import org.junit.jupiter.api.Test;
26+
27+
import static org.junit.jupiter.api.Assertions.assertEquals;
28+
import static org.junit.jupiter.api.Assertions.assertThrows;
29+
import static org.junit.jupiter.api.Assertions.assertTrue;
30+
31+
/**
32+
* Pure unit tests for {@link ToolRetryInterceptor} that exercise the retry loop directly
33+
* with a mock {@link com.alibaba.cloud.ai.graph.agent.interceptor.ToolCallHandler}, so no
34+
* model / API key is required. Guards against the off-by-one regression where the loop
35+
* performed one attempt too few, skipped the tool entirely for a single-attempt config
36+
* (leading to an NPE), and slept an unused backoff after the final failure. Also verifies
37+
* the {@code maxAttempts} API and the deprecated {@code maxRetries} alias stay in sync.
38+
*/
39+
class ToolRetryInterceptorUnitTest {
40+
41+
private ToolCallRequest request() {
42+
return new ToolCallRequest("t", "{}", "id-1", new HashMap<>());
43+
}
44+
45+
private ToolRetryInterceptor.Builder builder(int maxAttempts) {
46+
return ToolRetryInterceptor.builder().maxAttempts(maxAttempts).initialDelay(1).jitter(false);
47+
}
48+
49+
@Test
50+
void runsAllConfiguredAttempts() {
51+
AtomicInteger calls = new AtomicInteger();
52+
ToolCallResponse response = builder(3).build().interceptToolCall(request(), r -> {
53+
calls.incrementAndGet();
54+
throw new RuntimeException("boom");
55+
});
56+
assertEquals(3, calls.get());
57+
assertTrue(response.getResult().contains("3 attempts"));
58+
}
59+
60+
@Test
61+
void twoAttemptsMeansOneRetry() {
62+
AtomicInteger calls = new AtomicInteger();
63+
builder(2).build().interceptToolCall(request(), r -> {
64+
calls.incrementAndGet();
65+
throw new RuntimeException("boom");
66+
});
67+
assertEquals(2, calls.get());
68+
}
69+
70+
@Test
71+
void singleAttemptRunsToolExactlyOnceWithoutNpe() {
72+
AtomicInteger calls = new AtomicInteger();
73+
ToolCallResponse response = builder(1).build().interceptToolCall(request(), r -> {
74+
calls.incrementAndGet();
75+
throw new RuntimeException("boom");
76+
});
77+
// The tool must still execute once, and no NullPointerException must be thrown.
78+
assertEquals(1, calls.get());
79+
assertTrue(response.getResult().contains("1 attempts"));
80+
}
81+
82+
@Test
83+
void stopsRetryingOnceSuccessful() {
84+
AtomicInteger calls = new AtomicInteger();
85+
ToolCallResponse response = builder(6).build().interceptToolCall(request(), r -> {
86+
if (calls.incrementAndGet() < 3) {
87+
throw new RuntimeException("transient");
88+
}
89+
return ToolCallResponse.success(r.getToolCallId(), r.getToolName(), "ok");
90+
});
91+
assertEquals(3, calls.get());
92+
assertEquals(ToolCallResponse.SUCCESS_STATUS, response.getStatus());
93+
assertEquals("ok", response.getResult());
94+
}
95+
96+
@Test
97+
void raiseBehaviorThrowsAfterAllAttempts() {
98+
AtomicInteger calls = new AtomicInteger();
99+
ToolRetryInterceptor interceptor = builder(3)
100+
.onFailure(ToolRetryInterceptor.OnFailureBehavior.RAISE)
101+
.build();
102+
RuntimeException ex = assertThrows(RuntimeException.class,
103+
() -> interceptor.interceptToolCall(request(), r -> {
104+
calls.incrementAndGet();
105+
throw new RuntimeException("boom");
106+
}));
107+
assertEquals(3, calls.get());
108+
assertTrue(ex.getMessage().contains("3 attempts"));
109+
}
110+
111+
@Test
112+
void nonRetryableExceptionIsRethrownImmediately() {
113+
AtomicInteger calls = new AtomicInteger();
114+
ToolRetryInterceptor interceptor = builder(4)
115+
.retryOn(IllegalStateException.class) // only retry ISE
116+
.build();
117+
assertThrows(IllegalArgumentException.class,
118+
() -> interceptor.interceptToolCall(request(), r -> {
119+
calls.incrementAndGet();
120+
throw new IllegalArgumentException("not retryable");
121+
}));
122+
assertEquals(1, calls.get());
123+
}
124+
125+
@Test
126+
@SuppressWarnings("deprecation")
127+
void deprecatedMaxRetriesAliasMapsToAttempts() {
128+
// maxRetries(n) == maxAttempts(n + 1)
129+
AtomicInteger calls = new AtomicInteger();
130+
ToolRetryInterceptor.builder().maxRetries(2).initialDelay(1).jitter(false).build()
131+
.interceptToolCall(request(), r -> {
132+
calls.incrementAndGet();
133+
throw new RuntimeException("boom");
134+
});
135+
assertEquals(3, calls.get());
136+
137+
// The previously buggy edge case: maxRetries(0) must still run the tool once, not zero times.
138+
AtomicInteger zeroCalls = new AtomicInteger();
139+
ToolRetryInterceptor.builder().maxRetries(0).initialDelay(1).jitter(false).build()
140+
.interceptToolCall(request(), r -> {
141+
zeroCalls.incrementAndGet();
142+
throw new RuntimeException("boom");
143+
});
144+
assertEquals(1, zeroCalls.get());
145+
}
146+
}

0 commit comments

Comments
 (0)