Skip to content

Commit 8cc7024

Browse files
rpanackalbot-sdk-jsCharlesDuboisSAPnewtork
authored
feat: [Orchestration] Filtering details and additional convenience on exception (#497)
* Input Filtering resolved * Input Filtering and Output filtering resolved * Refactor - from enum to individual types of filtering exception * Refactor - from enum to individual types of filtering exception * Exception factory introduced * Finish merge with main * Update tests and add better javadocs * Updating error messages - remove crafty object mapping in response handler * Integrating charles suggestions and extend unit tests * Integrating charles suggestions and extend unit tests * Filter exc classes non static and setter for client error * Filter exc classes better static * Formatting * Suppress unchecked warning * Formatting * cleaning up * fix method reference * Shortcut getErrorResponse mask clientError add better testing add filter specific getter in exception * merging change with careful logging * Release notes * Update core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java Co-authored-by: Charles Dubois <[email protected]> * Update orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java Co-authored-by: Charles Dubois <[email protected]> * Sample app tests use filter specific convenience pn exception * Review suggestion * Formatting * Handle class cast exceptions * Format * Format --------- Co-authored-by: Roshin Rajan Panackal <[email protected]> Co-authored-by: SAP Cloud SDK Bot <[email protected]> Co-authored-by: Charles Dubois <[email protected]> Co-authored-by: Alexander Dümont <[email protected]> Co-authored-by: Alexander Dümont <[email protected]>
1 parent 741c43a commit 8cc7024

File tree

28 files changed

+949
-178
lines changed

28 files changed

+949
-178
lines changed

core/src/main/java/com/sap/ai/sdk/core/common/ClientException.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package com.sap.ai.sdk.core.common;
22

33
import com.google.common.annotations.Beta;
4+
import javax.annotation.Nullable;
5+
import lombok.AccessLevel;
6+
import lombok.Getter;
7+
import lombok.Setter;
48
import lombok.experimental.StandardException;
59

610
/**
@@ -10,4 +14,14 @@
1014
*/
1115
@Beta
1216
@StandardException
13-
public class ClientException extends RuntimeException {}
17+
public class ClientException extends RuntimeException {
18+
19+
/**
20+
* Wraps a structured error payload received from the remote service, if available. This can be
21+
* used to extract more detailed error information.
22+
*/
23+
@Nullable
24+
@Getter(onMethod_ = @Beta, value = AccessLevel.PROTECTED)
25+
@Setter(onMethod_ = @Beta, value = AccessLevel.PROTECTED)
26+
ClientError clientError;
27+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.sap.ai.sdk.core.common;
2+
3+
import com.google.common.annotations.Beta;
4+
import javax.annotation.Nonnull;
5+
import javax.annotation.Nullable;
6+
7+
/**
8+
* A factory whose implementations can provide customized exception types and error mapping logic
9+
* for different service clients or error scenarios.
10+
*
11+
* @param <E> The subtype of {@link ClientException} to be created by this factory.
12+
* @param <R> The subtype of {@link ClientError} payload that can be processed by this factory.
13+
*/
14+
@Beta
15+
public interface ClientExceptionFactory<E extends ClientException, R extends ClientError> {
16+
17+
/**
18+
* Creates an exception with a message and optional cause.
19+
*
20+
* @param message A descriptive message for the exception.
21+
* @param cause An optional cause of the exception, can be null if not applicable.
22+
* @return An instance of the specified {@link ClientException} type
23+
*/
24+
@Nonnull
25+
E build(@Nonnull final String message, @Nullable final Throwable cause);
26+
27+
/**
28+
* Creates an exception from a given message and an HTTP error response that has been successfully
29+
* deserialized into a {@link ClientError} object.
30+
*
31+
* @param message A descriptive message for the exception.
32+
* @param clientError The structured {@link ClientError} object deserialized from the response.
33+
* @return An instance of the specified {@link ClientException} type
34+
*/
35+
@Nonnull
36+
E buildFromClientError(@Nonnull final String message, @Nonnull final R clientError);
37+
}

core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java

Lines changed: 72 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,40 @@
66
import com.fasterxml.jackson.databind.ObjectMapper;
77
import com.google.common.annotations.Beta;
88
import io.vavr.control.Try;
9-
import java.io.IOException;
109
import java.nio.charset.StandardCharsets;
11-
import java.util.Objects;
12-
import java.util.function.BiFunction;
10+
import java.util.Optional;
1311
import javax.annotation.Nonnull;
12+
import javax.annotation.Nullable;
1413
import lombok.RequiredArgsConstructor;
1514
import lombok.extern.slf4j.Slf4j;
1615
import lombok.val;
1716
import org.apache.hc.core5.http.ClassicHttpResponse;
1817
import org.apache.hc.core5.http.ContentType;
1918
import org.apache.hc.core5.http.HttpEntity;
20-
import org.apache.hc.core5.http.ParseException;
2119
import org.apache.hc.core5.http.io.HttpClientResponseHandler;
2220
import org.apache.hc.core5.http.io.entity.EntityUtils;
2321

2422
/**
2523
* Parse incoming JSON responses and handles any errors. For internal use only.
2624
*
27-
* @param <T> The type of the response.
25+
* @param <T> The type of the successful response.
2826
* @param <E> The type of the exception to throw.
27+
* @param <R> The type of the error response.
2928
* @since 1.1.0
3029
*/
3130
@Beta
3231
@Slf4j
3332
@RequiredArgsConstructor
34-
public class ClientResponseHandler<T, E extends ClientException>
33+
public class ClientResponseHandler<T, R extends ClientError, E extends ClientException>
3534
implements HttpClientResponseHandler<T> {
36-
@Nonnull final Class<T> responseType;
37-
@Nonnull private final Class<? extends ClientError> errorType;
38-
@Nonnull final BiFunction<String, Throwable, E> exceptionConstructor;
35+
/** The HTTP success response type */
36+
@Nonnull final Class<T> successType;
37+
38+
/** The HTTP error response type */
39+
@Nonnull final Class<R> errorType;
40+
41+
/** The factory to create exceptions for Http 4xx/5xx responses. */
42+
@Nonnull final ClientExceptionFactory<E, R> exceptionFactory;
3943

4044
/** The parses for JSON responses, will be private once we can remove mixins */
4145
@Nonnull ObjectMapper objectMapper = getDefaultObjectMapper();
@@ -48,7 +52,7 @@ public class ClientResponseHandler<T, E extends ClientException>
4852
*/
4953
@Beta
5054
@Nonnull
51-
public ClientResponseHandler<T, E> objectMapper(@Nonnull final ObjectMapper jackson) {
55+
public ClientResponseHandler<T, R, E> objectMapper(@Nonnull final ObjectMapper jackson) {
5256
objectMapper = jackson;
5357
return this;
5458
}
@@ -64,91 +68,105 @@ public ClientResponseHandler<T, E> objectMapper(@Nonnull final ObjectMapper jack
6468
@Override
6569
public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E {
6670
if (response.getCode() >= 300) {
67-
buildExceptionAndThrow(response);
71+
buildAndThrowException(response);
6872
}
69-
return parseResponse(response);
73+
return parseSuccess(response);
7074
}
7175

7276
// The InputStream of the HTTP entity is closed by EntityUtils.toString
7377
@SuppressWarnings("PMD.CloseResource")
7478
@Nonnull
75-
private T parseResponse(@Nonnull final ClassicHttpResponse response) throws E {
79+
private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E {
7680
final HttpEntity responseEntity = response.getEntity();
7781
if (responseEntity == null) {
78-
throw exceptionConstructor.apply("Response was empty.", null);
82+
throw exceptionFactory.build("The HTTP Response is empty", null);
7983
}
80-
val content = getContent(responseEntity);
84+
85+
val content =
86+
tryGetContent(responseEntity)
87+
.getOrElseThrow(e -> exceptionFactory.build("Failed to parse response entity.", e));
8188
try {
82-
return objectMapper.readValue(content, responseType);
89+
return objectMapper.readValue(content, successType);
8390
} catch (final JsonProcessingException e) {
84-
log.error("Failed to parse response to type {}", responseType);
85-
throw exceptionConstructor.apply("Failed to parse response", e);
91+
log.error("Failed to parse response to type {}", successType);
92+
throw exceptionFactory.build("Failed to parse response", e);
8693
}
8794
}
8895

8996
@Nonnull
90-
private String getContent(@Nonnull final HttpEntity entity) {
91-
try {
92-
return EntityUtils.toString(entity, StandardCharsets.UTF_8);
93-
} catch (IOException | ParseException e) {
94-
throw exceptionConstructor.apply("Failed to read response content", e);
95-
}
97+
private Try<String> tryGetContent(@Nonnull final HttpEntity entity) {
98+
return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8));
9699
}
97100

98101
/**
99-
* Parse the error response and throw an exception.
102+
* Process the error response and throw an exception.
100103
*
101-
* @param response The response to process
104+
* @param httpResponse The response to process
105+
* @throws ClientException if the response is an error (4xx/5xx)
102106
*/
103107
@SuppressWarnings("PMD.CloseResource")
104-
public void buildExceptionAndThrow(@Nonnull final ClassicHttpResponse response) throws E {
105-
val exception =
106-
exceptionConstructor.apply(
107-
"Request failed with status %s %s"
108-
.formatted(response.getCode(), response.getReasonPhrase()),
109-
null);
110-
val entity = response.getEntity();
108+
protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E {
109+
110+
val entity = httpResponse.getEntity();
111+
111112
if (entity == null) {
112-
throw exception;
113+
val message = getErrorMessage(httpResponse, "The HTTP Response is empty");
114+
throw exceptionFactory.build(message, null);
113115
}
114-
val maybeContent = Try.of(() -> getContent(entity));
116+
val maybeContent = tryGetContent(entity);
115117
if (maybeContent.isFailure()) {
116-
exception.addSuppressed(maybeContent.getCause());
117-
throw exception;
118+
val message = getErrorMessage(httpResponse, "Failed to read the response content");
119+
val baseException = exceptionFactory.build(message, null);
120+
baseException.addSuppressed(maybeContent.getCause());
121+
throw baseException;
118122
}
119123
val content = maybeContent.get();
120-
if (content.isBlank()) {
121-
throw exception;
124+
if (content == null || content.isBlank()) {
125+
val message = getErrorMessage(httpResponse, "Empty or blank response content");
126+
throw exceptionFactory.build(message, null);
122127
}
123128

124129
log.error(
125130
"The service responded with an HTTP {} ({})",
126-
response.getCode(),
127-
response.getReasonPhrase());
131+
httpResponse.getCode(),
132+
httpResponse.getReasonPhrase());
128133
val contentType = ContentType.parse(entity.getContentType());
129134
if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) {
130-
throw exception;
135+
val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON");
136+
throw exceptionFactory.build(message, null);
131137
}
132138

133-
parseErrorAndThrow(content, exception);
139+
parseErrorResponseAndThrow(content, httpResponse);
134140
}
135141

136142
/**
137-
* Parse the error response and throw an exception.
143+
* Parses the JSON content of an error response and throws a module specific exception.
138144
*
139-
* @param errorResponse the error response, most likely a unique JSON class.
140-
* @param baseException a base exception to add the error message to.
145+
* @param content The JSON content of the error response.
146+
* @param httpResponse The HTTP response that contains the error.
147+
* @throws ClientException if the response is an error (4xx/5xx)
141148
*/
142-
public void parseErrorAndThrow(
143-
@Nonnull final String errorResponse, @Nonnull final E baseException) throws E {
144-
val maybeError = Try.of(() -> objectMapper.readValue(errorResponse, errorType));
145-
if (maybeError.isFailure()) {
146-
baseException.addSuppressed(maybeError.getCause());
149+
protected void parseErrorResponseAndThrow(
150+
@Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E {
151+
val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType));
152+
if (maybeClientError.isFailure()) {
153+
val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response");
154+
val baseException = exceptionFactory.build(message, null);
155+
baseException.addSuppressed(maybeClientError.getCause());
147156
throw baseException;
148157
}
158+
final R clientError = maybeClientError.get();
159+
val message = getErrorMessage(httpResponse, clientError.getMessage());
160+
throw exceptionFactory.buildFromClientError(message, clientError);
161+
}
162+
163+
private static String getErrorMessage(
164+
@Nonnull final ClassicHttpResponse httpResponse, @Nullable final String additionalMessage) {
165+
val baseErrorMessage =
166+
"Request failed with status %d (%s)"
167+
.formatted(httpResponse.getCode(), httpResponse.getReasonPhrase());
149168

150-
val error = Objects.requireNonNullElse(maybeError.get().getMessage(), "");
151-
val message = "%s and error message: '%s'".formatted(baseException.getMessage(), error);
152-
throw exceptionConstructor.apply(message, baseException);
169+
val message = Optional.ofNullable(additionalMessage).orElse("");
170+
return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message);
153171
}
154172
}

core/src/main/java/com/sap/ai/sdk/core/common/ClientStreamingHandler.java

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import com.fasterxml.jackson.databind.ObjectMapper;
44
import com.google.common.annotations.Beta;
55
import java.io.IOException;
6-
import java.util.function.BiFunction;
76
import java.util.stream.Stream;
87
import javax.annotation.Nonnull;
98
import lombok.extern.slf4j.Slf4j;
@@ -14,12 +13,14 @@
1413
*
1514
* @param <D> The type of the response.
1615
* @param <E> The type of the exception to throw.
16+
* @param <R> The type of the error.
1717
* @since 1.2.0
1818
*/
1919
@Beta
2020
@Slf4j
21-
public class ClientStreamingHandler<D extends StreamedDelta, E extends ClientException>
22-
extends ClientResponseHandler<D, E> {
21+
public class ClientStreamingHandler<
22+
D extends StreamedDelta, R extends ClientError, E extends ClientException>
23+
extends ClientResponseHandler<D, R, E> {
2324

2425
/**
2526
* Set the {@link ObjectMapper} to use for parsing JSON responses.
@@ -28,7 +29,7 @@ public class ClientStreamingHandler<D extends StreamedDelta, E extends ClientExc
2829
* @return the current instance of {@link ClientStreamingHandler} with the changed object mapper
2930
*/
3031
@Nonnull
31-
public ClientStreamingHandler<D, E> objectMapper(@Nonnull final ObjectMapper jackson) {
32+
public ClientStreamingHandler<D, R, E> objectMapper(@Nonnull final ObjectMapper jackson) {
3233
super.objectMapper(jackson);
3334
return this;
3435
}
@@ -38,13 +39,13 @@ public ClientStreamingHandler<D, E> objectMapper(@Nonnull final ObjectMapper jac
3839
*
3940
* @param deltaType The type of the response.
4041
* @param errorType The type of the error.
41-
* @param exceptionType The type of the exception to throw.
42+
* @param exceptionFactory The factory to create exceptions.
4243
*/
4344
public ClientStreamingHandler(
4445
@Nonnull final Class<D> deltaType,
45-
@Nonnull final Class<? extends ClientError> errorType,
46-
@Nonnull final BiFunction<String, Throwable, E> exceptionType) {
47-
super(deltaType, errorType, exceptionType);
46+
@Nonnull final Class<R> errorType,
47+
@Nonnull final ClientExceptionFactory<E, R> exceptionFactory) {
48+
super(deltaType, errorType, exceptionFactory);
4849
}
4950

5051
/**
@@ -59,26 +60,27 @@ public ClientStreamingHandler(
5960
@Nonnull
6061
public Stream<D> handleStreamingResponse(@Nonnull final ClassicHttpResponse response) throws E {
6162
if (response.getCode() >= 300) {
62-
super.buildExceptionAndThrow(response);
63+
super.buildAndThrowException(response);
6364
}
64-
return IterableStreamConverter.lines(response.getEntity(), exceptionConstructor)
65+
66+
return IterableStreamConverter.lines(response.getEntity(), exceptionFactory)
6567
// half of the lines are empty newlines, the last line is "data: [DONE]"
6668
.filter(line -> !line.isEmpty() && !"data: [DONE]".equals(line.trim()))
6769
.peek(
6870
line -> {
6971
if (!line.startsWith("data: ")) {
7072
final String msg = "Failed to parse response";
71-
super.parseErrorAndThrow(line, exceptionConstructor.apply(msg, null));
73+
throw exceptionFactory.build(msg, null);
7274
}
7375
})
7476
.map(
7577
line -> {
7678
final String data = line.substring(5); // remove "data: "
7779
try {
78-
return objectMapper.readValue(data, responseType);
80+
return objectMapper.readValue(data, successType);
7981
} catch (final IOException e) { // exception message e gets lost
80-
log.error("Failed to parse delta chunk to type {}", responseType);
81-
throw exceptionConstructor.apply("Failed to parse delta chunk", e);
82+
log.error("Failed to parse delta chunk to type {}", successType);
83+
throw exceptionFactory.build("Failed to parse delta chunk", e);
8284
}
8385
});
8486
}

0 commit comments

Comments
 (0)