Skip to content

Commit 3f9d6b1

Browse files
committed
Add @GrpcAdvice and @GrpcExceptionHandler annotations
Introduce annotation-based exception handling for gRPC services, similar to Spring MVC's @ControllerAdvice and @ExceptionHandler. This allows simplified exception handling: @GrpcAdvice class MyExceptionHandler { @GrpcExceptionHandler fun handle(ex: TimeoutException): Status { return Status.DEADLINE_EXCEEDED.withDescription(ex.message) } } Signed-off-by: Oleksandr Shevchenko <oleksandr.shevchenko@datarobot.com> [resolves #350]
1 parent cad5b72 commit 3f9d6b1

File tree

9 files changed

+809
-0
lines changed

9 files changed

+809
-0
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2024-present 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.grpc.server.advice;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
import org.springframework.stereotype.Component;
26+
27+
/**
28+
* Marks a class for gRPC exception handling with
29+
* {@link GrpcExceptionHandler @GrpcExceptionHandler} methods.
30+
*
31+
* @author Oleksandr Shevchenko
32+
* @see GrpcExceptionHandler
33+
*/
34+
@Target({ ElementType.TYPE, ElementType.METHOD })
35+
@Retention(RetentionPolicy.RUNTIME)
36+
@Documented
37+
@Component
38+
public @interface GrpcAdvice {
39+
40+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright 2024-present 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.grpc.server.advice;
18+
19+
import java.lang.reflect.Method;
20+
import java.util.Collection;
21+
import java.util.Map;
22+
import java.util.Set;
23+
import java.util.stream.Collectors;
24+
25+
import org.apache.commons.logging.Log;
26+
import org.apache.commons.logging.LogFactory;
27+
28+
import org.springframework.beans.factory.InitializingBean;
29+
import org.springframework.context.ApplicationContext;
30+
import org.springframework.context.ApplicationContextAware;
31+
import org.springframework.core.MethodIntrospector;
32+
import org.springframework.core.annotation.AnnotatedElementUtils;
33+
import org.springframework.util.Assert;
34+
import org.springframework.util.ReflectionUtils.MethodFilter;
35+
36+
/**
37+
* Discovers {@link GrpcAdvice @GrpcAdvice} beans and
38+
* {@link GrpcExceptionHandler @GrpcExceptionHandler} methods.
39+
*
40+
* @author Oleksandr Shevchenko
41+
*/
42+
public class GrpcAdviceDiscoverer implements InitializingBean, ApplicationContextAware {
43+
44+
private static final Log logger = LogFactory.getLog(GrpcAdviceDiscoverer.class);
45+
46+
/**
47+
* A filter for selecting {@code @GrpcExceptionHandler} methods.
48+
*/
49+
public static final MethodFilter EXCEPTION_HANDLER_METHODS = method -> AnnotatedElementUtils.hasAnnotation(method,
50+
GrpcExceptionHandler.class);
51+
52+
private ApplicationContext applicationContext;
53+
54+
private Map<String, Object> annotatedBeans;
55+
56+
private Set<Method> annotatedMethods;
57+
58+
@Override
59+
public void setApplicationContext(ApplicationContext applicationContext) {
60+
this.applicationContext = applicationContext;
61+
}
62+
63+
@Override
64+
public void afterPropertiesSet() {
65+
this.annotatedBeans = this.applicationContext.getBeansWithAnnotation(GrpcAdvice.class);
66+
this.annotatedBeans.forEach((key, value) -> {
67+
if (logger.isDebugEnabled()) {
68+
logger.debug("Found gRPC advice: " + key + ", class: " + value.getClass().getName());
69+
}
70+
});
71+
this.annotatedMethods = findAnnotatedMethods();
72+
}
73+
74+
private Set<Method> findAnnotatedMethods() {
75+
return this.annotatedBeans.values()
76+
.stream()
77+
.map(Object::getClass)
78+
.map(this::findAnnotatedMethods)
79+
.flatMap(Collection::stream)
80+
.collect(Collectors.toSet());
81+
}
82+
83+
private Set<Method> findAnnotatedMethods(Class<?> clazz) {
84+
return MethodIntrospector.selectMethods(clazz, EXCEPTION_HANDLER_METHODS);
85+
}
86+
87+
/**
88+
* Return the discovered {@link GrpcAdvice @GrpcAdvice} beans.
89+
* @return the annotated beans
90+
*/
91+
public Map<String, Object> getAnnotatedBeans() {
92+
Assert.state(this.annotatedBeans != null, "@GrpcAdvice annotation scanning failed.");
93+
return this.annotatedBeans;
94+
}
95+
96+
/**
97+
* Return the discovered {@link GrpcExceptionHandler @GrpcExceptionHandler} methods.
98+
* @return the annotated methods
99+
*/
100+
public Set<Method> getAnnotatedMethods() {
101+
Assert.state(this.annotatedMethods != null, "@GrpcExceptionHandler annotation scanning failed.");
102+
return this.annotatedMethods;
103+
}
104+
105+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*
2+
* Copyright 2024-present 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.grpc.server.advice;
18+
19+
import java.lang.reflect.InvocationTargetException;
20+
import java.lang.reflect.Method;
21+
import java.lang.reflect.Parameter;
22+
import java.lang.reflect.Type;
23+
import java.util.Map.Entry;
24+
25+
import org.apache.commons.logging.Log;
26+
import org.apache.commons.logging.LogFactory;
27+
import org.jspecify.annotations.Nullable;
28+
29+
import org.springframework.util.Assert;
30+
31+
import io.grpc.Metadata;
32+
import io.grpc.Status;
33+
import io.grpc.StatusException;
34+
import io.grpc.StatusRuntimeException;
35+
36+
/**
37+
* Handles exceptions by delegating to {@link GrpcExceptionHandler @GrpcExceptionHandler}
38+
* methods in {@link GrpcAdvice @GrpcAdvice} beans.
39+
*
40+
* @author Oleksandr Shevchenko
41+
* @see GrpcAdvice
42+
* @see GrpcExceptionHandler
43+
*/
44+
public class GrpcAdviceExceptionHandler implements org.springframework.grpc.server.exception.GrpcExceptionHandler {
45+
46+
private static final Log logger = LogFactory.getLog(GrpcAdviceExceptionHandler.class);
47+
48+
private final GrpcExceptionHandlerMethodResolver grpcExceptionHandlerMethodResolver;
49+
50+
/**
51+
* Create a new instance.
52+
* @param grpcExceptionHandlerMethodResolver the method resolver to use
53+
*/
54+
public GrpcAdviceExceptionHandler(GrpcExceptionHandlerMethodResolver grpcExceptionHandlerMethodResolver) {
55+
Assert.notNull(grpcExceptionHandlerMethodResolver, "grpcExceptionHandlerMethodResolver must not be null");
56+
this.grpcExceptionHandlerMethodResolver = grpcExceptionHandlerMethodResolver;
57+
}
58+
59+
@Override
60+
public @Nullable StatusException handleException(Throwable exception) {
61+
try {
62+
Object mappedReturnType = handleThrownException(exception);
63+
if (mappedReturnType == null) {
64+
return null;
65+
}
66+
Status status = resolveStatus(mappedReturnType);
67+
Metadata metadata = resolveMetadata(mappedReturnType);
68+
return status.asException(metadata);
69+
}
70+
catch (Throwable errorWhileResolving) {
71+
if (errorWhileResolving != exception) {
72+
errorWhileResolving.addSuppressed(exception);
73+
}
74+
logger.error("Exception thrown during invocation of annotated @GrpcExceptionHandler method: ",
75+
errorWhileResolving);
76+
return Status.INTERNAL.withCause(errorWhileResolving)
77+
.withDescription("There was a server error trying to handle an exception")
78+
.asException();
79+
}
80+
}
81+
82+
/**
83+
* Resolve the gRPC status from the handler's return value.
84+
* @param mappedReturnType the handler return value
85+
* @return the resolved status
86+
*/
87+
protected Status resolveStatus(Object mappedReturnType) {
88+
if (mappedReturnType instanceof Status status) {
89+
return status;
90+
}
91+
else if (mappedReturnType instanceof Throwable throwable) {
92+
return Status.fromThrowable(throwable);
93+
}
94+
throw new IllegalStateException(
95+
String.format("Error for mapped return type [%s] inside @GrpcAdvice, it has to be of type: "
96+
+ "[Status, StatusException, StatusRuntimeException, Throwable]", mappedReturnType));
97+
}
98+
99+
/**
100+
* Resolve the metadata from the handler's return value.
101+
* @param mappedReturnType the handler return value
102+
* @return the resolved metadata, or empty metadata
103+
*/
104+
protected Metadata resolveMetadata(Object mappedReturnType) {
105+
Metadata result = null;
106+
if (mappedReturnType instanceof StatusException statusException) {
107+
result = statusException.getTrailers();
108+
}
109+
else if (mappedReturnType instanceof StatusRuntimeException statusRuntimeException) {
110+
result = statusRuntimeException.getTrailers();
111+
}
112+
return (result == null) ? new Metadata() : result;
113+
}
114+
115+
/**
116+
* Look up and invoke the handler method for the given exception.
117+
* @param exception the exception to handle
118+
* @return the handler result, or {@code null} if no handler is mapped
119+
* @throws Throwable if the handler throws an exception
120+
*/
121+
@Nullable
122+
protected Object handleThrownException(Throwable exception) throws Throwable {
123+
if (logger.isDebugEnabled()) {
124+
logger.debug("Exception caught during gRPC execution: " + exception);
125+
}
126+
127+
Class<? extends Throwable> exceptionClass = exception.getClass();
128+
boolean exceptionIsMapped = this.grpcExceptionHandlerMethodResolver.isMethodMappedForException(exceptionClass);
129+
if (!exceptionIsMapped) {
130+
return null;
131+
}
132+
133+
Entry<Object, Method> methodWithInstance = this.grpcExceptionHandlerMethodResolver
134+
.resolveMethodWithInstance(exceptionClass);
135+
Method mappedMethod = methodWithInstance.getValue();
136+
Object instanceOfMappedMethod = methodWithInstance.getKey();
137+
138+
if (mappedMethod == null || instanceOfMappedMethod == null) {
139+
return null;
140+
}
141+
142+
Object[] instancedParams = determineInstancedParameters(mappedMethod, exception);
143+
return invokeMappedMethodSafely(mappedMethod, instanceOfMappedMethod, instancedParams);
144+
}
145+
146+
private Object[] determineInstancedParameters(Method mappedMethod, Throwable exception) {
147+
Parameter[] parameters = mappedMethod.getParameters();
148+
Object[] instancedParams = new Object[parameters.length];
149+
150+
for (int i = 0; i < parameters.length; i++) {
151+
Class<?> parameterClass = convertToClass(parameters[i]);
152+
if (parameterClass.isAssignableFrom(exception.getClass())) {
153+
instancedParams[i] = exception;
154+
break;
155+
}
156+
}
157+
return instancedParams;
158+
}
159+
160+
private Class<?> convertToClass(Parameter parameter) {
161+
Type paramType = parameter.getParameterizedType();
162+
if (paramType instanceof Class<?> clazz) {
163+
return clazz;
164+
}
165+
throw new IllegalStateException("Parameter type of method has to be from Class, it was: " + paramType);
166+
}
167+
168+
private Object invokeMappedMethodSafely(Method mappedMethod, Object instanceOfMappedMethod,
169+
Object[] instancedParams) throws Throwable {
170+
try {
171+
return mappedMethod.invoke(instanceOfMappedMethod, instancedParams);
172+
}
173+
catch (InvocationTargetException ex) {
174+
throw ex.getCause();
175+
}
176+
catch (IllegalAccessException ex) {
177+
throw new IllegalStateException("Could not access @GrpcExceptionHandler method: " + mappedMethod, ex);
178+
}
179+
}
180+
181+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2024-present 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.grpc.server.advice;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
/**
26+
* Marks a method as a gRPC exception handler within a {@link GrpcAdvice @GrpcAdvice}
27+
* class. The method should return {@link io.grpc.Status},
28+
* {@link io.grpc.StatusException}, or {@link io.grpc.StatusRuntimeException}.
29+
*
30+
* @author Oleksandr Shevchenko
31+
* @see GrpcAdvice
32+
*/
33+
@Documented
34+
@Target(ElementType.METHOD)
35+
@Retention(RetentionPolicy.RUNTIME)
36+
public @interface GrpcExceptionHandler {
37+
38+
/**
39+
* Exception types to handle. If empty, inferred from method parameter types.
40+
* @return the exception types to handle
41+
*/
42+
Class<? extends Throwable>[] value() default {};
43+
44+
}

0 commit comments

Comments
 (0)