diff --git a/modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/HttpUtils.java b/modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/HttpUtils.java index 159e2bc6..573e997f 100644 --- a/modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/HttpUtils.java +++ b/modules/watsonx-ai-core/src/main/java/com/ibm/watsonx/ai/core/HttpUtils.java @@ -116,7 +116,8 @@ public static String inOneLine(Map> headers) { * @param statusCode status code of the http response. * @param body The error response body as a String. * @param contentType The content type of the error response. - * @return An instance of WatsonxError parsed from the body. + * @return An instance of WatsonxError parsed from the body, or an unclassified error carrying the status code when the content type is neither + * JSON nor XML. */ public static WatsonxError parseErrorBody(int statusCode, String body, String contentType) { if (isNull(contentType)) @@ -160,7 +161,7 @@ public static WatsonxError parseErrorBody(int statusCode, String body, String co if (contentType.contains("application/xml")) return parseXmlError(body); - throw new RuntimeException(body); + return new WatsonxError(statusCode, "", List.of(new Error(Code.UNCLASSIFIED.value(), body, null))); } /** diff --git a/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/HttpUtilsTest.java b/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/HttpUtilsTest.java index a019f803..8081d743 100644 --- a/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/HttpUtilsTest.java +++ b/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/HttpUtilsTest.java @@ -277,10 +277,23 @@ void should_parse_model_gateway_error_body_correctly_from_json() { } @Test - void should_throw_runtime_exception_when_parsing_error_body_with_unsupported_content_type() { - assertThrows(RuntimeException.class, () -> { - HttpUtils.parseErrorBody(500, "test body", "text/plain"); - }); + void should_parse_error_body_as_unclassified_when_content_type_is_not_supported() { + WatsonxError result = HttpUtils.parseErrorBody(500, "test body", "text/plain"); + assertEquals(500, result.statusCode()); + assertEquals(1, result.errors().size()); + assertEquals(WatsonxError.Code.UNCLASSIFIED.value(), result.errors().get(0).code()); + assertEquals("test body", result.errors().get(0).message()); + assertEquals(null, result.errors().get(0).moreInfo()); + } + + @Test + void should_keep_status_code_when_gateway_returns_plain_text_error() { + WatsonxError result = HttpUtils.parseErrorBody(502, "error code: 502", "text/plain"); + assertEquals(502, result.statusCode()); + assertEquals("error code: 502", result.errors().get(0).message()); + + var ex = HttpUtils.mapWatsonxException(new WatsonxException("error code: 502", 502, result)); + assertEquals(502, ex.statusCode()); } @Test diff --git a/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/RetryInterceptorTest.java b/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/RetryInterceptorTest.java index 119be0b9..0be41b9e 100644 --- a/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/RetryInterceptorTest.java +++ b/modules/watsonx-ai-core/src/test/java/com/ibm/watsonx/ai/core/RetryInterceptorTest.java @@ -362,6 +362,39 @@ public HttpResponse intercept(HttpRequest request, BodyHandler bodyHan client.send(httpRequest, bodyHandler); verify(httpClient, times(4)).send(any(), eq(bodyHandler)); } + + @Test + @SuppressWarnings("unchecked") + void should_retry_when_plain_text_response_has_retryable_status_code() throws Exception { + + SyncHttpClient client = SyncHttpClient.builder() + .httpClient(httpClient) + .interceptor(RetryInterceptor.ON_RETRYABLE_STATUS_CODES) + .interceptor(mockInterceptor) + .build(); + + when(mockInterceptor.intercept(any(), eq(bodyHandler), anyInt(), any())) + .thenAnswer(CHAIN_MOCK); + + var badGatewayResponse = mock(HttpResponse.class); + when(badGatewayResponse.statusCode()).thenReturn(502); + when(badGatewayResponse.headers()) + .thenReturn(HttpHeaders.of(Map.of("Content-Type", List.of("text/plain")), (t, u) -> true)); + when(badGatewayResponse.body()).thenReturn("error code: 502"); + + when(httpResponse.statusCode()) + .thenReturn(200); + + when(httpClient.send(any(), eq(bodyHandler))) + .thenReturn(badGatewayResponse) + .thenReturn(httpResponse); + + var result = client.send(httpRequest, bodyHandler); + assertEquals(httpResponse, result); + verify(httpClient, times(2)).send(any(), eq(bodyHandler)); + verify(mockInterceptor, times(2)).intercept(any(), eq(bodyHandler), anyInt(), any()); + } + } @Nested @@ -625,6 +658,49 @@ public CompletableFuture> intercept(HttpRequest request, Bod client.send(httpRequest, bodyHandler).join(); verify(httpClient, times(4)).sendAsync(any(), any(BodyHandler.class)); } + + @Test + @SuppressWarnings("unchecked") + void should_retry_when_plain_text_response_has_retryable_status_code() throws Exception { + + AsyncHttpClient client = AsyncHttpClient.builder() + .httpClient(httpClient) + .interceptor(RetryInterceptor.ON_RETRYABLE_STATUS_CODES) + .interceptor(mockInterceptor) + .build(); + + when(mockInterceptor.intercept(any(), eq(bodyHandler), anyInt(), any())) + .thenAnswer(CHAIN_MOCK); + + when(httpClient.sendAsync(any(), any(BodyHandler.class))) + .thenAnswer(invocation -> { + BodyHandler handler = invocation.getArgument(1); + var responseInfo = mock(HttpResponse.ResponseInfo.class); + when(responseInfo.statusCode()).thenReturn(502); + when(responseInfo.headers()) + .thenReturn(HttpHeaders.of(Map.of("Content-Type", List.of("text/plain")), (t, u) -> true)); + + var subscriber = handler.apply(responseInfo); + + subscriber.onSubscribe(mock(Flow.Subscription.class)); + subscriber.onNext(List.of(ByteBuffer.wrap("error code: 502".getBytes(StandardCharsets.UTF_8)))); + subscriber.onComplete(); + + return subscriber.getBody().handle((body, throwable) -> { + if (throwable != null) { + return CompletableFuture.failedFuture(throwable); + } + return CompletableFuture.failedFuture(new IllegalStateException("Exception was expected")); + }).thenCompose(cf -> cf); + }) + .thenReturn(completedFuture(httpResponse)); + + var result = client.send(httpRequest, bodyHandler).get(); + assertEquals(httpResponse, result); + verify(httpClient, times(2)).sendAsync(any(), any(BodyHandler.class)); + verify(mockInterceptor, times(2)).intercept(any(), eq(bodyHandler), anyInt(), any()); + } + } @Test