Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e863592
Input Filtering resolved
rpanackal Jul 8, 2025
c16677f
Input Filtering and Output filtering resolved
rpanackal Jul 8, 2025
842d53c
Refactor - from enum to individual types of filtering exception
rpanackal Jul 8, 2025
4a1a026
Refactor - from enum to individual types of filtering exception
rpanackal Jul 10, 2025
69087b6
Exception factory introduced
rpanackal Jul 11, 2025
6e48dab
Merge branch 'refs/heads/main' into feat/orchestration/filter-excepti…
rpanackal Jul 11, 2025
e6d371c
Finish merge with main
rpanackal Jul 11, 2025
485482f
Update tests and add better javadocs
rpanackal Jul 14, 2025
55e1ee5
Merge branch 'refs/heads/main' into feat/orchestration/filter-excepti…
rpanackal Jul 14, 2025
f6f3528
Updating error messages
rpanackal Jul 14, 2025
efbf415
Merge branch 'refs/heads/main' into feat/orchestration/filter-excepti…
rpanackal Jul 17, 2025
d22b715
Integrating charles suggestions and extend unit tests
rpanackal Jul 21, 2025
4699a43
Integrating charles suggestions and extend unit tests
rpanackal Jul 22, 2025
bbbc813
Merge branch 'feat/orchestration/filter-exception-handling-factory' o…
rpanackal Jul 22, 2025
bcf8c44
Filter exc classes non static and setter for client error
rpanackal Jul 22, 2025
8e3a379
Filter exc classes better static
rpanackal Jul 22, 2025
25f2758
Formatting
bot-sdk-js Jul 22, 2025
e503900
Suppress unchecked warning
rpanackal Jul 22, 2025
90e3bbd
Merge remote-tracking branch 'origin/feat/orchestration/filter-except…
rpanackal Jul 22, 2025
31892ea
Formatting
bot-sdk-js Jul 22, 2025
535c906
cleaning up
rpanackal Jul 22, 2025
0d25be7
Merge remote-tracking branch 'origin/feat/orchestration/filter-except…
rpanackal Jul 22, 2025
df8ce81
Merge branch 'refs/heads/main' into feat/orchestration/filter-excepti…
rpanackal Jul 22, 2025
2c0f2ea
fix method reference
rpanackal Jul 22, 2025
6d3525f
Shortcut getErrorResponse
rpanackal Jul 25, 2025
c2cacc7
Merge remote-tracking branch 'refs/remotes/origin/main' into feat/orc…
rpanackal Jul 25, 2025
c9c5c96
merging change with careful logging
rpanackal Jul 25, 2025
180d337
Merge branch 'main' into feat/orchestration/filter-exception-handling…
rpanackal Jul 28, 2025
7b631c1
Release notes
rpanackal Jul 28, 2025
13d0f91
Update core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHa…
rpanackal Jul 28, 2025
2bc9a24
Update orchestration/src/test/java/com/sap/ai/sdk/orchestration/Orche…
rpanackal Jul 28, 2025
0578560
Sample app tests use filter specific convenience pn exception
rpanackal Jul 28, 2025
c1b6440
Merge branch 'feat/orchestration/filter-exception-handling-factory' o…
rpanackal Jul 28, 2025
cf59964
Review suggestion
rpanackal Jul 30, 2025
2293fdc
Formatting
bot-sdk-js Jul 30, 2025
b07d789
Merge branch 'main' into feat/orchestration/filter-exception-handling…
rpanackal Aug 4, 2025
6a348da
Handle class cast exceptions
newtork Aug 5, 2025
a9b48d4
Format
newtork Aug 5, 2025
789e12b
Format
newtork Aug 5, 2025
9fc9108
Merge branch 'main' into feat/orchestration/filter-exception-handling…
newtork Aug 5, 2025
e4b179f
Fix compilation
newtork Aug 5, 2025
5252a2c
Merge remote-tracking branch 'origin/feat/orchestration/filter-except…
newtork Aug 5, 2025
0235bf1
Fix compilation
newtork Aug 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.sap.ai.sdk.core.common;

import com.google.common.annotations.Beta;
import javax.annotation.Nullable;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.StandardException;

/**
Expand All @@ -10,4 +14,14 @@
*/
@Beta
@StandardException
public class ClientException extends RuntimeException {}
public class ClientException extends RuntimeException {

/**
* Wraps a structured error payload received from the remote service, if available. This can be
* used to extract more detailed error information.
*/
@Nullable
@Getter(onMethod_ = @Beta, value = AccessLevel.PROTECTED)
@Setter(onMethod_ = @Beta, value = AccessLevel.PROTECTED)
ClientError clientError;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.sap.ai.sdk.core.common;

import com.google.common.annotations.Beta;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;

/**
* A factory whose implementations can provide customized exception types and error mapping logic
* for different service clients or error scenarios.
*
* @param <E> The subtype of {@link ClientException} to be created by this factory.
* @param <R> The subtype of {@link ClientError} payload that can be processed by this factory.
*/
@Beta
public interface ClientExceptionFactory<E extends ClientException, R extends ClientError> {

/**
* Creates an exception with a message and optional cause.
*
* @param message A descriptive message for the exception.
* @param cause An optional cause of the exception, can be null if not applicable.
* @return An instance of the specified {@link ClientException} type
*/
@Nonnull
E build(@Nonnull final String message, @Nullable final Throwable cause);

/**
* Creates an exception from a given message and an HTTP error response that has been successfully
* deserialized into a {@link ClientError} object.
*
* @param message A descriptive message for the exception.
* @param clientError The structured {@link ClientError} object deserialized from the response.
* @return An instance of the specified {@link ClientException} type
*/
@Nonnull
E buildFromClientError(@Nonnull final String message, @Nonnull final R clientError);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,40 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.Beta;
import io.vavr.control.Try;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.Optional;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.ParseException;
import org.apache.hc.core5.http.io.HttpClientResponseHandler;
import org.apache.hc.core5.http.io.entity.EntityUtils;

/**
* Parse incoming JSON responses and handles any errors. For internal use only.
*
* @param <T> The type of the response.
* @param <T> The type of the successful response.
* @param <E> The type of the exception to throw.
* @param <R> The type of the error response.
* @since 1.1.0
*/
@Beta
@Slf4j
@RequiredArgsConstructor
public class ClientResponseHandler<T, E extends ClientException>
public class ClientResponseHandler<T, R extends ClientError, E extends ClientException>
implements HttpClientResponseHandler<T> {
@Nonnull final Class<T> responseType;
@Nonnull private final Class<? extends ClientError> errorType;
@Nonnull final BiFunction<String, Throwable, E> exceptionConstructor;
/** The HTTP success response type */
@Nonnull final Class<T> successType;

/** The HTTP error response type */
@Nonnull final Class<R> errorType;

/** The factory to create exceptions for Http 4xx/5xx responses. */
@Nonnull final ClientExceptionFactory<E, R> exceptionFactory;

/** The parses for JSON responses, will be private once we can remove mixins */
@Nonnull ObjectMapper objectMapper = getDefaultObjectMapper();
Expand All @@ -48,7 +52,7 @@ public class ClientResponseHandler<T, E extends ClientException>
*/
@Beta
@Nonnull
public ClientResponseHandler<T, E> objectMapper(@Nonnull final ObjectMapper jackson) {
public ClientResponseHandler<T, R, E> objectMapper(@Nonnull final ObjectMapper jackson) {
objectMapper = jackson;
return this;
}
Expand All @@ -64,91 +68,105 @@ public ClientResponseHandler<T, E> objectMapper(@Nonnull final ObjectMapper jack
@Override
public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E {
if (response.getCode() >= 300) {
buildExceptionAndThrow(response);
buildAndThrowException(response);
}
return parseResponse(response);
return parseSuccess(response);
}

// The InputStream of the HTTP entity is closed by EntityUtils.toString
@SuppressWarnings("PMD.CloseResource")
@Nonnull
private T parseResponse(@Nonnull final ClassicHttpResponse response) throws E {
private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E {
final HttpEntity responseEntity = response.getEntity();
if (responseEntity == null) {
throw exceptionConstructor.apply("Response was empty.", null);
throw exceptionFactory.build("The HTTP Response is empty", null);
}
val content = getContent(responseEntity);

val content =
tryGetContent(responseEntity)
.getOrElseThrow(e -> exceptionFactory.build("Failed to parse response entity.", e));
try {
return objectMapper.readValue(content, responseType);
return objectMapper.readValue(content, successType);
} catch (final JsonProcessingException e) {
log.error("Failed to parse response to type {}", responseType);
throw exceptionConstructor.apply("Failed to parse response", e);
log.error("Failed to parse response to type {}", successType);
throw exceptionFactory.build("Failed to parse response", e);
}
}

@Nonnull
private String getContent(@Nonnull final HttpEntity entity) {
try {
return EntityUtils.toString(entity, StandardCharsets.UTF_8);
} catch (IOException | ParseException e) {
throw exceptionConstructor.apply("Failed to read response content", e);
}
private Try<String> tryGetContent(@Nonnull final HttpEntity entity) {
return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8));
}

/**
* Parse the error response and throw an exception.
* Process the error response and throw an exception.
*
* @param response The response to process
* @param httpResponse The response to process
* @throws ClientException if the response is an error (4xx/5xx)
*/
@SuppressWarnings("PMD.CloseResource")
public void buildExceptionAndThrow(@Nonnull final ClassicHttpResponse response) throws E {
val exception =
exceptionConstructor.apply(
"Request failed with status %s %s"
.formatted(response.getCode(), response.getReasonPhrase()),
null);
val entity = response.getEntity();
protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E {

val entity = httpResponse.getEntity();

if (entity == null) {
throw exception;
val message = getErrorMessage(httpResponse, "The HTTP Response is empty");
throw exceptionFactory.build(message, null);
}
val maybeContent = Try.of(() -> getContent(entity));
val maybeContent = tryGetContent(entity);
if (maybeContent.isFailure()) {
exception.addSuppressed(maybeContent.getCause());
throw exception;
val message = getErrorMessage(httpResponse, "Failed to read the response content");
val baseException = exceptionFactory.build(message, null);
baseException.addSuppressed(maybeContent.getCause());
throw baseException;
}
val content = maybeContent.get();
if (content.isBlank()) {
throw exception;
if (content == null || content.isBlank()) {
val message = getErrorMessage(httpResponse, "Empty or blank response content");
throw exceptionFactory.build(message, null);
}

log.error(
"The service responded with an HTTP {} ({})",
response.getCode(),
response.getReasonPhrase());
httpResponse.getCode(),
httpResponse.getReasonPhrase());
val contentType = ContentType.parse(entity.getContentType());
if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) {
throw exception;
val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON");
throw exceptionFactory.build(message, null);
}

parseErrorAndThrow(content, exception);
parseErrorResponseAndThrow(content, httpResponse);
}

/**
* Parse the error response and throw an exception.
* Parses the JSON content of an error response and throws a module specific exception.
*
* @param errorResponse the error response, most likely a unique JSON class.
* @param baseException a base exception to add the error message to.
* @param content The JSON content of the error response.
* @param httpResponse The HTTP response that contains the error.
* @throws ClientException if the response is an error (4xx/5xx)
*/
public void parseErrorAndThrow(
@Nonnull final String errorResponse, @Nonnull final E baseException) throws E {
val maybeError = Try.of(() -> objectMapper.readValue(errorResponse, errorType));
if (maybeError.isFailure()) {
baseException.addSuppressed(maybeError.getCause());
protected void parseErrorResponseAndThrow(
@Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E {
val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType));
if (maybeClientError.isFailure()) {
val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response");
val baseException = exceptionFactory.build(message, null);
baseException.addSuppressed(maybeClientError.getCause());
throw baseException;
}
final R clientError = maybeClientError.get();
val message = getErrorMessage(httpResponse, clientError.getMessage());
throw exceptionFactory.buildFromClientError(message, clientError);
}

private static String getErrorMessage(
@Nonnull final ClassicHttpResponse httpResponse, @Nullable final String additionalMessage) {
val baseErrorMessage =
"Request failed with status %d (%s)"
.formatted(httpResponse.getCode(), httpResponse.getReasonPhrase());

val error = Objects.requireNonNullElse(maybeError.get().getMessage(), "");
val message = "%s and error message: '%s'".formatted(baseException.getMessage(), error);
throw exceptionConstructor.apply(message, baseException);
val message = Optional.ofNullable(additionalMessage).orElse("");
return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.Beta;
import java.io.IOException;
import java.util.function.BiFunction;
import java.util.stream.Stream;
import javax.annotation.Nonnull;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -14,12 +13,14 @@
*
* @param <D> The type of the response.
* @param <E> The type of the exception to throw.
* @param <R> The type of the error.
* @since 1.2.0
*/
@Beta
@Slf4j
public class ClientStreamingHandler<D extends StreamedDelta, E extends ClientException>
extends ClientResponseHandler<D, E> {
public class ClientStreamingHandler<
D extends StreamedDelta, R extends ClientError, E extends ClientException>
extends ClientResponseHandler<D, R, E> {

/**
* Set the {@link ObjectMapper} to use for parsing JSON responses.
Expand All @@ -28,7 +29,7 @@ public class ClientStreamingHandler<D extends StreamedDelta, E extends ClientExc
* @return the current instance of {@link ClientStreamingHandler} with the changed object mapper
*/
@Nonnull
public ClientStreamingHandler<D, E> objectMapper(@Nonnull final ObjectMapper jackson) {
public ClientStreamingHandler<D, R, E> objectMapper(@Nonnull final ObjectMapper jackson) {
super.objectMapper(jackson);
return this;
}
Expand All @@ -38,13 +39,13 @@ public ClientStreamingHandler<D, E> objectMapper(@Nonnull final ObjectMapper jac
*
* @param deltaType The type of the response.
* @param errorType The type of the error.
* @param exceptionType The type of the exception to throw.
* @param exceptionFactory The factory to create exceptions.
*/
public ClientStreamingHandler(
@Nonnull final Class<D> deltaType,
@Nonnull final Class<? extends ClientError> errorType,
@Nonnull final BiFunction<String, Throwable, E> exceptionType) {
super(deltaType, errorType, exceptionType);
@Nonnull final Class<R> errorType,
@Nonnull final ClientExceptionFactory<E, R> exceptionFactory) {
super(deltaType, errorType, exceptionFactory);
}

/**
Expand All @@ -59,26 +60,27 @@ public ClientStreamingHandler(
@Nonnull
public Stream<D> handleStreamingResponse(@Nonnull final ClassicHttpResponse response) throws E {
if (response.getCode() >= 300) {
super.buildExceptionAndThrow(response);
super.buildAndThrowException(response);
}
return IterableStreamConverter.lines(response.getEntity(), exceptionConstructor)

return IterableStreamConverter.lines(response.getEntity(), exceptionFactory)
// half of the lines are empty newlines, the last line is "data: [DONE]"
.filter(line -> !line.isEmpty() && !"data: [DONE]".equals(line.trim()))
.peek(
line -> {
if (!line.startsWith("data: ")) {
final String msg = "Failed to parse response";
super.parseErrorAndThrow(line, exceptionConstructor.apply(msg, null));
throw exceptionFactory.build(msg, null);
}
})
.map(
line -> {
final String data = line.substring(5); // remove "data: "
try {
return objectMapper.readValue(data, responseType);
return objectMapper.readValue(data, successType);
} catch (final IOException e) { // exception message e gets lost
log.error("Failed to parse delta chunk to type {}", responseType);
throw exceptionConstructor.apply("Failed to parse delta chunk", e);
log.error("Failed to parse delta chunk to type {}", successType);
throw exceptionFactory.build("Failed to parse delta chunk", e);
}
});
}
Expand Down
Loading