Skip to content

Commit beb1d05

Browse files
Kehrlanntzolov
authored andcommitted
feat: add selective exception rethrowing to DefaultToolExecutionExceptionProcessor (#3595)
This allows certain exceptions (like OAuth2 authorization errors) to bubble up properly instead of being wrapped, enabling correct authentication flows. - Add support for configuring allowlist of exceptions to be rethrown directly - Enhance DefaultToolExecutionExceptionProcessor with builder pattern - Add automatic detection and handling of OAuth2 ClientAuthorizationException - Update ToolCallingAutoConfiguration to configure OAuth2 exception rethrowing - Add unit tests for new exception handling behavior Signed-off-by: Daniel Garnier-Moiroux <[email protected]>
1 parent 8107612 commit beb1d05

File tree

3 files changed

+165
-5
lines changed

3 files changed

+165
-5
lines changed

auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,9 @@
1616

1717
package org.springframework.ai.model.tool.autoconfigure;
1818

19+
import io.micrometer.observation.ObservationRegistry;
1920
import java.util.ArrayList;
2021
import java.util.List;
21-
22-
import io.micrometer.observation.ObservationRegistry;
2322
import org.slf4j.Logger;
2423
import org.slf4j.LoggerFactory;
2524

@@ -43,12 +42,14 @@
4342
import org.springframework.boot.context.properties.EnableConfigurationProperties;
4443
import org.springframework.context.annotation.Bean;
4544
import org.springframework.context.support.GenericApplicationContext;
45+
import org.springframework.util.ClassUtils;
4646

4747
/**
4848
* Auto-configuration for common tool calling features of {@link ChatModel}.
4949
*
5050
* @author Thomas Vitale
5151
* @author Christian Tzolov
52+
* @author Daniel Garnier-Moiroux
5253
* @since 1.0.0
5354
*/
5455
@AutoConfiguration
@@ -78,7 +79,21 @@ ToolCallbackResolver toolCallbackResolver(GenericApplicationContext applicationC
7879
@Bean
7980
@ConditionalOnMissingBean
8081
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor(ToolCallingProperties properties) {
81-
return new DefaultToolExecutionExceptionProcessor(properties.isThrowExceptionOnError());
82+
ArrayList<Class<? extends RuntimeException>> rethrownExceptions = new ArrayList<>();
83+
84+
// ClientAuthorizationException is used by Spring Security in oauth2 flows,
85+
// for example with ServletOAuth2AuthorizedClientExchangeFilterFunction and
86+
// OAuth2ClientHttpRequestInterceptor.
87+
Class<? extends RuntimeException> oauth2Exception = getClassOrNull(
88+
"org.springframework.security.oauth2.client.ClientAuthorizationException");
89+
if (oauth2Exception != null) {
90+
rethrownExceptions.add(oauth2Exception);
91+
}
92+
93+
return DefaultToolExecutionExceptionProcessor.builder()
94+
.alwaysThrow(properties.isThrowExceptionOnError())
95+
.rethrowExceptions(rethrownExceptions)
96+
.build();
8297
}
8398

8499
@Bean
@@ -108,4 +123,14 @@ ToolCallingContentObservationFilter toolCallingContentObservationFilter() {
108123
return new ToolCallingContentObservationFilter();
109124
}
110125

126+
private static Class<? extends RuntimeException> getClassOrNull(String className) {
127+
try {
128+
return (Class<? extends RuntimeException>) ClassUtils.forName(className, null);
129+
}
130+
catch (ClassNotFoundException e) {
131+
logger.debug("Cannot load class", e);
132+
}
133+
return null;
134+
}
135+
111136
}

spring-ai-model/src/main/java/org/springframework/ai/tool/execution/DefaultToolExecutionExceptionProcessor.java

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,20 @@
1616

1717
package org.springframework.ai.tool.execution;
1818

19+
import java.util.Collections;
20+
import java.util.List;
1921
import org.slf4j.Logger;
2022
import org.slf4j.LoggerFactory;
2123

2224
import org.springframework.util.Assert;
2325

2426
/**
25-
* Default implementation of {@link ToolExecutionExceptionProcessor}.
27+
* Default implementation of {@link ToolExecutionExceptionProcessor}. Can be configured
28+
* with an allowlist of exceptions that will be unwrapped from the
29+
* {@link ToolExecutionException} and rethrown as is.
2630
*
2731
* @author Thomas Vitale
32+
* @author Daniel Garnier-Moiroux
2833
* @since 1.0.0
2934
*/
3035
public class DefaultToolExecutionExceptionProcessor implements ToolExecutionExceptionProcessor {
@@ -35,13 +40,26 @@ public class DefaultToolExecutionExceptionProcessor implements ToolExecutionExce
3540

3641
private final boolean alwaysThrow;
3742

43+
private final List<Class<? extends RuntimeException>> rethrownExceptions;
44+
3845
public DefaultToolExecutionExceptionProcessor(boolean alwaysThrow) {
46+
this(alwaysThrow, Collections.emptyList());
47+
}
48+
49+
public DefaultToolExecutionExceptionProcessor(boolean alwaysThrow,
50+
List<Class<? extends RuntimeException>> rethrownExceptions) {
3951
this.alwaysThrow = alwaysThrow;
52+
this.rethrownExceptions = Collections.unmodifiableList(rethrownExceptions);
4053
}
4154

4255
@Override
4356
public String process(ToolExecutionException exception) {
4457
Assert.notNull(exception, "exception cannot be null");
58+
Throwable cause = exception.getCause();
59+
if (cause instanceof RuntimeException runtimeException
60+
&& this.rethrownExceptions.stream().anyMatch(rethrown -> rethrown.isAssignableFrom(cause.getClass()))) {
61+
throw runtimeException;
62+
}
4563
if (this.alwaysThrow) {
4664
throw exception;
4765
}
@@ -58,13 +76,31 @@ public static class Builder {
5876

5977
private boolean alwaysThrow = DEFAULT_ALWAYS_THROW;
6078

79+
private List<Class<? extends RuntimeException>> exceptions = Collections.emptyList();
80+
81+
/**
82+
* Rethrow the {@link ToolExecutionException}
83+
* @param alwaysThrow when true, throws; when false, returns the exception message
84+
* @return the builder instance
85+
*/
6186
public Builder alwaysThrow(boolean alwaysThrow) {
6287
this.alwaysThrow = alwaysThrow;
6388
return this;
6489
}
6590

91+
/**
92+
* An allowlist of exceptions thrown by tools, which will be unwrapped and
93+
* re-thrown without further processing.
94+
* @param exceptions the list of exceptions
95+
* @return the builder instance
96+
*/
97+
public Builder rethrowExceptions(List<Class<? extends RuntimeException>> exceptions) {
98+
this.exceptions = exceptions;
99+
return this;
100+
}
101+
66102
public DefaultToolExecutionExceptionProcessor build() {
67-
return new DefaultToolExecutionExceptionProcessor(this.alwaysThrow);
103+
return new DefaultToolExecutionExceptionProcessor(this.alwaysThrow, exceptions);
68104
}
69105

70106
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright 2023-2025 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+
17+
package org.springframework.ai.tool.execution;
18+
19+
import java.util.List;
20+
import org.junit.jupiter.api.Test;
21+
22+
import org.springframework.ai.tool.definition.DefaultToolDefinition;
23+
import static org.assertj.core.api.Assertions.assertThat;
24+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
25+
import static org.assertj.core.api.InstanceOfAssertFactories.type;
26+
27+
/**
28+
* Unit tests for {@link DefaultToolExecutionExceptionProcessor}.
29+
*
30+
* @author Daniel Garnier-Moiroux
31+
*/
32+
class DefaultToolExecutionExceptionProcessorTests {
33+
34+
private final IllegalStateException toolException = new IllegalStateException("Inner exception");
35+
36+
private final DefaultToolDefinition toolDefinition = new DefaultToolDefinition("toolName", "toolDescription",
37+
"inputSchema");
38+
39+
private final ToolExecutionException toolExecutionException = new ToolExecutionException(toolDefinition,
40+
toolException);
41+
42+
@Test
43+
void processReturnsMessage() {
44+
DefaultToolExecutionExceptionProcessor processor = DefaultToolExecutionExceptionProcessor.builder().build();
45+
46+
String result = processor.process(this.toolExecutionException);
47+
48+
assertThat(result).isEqualTo(this.toolException.getMessage());
49+
}
50+
51+
@Test
52+
void processAlwaysThrows() {
53+
DefaultToolExecutionExceptionProcessor processor = DefaultToolExecutionExceptionProcessor.builder()
54+
.alwaysThrow(true)
55+
.build();
56+
57+
assertThatThrownBy(() -> processor.process(this.toolExecutionException))
58+
.hasMessage(this.toolException.getMessage())
59+
.hasCauseInstanceOf(this.toolException.getClass())
60+
.asInstanceOf(type(ToolExecutionException.class))
61+
.extracting(ToolExecutionException::getToolDefinition)
62+
.isEqualTo(this.toolDefinition);
63+
}
64+
65+
@Test
66+
void processRethrows() {
67+
DefaultToolExecutionExceptionProcessor processor = DefaultToolExecutionExceptionProcessor.builder()
68+
.alwaysThrow(false)
69+
.rethrowExceptions(List.of(IllegalStateException.class))
70+
.build();
71+
72+
assertThatThrownBy(() -> processor.process(this.toolExecutionException)).isEqualTo(this.toolException);
73+
}
74+
75+
@Test
76+
void processRethrowsExceptionSubclasses() {
77+
DefaultToolExecutionExceptionProcessor processor = DefaultToolExecutionExceptionProcessor.builder()
78+
.alwaysThrow(false)
79+
.rethrowExceptions(List.of(RuntimeException.class))
80+
.build();
81+
82+
assertThatThrownBy(() -> processor.process(this.toolExecutionException)).isEqualTo(this.toolException);
83+
}
84+
85+
@Test
86+
void processRethrowsOnlySelectExceptions() {
87+
DefaultToolExecutionExceptionProcessor processor = DefaultToolExecutionExceptionProcessor.builder()
88+
.alwaysThrow(false)
89+
.rethrowExceptions(List.of(IllegalStateException.class))
90+
.build();
91+
92+
ToolExecutionException exception = new ToolExecutionException(this.toolDefinition,
93+
new RuntimeException("This exception was not rethrown"));
94+
String result = processor.process(exception);
95+
96+
assertThat(result).isEqualTo("This exception was not rethrown");
97+
}
98+
99+
}

0 commit comments

Comments
 (0)