Skip to content

Commit 712d0ee

Browse files
committed
Merge branch 'main' into transcription-apis
2 parents 5b2552f + d70ea12 commit 712d0ee

64 files changed

Lines changed: 2667 additions & 274 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/Json.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55
package com.ibm.watsonx.ai.core;
66

7-
import static java.util.Objects.requireNonNull;
87
import java.util.ServiceLoader;
98
import com.ibm.watsonx.ai.core.provider.JacksonProvider;
109
import com.ibm.watsonx.ai.core.spi.json.JsonProvider;
@@ -64,10 +63,19 @@ public static String toJson(Object object) {
6463
* @return a JSON-formatted string representation of the object
6564
*/
6665
public static String prettyPrint(Object object) {
67-
requireNonNull(object);
6866
return provider.prettyPrint(object);
6967
}
7068

69+
/**
70+
* Validates whether the given string is a valid JSON object.
71+
*
72+
* @param json the JSON string to validate
73+
* @return {@code true} if the string is a valid JSON object, {@code false} otherwise
74+
*/
75+
public static boolean isValidObject(String json) {
76+
return provider.isValidObject(json);
77+
}
78+
7179
/**
7280
* Attempts to load a {@link JsonProvider} via {@link ServiceLoader}.
7381
* <p>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright IBM Corp. 2025 - 2025
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package com.ibm.watsonx.ai.core;
6+
7+
import static java.util.Optional.ofNullable;
8+
import java.time.Duration;
9+
10+
/**
11+
* Configuration class for retry behavior in HTTP interceptors.
12+
* <p>
13+
* This class provides centralized access to retry configuration parameters that can be customized via environment variables. All methods return
14+
* default values if the corresponding environment variables are not set.
15+
* </p>
16+
* <p>
17+
* Supported environment variables:
18+
* <ul>
19+
* <li>{@code WATSONX_RETRY_TOKEN_EXPIRED_MAX_RETRIES} - Maximum retries for expired token errors (default: 1)</li>
20+
* <li>{@code WATSONX_RETRY_STATUS_CODES_MAX_RETRIES} - Maximum retries for transient status codes (default: 10)</li>
21+
* <li>{@code WATSONX_RETRY_STATUS_CODES_BACKOFF_ENABLED} - Enable exponential backoff (default: true)</li>
22+
* <li>{@code WATSONX_RETRY_STATUS_CODES_INITIAL_INTERVAL_MS} - Initial retry interval in milliseconds (default: 20)</li>
23+
* </ul>
24+
* </p>
25+
*/
26+
public final class RetryConfig {
27+
private static final int DEFAULT_TOKEN_EXPIRED_MAX_RETRIES = 1;
28+
private static final int DEFAULT_STATUS_CODES_MAX_RETRIES = 10;
29+
private static final boolean DEFAULT_STATUS_CODES_BACKOFF_ENABLED = true;
30+
private static final Duration DEFAULT_STATUS_CODES_INITIAL_INTERVAL = Duration.ofMillis(20);
31+
32+
private RetryConfig() {}
33+
34+
/**
35+
* Returns the maximum number of retry attempts for expired authentication token errors.
36+
* <p>
37+
* This value can be customized by setting the {@code WATSONX_RETRY_TOKEN_EXPIRED_MAX_RETRIES} environment variable.
38+
* </p>
39+
*
40+
* @return the maximum number of retries for token expiration, defaults to 1
41+
*/
42+
public static int tokenExpiredMaxRetries() {
43+
return ofNullable(System.getenv("WATSONX_RETRY_TOKEN_EXPIRED_MAX_RETRIES"))
44+
.map(Integer::valueOf)
45+
.orElse(DEFAULT_TOKEN_EXPIRED_MAX_RETRIES);
46+
}
47+
48+
/**
49+
* Returns the maximum number of retry attempts for transient HTTP status codes.
50+
* <p>
51+
* This value can be customized by setting the {@code WATSONX_RETRY_STATUS_CODES_MAX_RETRIES} environment variable.
52+
* <p>
53+
* Applies to status codes: {@code 429}, {@code 503}, {@code 504}, and {@code 520}.
54+
* </p>
55+
*
56+
* @return the maximum number of retries for status codes, defaults to 10
57+
*/
58+
public static int statusCodesMaxRetries() {
59+
return ofNullable(System.getenv("WATSONX_RETRY_STATUS_CODES_MAX_RETRIES"))
60+
.map(Integer::valueOf)
61+
.orElse(DEFAULT_STATUS_CODES_MAX_RETRIES);
62+
}
63+
64+
/**
65+
* Returns whether exponential backoff is enabled for status code retries.
66+
* <p>
67+
* When enabled, the retry interval doubles after each failed attempt. This value can be customized by setting the
68+
* {@code WATSONX_RETRY_STATUS_CODES_BACKOFF_ENABLED} environment variable.
69+
* </p>
70+
*
71+
* @return {@code true} if exponential backoff is enabled, defaults to {@code true}
72+
*/
73+
public static boolean statusCodesExponentialBackoffEnabled() {
74+
return ofNullable(System.getenv("WATSONX_RETRY_STATUS_CODES_BACKOFF_ENABLED"))
75+
.map(Boolean::valueOf)
76+
.orElse(DEFAULT_STATUS_CODES_BACKOFF_ENABLED);
77+
}
78+
79+
/**
80+
* Returns the initial retry interval for status code retries.
81+
* <p>
82+
* This value can be customized by setting the {@code WATSONX_RETRY_STATUS_CODES_INITIAL_INTERVAL_MS} environment variable (in milliseconds). When
83+
* exponential backoff is enabled, this serves as the base interval that gets doubled with each retry.
84+
* </p>
85+
*
86+
* @return the initial retry interval, defaults to 20 milliseconds
87+
*/
88+
public static Duration statusCodesInitialRetryInterval() {
89+
return ofNullable(System.getenv("WATSONX_RETRY_STATUS_CODES_INITIAL_INTERVAL_MS"))
90+
.map(Long::valueOf)
91+
.map(Duration::ofMillis)
92+
.orElse(DEFAULT_STATUS_CODES_INITIAL_INTERVAL);
93+
}
94+
}
95+

modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/exception/WatsonxException.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public WatsonxException(Integer statusCode) {
3333
*
3434
* @param message the detail message explaining the exception
3535
* @param statusCode the HTTP status code of the error response
36-
* @param details the detailed error information from the API response, may be {@code null}
36+
* @param details the detailed error information from the API response
3737
*/
3838
public WatsonxException(String message, Integer statusCode, WatsonxError details) {
3939
super(message);

modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/http/AsyncHttpClient.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ public final class AsyncHttpClient extends BaseHttpClient {
3232
/**
3333
* Constructs an {@code AsyncHttpClient} with the given underlying {@link HttpClient} and interceptors.
3434
*
35-
* @param httpClient the HTTP client to use; if {@code null}, a default client is used
36-
* @param interceptors a list of asynchronous HTTP interceptors; may be {@code null}
35+
* @param httpClient the HTTP client to use, if {@code null}, a default client is used
36+
* @param interceptors a list of asynchronous HTTP interceptors
3737
*/
3838
AsyncHttpClient(HttpClient httpClient, List<AsyncHttpInterceptor> interceptors) {
3939
super(requireNonNull(httpClient, "The HTTP client cannot be null"));

modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/http/SyncHttpClient.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ public final class SyncHttpClient extends BaseHttpClient {
3131
/**
3232
* Constructs an {@code SyncHttpClient} with the given underlying {@link HttpClient} and interceptors.
3333
*
34-
* @param httpClient the HTTP client to use; if {@code null}, a default client is used
34+
* @param httpClient the HTTP client to use, if {@code null}, a default client is used
3535
* @param interceptors a list of synchronous HTTP interceptors; may be {@code null}
3636
*/
37-
private SyncHttpClient(HttpClient httpClient, List<SyncHttpInterceptor> interceptors) {
37+
SyncHttpClient(HttpClient httpClient, List<SyncHttpInterceptor> interceptors) {
3838
super(requireNonNull(httpClient, "The HTTP client cannot be null"));
3939
this.interceptors = requireNonNullElse(interceptors, List.of());
4040
}
@@ -44,7 +44,7 @@ private SyncHttpClient(HttpClient httpClient, List<SyncHttpInterceptor> intercep
4444
*
4545
* @param builder the builder instance
4646
*/
47-
public SyncHttpClient(Builder builder) {
47+
private SyncHttpClient(Builder builder) {
4848
this(builder.httpClient, builder.interceptors);
4949
}
5050

modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/http/interceptors/LoggerInterceptor.java

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ public void onNext(ByteBuffer item) {
150150
}
151151

152152
@Override
153-
public void onError(Throwable throwable) {}
153+
public void onError(Throwable throwable) {
154+
logger.warn("Error reading request body for logging", throwable);
155+
}
154156

155157
@Override
156158
public void onComplete() {
@@ -201,13 +203,7 @@ private <T> void logResponse(String watsonxAISDKRequestId, HttpResponse<T> respo
201203
headers = HttpUtils.inOneLine(response.headers().map());
202204
joiner.add("- headers: " + headers);
203205

204-
var headersMap = response.headers().map();
205-
var contentType = Optional.<String>empty();
206-
207-
if (headersMap.containsKey("Content-Type"))
208-
contentType = response.headers().firstValue("Content-Type");
209-
else if (headersMap.containsKey("content-type"))
210-
contentType = response.headers().firstValue("content-type");
206+
var contentType = response.headers().firstValue("Content-Type");
211207

212208
if (contentType.isPresent() && contentType.get().contains("application/json"))
213209
prettyPrint = true;
@@ -238,13 +234,7 @@ private void logRequest(HttpRequest request, String body) {
238234
body = formatBase64Image(body);
239235
body = maskApiKeysInJsonBody(body);
240236

241-
var headersMap = request.headers().map();
242-
var contentType = Optional.<String>empty();
243-
244-
if (headersMap.containsKey("Content-Type"))
245-
contentType = request.headers().firstValue("Content-Type");
246-
else if (headersMap.containsKey("content-type"))
247-
contentType = request.headers().firstValue("content-type");
237+
var contentType = request.headers().firstValue("Content-Type");
248238

249239
if (contentType.isPresent() && contentType.get().contains("application/json"))
250240
body = Json.prettyPrint(body);

modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/http/interceptors/RetryInterceptor.java

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.function.Predicate;
2323
import org.slf4j.Logger;
2424
import org.slf4j.LoggerFactory;
25+
import com.ibm.watsonx.ai.core.RetryConfig;
2526
import com.ibm.watsonx.ai.core.exception.WatsonxException;
2627
import com.ibm.watsonx.ai.core.exception.model.WatsonxError;
2728
import com.ibm.watsonx.ai.core.http.AsyncHttpInterceptor;
@@ -32,7 +33,6 @@
3233
* An HTTP interceptor that performs automatic retries.
3334
*/
3435
public final class RetryInterceptor implements SyncHttpInterceptor, AsyncHttpInterceptor {
35-
3636
private static final Logger logger = LoggerFactory.getLogger(RetryInterceptor.class);
3737

3838
public record RetryOn(Class<? extends Throwable> clazz, Optional<Predicate<Throwable>> predicate) {}
@@ -46,7 +46,7 @@ public record RetryOn(Class<? extends Throwable> clazz, Optional<Predicate<Throw
4646
* Checks whether a {@link WatsonxException} is retryable due to an expired authentication token.
4747
*/
4848
public static final RetryInterceptor ON_TOKEN_EXPIRED = RetryInterceptor.builder()
49-
.maxRetries(1)
49+
.maxRetries(RetryConfig.tokenExpiredMaxRetries())
5050
.retryOn(
5151
WatsonxException.class,
5252
ex -> {
@@ -70,16 +70,14 @@ public record RetryOn(Class<? extends Throwable> clazz, Optional<Predicate<Throw
7070
* </ul>
7171
*/
7272
public static final RetryInterceptor ON_RETRYABLE_STATUS_CODES = RetryInterceptor.builder()
73-
.maxRetries(10)
74-
.exponentialBackoff(true)
75-
.retryInterval(Duration.ofMillis(20))
73+
.maxRetries(RetryConfig.statusCodesMaxRetries())
74+
.exponentialBackoff(RetryConfig.statusCodesExponentialBackoffEnabled())
75+
.retryInterval(RetryConfig.statusCodesInitialRetryInterval())
7676
.retryOn(
7777
WatsonxException.class,
7878
ex -> {
7979
var statusCode = ((WatsonxException) ex).statusCode();
80-
return statusCode == 429 || statusCode == 503 || statusCode == 504 || statusCode == 520
81-
? true
82-
: false;
80+
return statusCode == 429 || statusCode == 503 || statusCode == 504 || statusCode == 520;
8381
}
8482
).build();
8583

@@ -125,7 +123,6 @@ public <T> HttpResponse<T> intercept(HttpRequest request, BodyHandler<T> bodyHan
125123
}
126124

127125
var res = chain.proceed(request, bodyHandler);
128-
timeout = Duration.from(retryInterval);
129126
return res;
130127

131128
} catch (Exception e) {
@@ -148,12 +145,10 @@ public <T> HttpResponse<T> intercept(HttpRequest request, BodyHandler<T> bodyHan
148145
continue;
149146
}
150147

151-
timeout = Duration.from(retryInterval);
152148
throw e;
153149
}
154150
}
155151

156-
timeout = Duration.from(retryInterval);
157152
throw new RuntimeException("Max retries reached for request [%s]".formatted(requestId), isNull(exception) ? new Exception() : exception);
158153
}
159154

@@ -166,7 +161,7 @@ private <T> CompletableFuture<HttpResponse<T>> executeWithRetry(HttpRequest requ
166161
Duration timeout, AsyncChain chain) {
167162

168163
return chain.proceed(request, bodyHandler)
169-
.exceptionallyCompose(throwable -> {
164+
.exceptionallyComposeAsync(throwable -> {
170165

171166
String requestId = request.headers()
172167
.firstValue(REQUEST_ID_HEADER)
@@ -203,13 +198,25 @@ private <T> CompletableFuture<HttpResponse<T>> executeWithRetry(HttpRequest requ
203198
},
204199
CompletableFuture.delayedExecutor(nextTimeout.toMillis(), TimeUnit.MILLISECONDS, ExecutorProvider.ioExecutor())
205200
).thenCompose(Function.identity());
206-
});
201+
}, ExecutorProvider.ioExecutor());
207202
}
208203

209204
public List<RetryOn> retryOn() {
210205
return retryOn;
211206
}
212207

208+
public int maxRetries() {
209+
return maxRetries;
210+
}
211+
212+
public Duration retryInterval() {
213+
return retryInterval;
214+
}
215+
216+
public boolean exponentialBackoff() {
217+
return exponentialBackoff;
218+
}
219+
213220
/**
214221
* Returns a new {@link Builder} instance.
215222
*

modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/provider/JacksonProvider.java

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
*/
55
package com.ibm.watsonx.ai.core.provider;
66

7+
import static java.util.Objects.isNull;
78
import com.fasterxml.jackson.annotation.JsonInclude.Include;
89
import com.fasterxml.jackson.core.JsonProcessingException;
910
import com.fasterxml.jackson.databind.DeserializationFeature;
1011
import com.fasterxml.jackson.databind.JavaType;
12+
import com.fasterxml.jackson.databind.JsonNode;
1113
import com.fasterxml.jackson.databind.ObjectMapper;
1214
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
1315
import com.ibm.watsonx.ai.core.spi.json.JsonProvider;
@@ -36,7 +38,7 @@ public <T> T fromJson(String json, Class<T> type) {
3638
try {
3739
return objectMapper.readValue(json, type);
3840
} catch (JsonProcessingException e) {
39-
throw new RuntimeException(e);
41+
throw new RuntimeException("Failed to deserialize JSON: '" + json + "'", e);
4042
}
4143
}
4244

@@ -46,7 +48,7 @@ public <T> T fromJson(String json, TypeToken<T> type) {
4648
JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType());
4749
return objectMapper.readValue(json, javaType);
4850
} catch (JsonProcessingException e) {
49-
throw new RuntimeException(e);
51+
throw new RuntimeException("Failed to deserialize JSON: '" + json + "'", e);
5052
}
5153
}
5254

@@ -62,12 +64,24 @@ public String toJson(Object obj) {
6264
@Override
6365
public String prettyPrint(Object obj) {
6466
try {
65-
if (obj instanceof String str) {
66-
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(objectMapper.readTree((str)));
67-
}
68-
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj);
67+
return (obj instanceof String str)
68+
? objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(objectMapper.readTree((str)))
69+
: objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj);
6970
} catch (JsonProcessingException e) {
7071
return obj.toString();
7172
}
7273
}
74+
75+
@Override
76+
public boolean isValidObject(String json) {
77+
if (isNull(json) || json.isBlank())
78+
return false;
79+
80+
try {
81+
JsonNode node = objectMapper.readTree(json);
82+
return node.isObject();
83+
} catch (JsonProcessingException e) {
84+
return false;
85+
}
86+
}
7387
}

modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/spi/json/JsonProvider.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,12 @@ public interface JsonProvider {
4444
* @return a JSON-formatted string representation of the object
4545
*/
4646
String prettyPrint(Object object);
47+
48+
/**
49+
* Validates whether the given string is a valid JSON object.
50+
*
51+
* @param json the JSON string to validate
52+
* @return {@code true} if the string is a valid JSON object, {@code false} otherwise
53+
*/
54+
boolean isValidObject(String json);
4755
}

0 commit comments

Comments
 (0)