From e3ff2adef4e49d4ecbad5088f13e58af45919b39 Mon Sep 17 00:00:00 2001 From: "ark-hand[bot]" <315378070+ark-hand[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:12:56 +0800 Subject: [PATCH] fix: preserve nonstandard API error responses Sync-Source-Commit: b77319c0d2e7684f58f620668e5bca5c8a73dd40 Ark-APIs-Commit: 665a2441c2c8d9342e4770d776ea19de36ef6f83 Hand-Written-Reason: Manual error-handling fix (preserve nonstandard API error responses). No ark-apis regeneration involved; attributed to the same ark-apis snapshot as parent ef0cb7e. Release-Version: 0.3.0 --- pom.xml | 6 ++ .../ark/runtime/exception/ArkAPIError.java | 40 +++++++++++ .../runtime/exception/ArkHttpException.java | 21 ++++-- .../ark/runtime/service/ArkService.java | 70 ++++++++++--------- .../runtime/utils/ResponseBodyCallback.java | 30 ++++---- .../runtime/exception/ArkAPIErrorTest.java | 66 +++++++++++++++++ .../runtime/service/ArkServiceErrorTest.java | 68 ++++++++++++++++++ 7 files changed, 247 insertions(+), 54 deletions(-) create mode 100644 src/test/java/com/volcengine/ark/runtime/exception/ArkAPIErrorTest.java create mode 100644 src/test/java/com/volcengine/ark/runtime/service/ArkServiceErrorTest.java diff --git a/pom.xml b/pom.xml index 47d5bee..4761e41 100644 --- a/pom.xml +++ b/pom.xml @@ -114,6 +114,12 @@ ${bouncycastle-version} true + + junit + junit + 4.13.2 + test + diff --git a/src/main/java/com/volcengine/ark/runtime/exception/ArkAPIError.java b/src/main/java/com/volcengine/ark/runtime/exception/ArkAPIError.java index ef8cd52..ea9c94e 100644 --- a/src/main/java/com/volcengine/ark/runtime/exception/ArkAPIError.java +++ b/src/main/java/com/volcengine/ark/runtime/exception/ArkAPIError.java @@ -4,6 +4,10 @@ package com.volcengine.ark.runtime.exception; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; + public class ArkAPIError { ArkErrorDetails error; @@ -22,6 +26,42 @@ public void setError(ArkErrorDetails error) { this.error = error; } + /** + * Parses both the standard {"error": {...}} envelope and services that + * return the error details directly. Unknown response shapes retain the + * raw body as the exception message instead of producing a null error. + */ + public static ArkAPIError fromResponseBody(ObjectMapper mapper, String responseBody, String fallbackMessage) { + if (responseBody != null && !responseBody.trim().isEmpty()) { + try { + JsonNode root = mapper.readTree(responseBody); + if (root != null && root.isObject()) { + JsonNode detailsNode = root.get("error"); + if (detailsNode == null && root.has("message")) { + detailsNode = root; + } + if (detailsNode != null && detailsNode.isObject()) { + ArkErrorDetails details = mapper.treeToValue(detailsNode, ArkErrorDetails.class); + if (details != null) { + return new ArkAPIError(details); + } + } + } + } catch (IOException ignored) { + // Fall through and preserve the raw response body. + } + } + + String message = responseBody; + if (message == null || message.trim().isEmpty()) { + message = fallbackMessage; + } + if (message == null || message.trim().isEmpty()) { + message = "HTTP request failed with an empty response body"; + } + return new ArkAPIError(new ArkErrorDetails(message, "", "", "HTTPError")); + } + @Override public String toString() { return "ArkAPIError{" + diff --git a/src/main/java/com/volcengine/ark/runtime/exception/ArkHttpException.java b/src/main/java/com/volcengine/ark/runtime/exception/ArkHttpException.java index e3e42c0..2194b3d 100644 --- a/src/main/java/com/volcengine/ark/runtime/exception/ArkHttpException.java +++ b/src/main/java/com/volcengine/ark/runtime/exception/ArkHttpException.java @@ -18,14 +18,27 @@ public class ArkHttpException extends RuntimeException { public final String requestId; public ArkHttpException(ArkAPIError error, Exception parent, int statusCode, String requestId) { - super(error.error.message, parent); + super(errorMessage(error), parent); + ArkAPIError.ArkErrorDetails details = errorDetails(error); this.statusCode = statusCode; - this.code = error.error.code; - this.param = error.error.param; - this.type = error.error.type; + this.code = details.getCode(); + this.param = details.getParam(); + this.type = details.getType(); this.requestId = requestId; } + private static String errorMessage(ArkAPIError error) { + return errorDetails(error).getMessage(); + } + + private static ArkAPIError.ArkErrorDetails errorDetails(ArkAPIError error) { + if (error != null && error.getError() != null) { + return error.getError(); + } + return new ArkAPIError.ArkErrorDetails( + "HTTP request failed without error details", "", "", "HTTPError"); + } + public String getMessage() { return this.toString(); } diff --git a/src/main/java/com/volcengine/ark/runtime/service/ArkService.java b/src/main/java/com/volcengine/ark/runtime/service/ArkService.java index 751028f..ab16457 100644 --- a/src/main/java/com/volcengine/ark/runtime/service/ArkService.java +++ b/src/main/java/com/volcengine/ark/runtime/service/ArkService.java @@ -220,24 +220,7 @@ public static T execute(Single apiCall) { T resp = apiCall.blockingGet(); return resp; } catch (HttpException e) { - String requestId = ""; - try { - Headers headers = e.response().raw().request().headers(); - requestId = headers.get(Const.CLIENT_REQUEST_HEADER); - } catch (Exception ignored) { - } - - try { - if (e.response() == null || e.response().errorBody() == null) { - throw e; - } - String errorBody = e.response().errorBody().string(); - - ArkAPIError error = mapper.readValue(errorBody, ArkAPIError.class); - throw new ArkHttpException(error, e, e.code(), requestId); - } catch (IOException ex) { - throw e; - } + throw translateHttpException(e); } } @@ -245,30 +228,49 @@ public static void execute(Completable apiCall) { try { apiCall.blockingAwait(); } catch (RuntimeException e) { + if (e instanceof HttpException) { + throw translateHttpException((HttpException) e); + } Throwable cause = e.getCause(); if (cause instanceof HttpException) { HttpException he = (HttpException) cause; - String requestId = ""; - try { - Headers headers = he.response().raw().request().headers(); - requestId = headers.get(Const.CLIENT_REQUEST_HEADER); - } catch (Exception ignored) { - } - try { - if (he.response() == null || he.response().errorBody() == null) { - throw he; - } - String errorBody = he.response().errorBody().string(); - ArkAPIError error = mapper.readValue(errorBody, ArkAPIError.class); - throw new ArkHttpException(error, he, he.code(), requestId); - } catch (IOException ioe) { - throw new RuntimeException(he); - } + throw translateHttpException(he); } throw e; } } + private static ArkHttpException translateHttpException(HttpException exception) { + String requestId = requestId(exception); + String responseBody = null; + try { + if (exception.response() != null && exception.response().errorBody() != null) { + responseBody = exception.response().errorBody().string(); + } + } catch (IOException ignored) { + // The fallback below retains status and request ID even if reading fails. + } + + ArkAPIError error = ArkAPIError.fromResponseBody(mapper, responseBody, exception.getMessage()); + return new ArkHttpException(error, exception, exception.code(), requestId); + } + + private static String requestId(HttpException exception) { + try { + if (exception.response() != null) { + String serverRequestId = exception.response().headers().get(Const.SERVER_REQUEST_HEADER); + if (serverRequestId != null && !serverRequestId.isEmpty()) { + return serverRequestId; + } + String clientRequestId = exception.response().raw().request().header(Const.CLIENT_REQUEST_HEADER); + return clientRequestId == null ? "" : clientRequestId; + } + } catch (Exception ignored) { + // Return an empty ID when the response does not expose its request. + } + return ""; + } + public static Flowable stream(Call apiCall) { return stream(apiCall, false); } diff --git a/src/main/java/com/volcengine/ark/runtime/utils/ResponseBodyCallback.java b/src/main/java/com/volcengine/ark/runtime/utils/ResponseBodyCallback.java index d44fa3c..867b86a 100644 --- a/src/main/java/com/volcengine/ark/runtime/utils/ResponseBodyCallback.java +++ b/src/main/java/com/volcengine/ark/runtime/utils/ResponseBodyCallback.java @@ -50,8 +50,11 @@ public void onResponse(Call call, Response response) String requestId = ""; try { - Headers headers = response.raw().request().headers(); - requestId = headers.get(Const.CLIENT_REQUEST_HEADER); + requestId = response.headers().get(Const.SERVER_REQUEST_HEADER); + if (requestId == null || requestId.isEmpty()) { + Headers headers = response.raw().request().headers(); + requestId = headers.get(Const.CLIENT_REQUEST_HEADER); + } } catch (Exception ignored) { } @@ -80,22 +83,17 @@ public void onResponse(Call call, Response response) if (!response.isSuccessful()) { HttpException e = new HttpException(response); ResponseBody errorBody = response.errorBody(); - - if (errorBody == null) { - throw e; - } else { - try { - ArkAPIError error = mapper.readValue( - errorBody.string(), - ArkAPIError.class - ); - throw new ArkHttpException(error, e, e.code(), requestId); - } catch (ArkHttpException httpException) { - throw httpException; - } catch (Exception ignore) { - throw new ArkHttpException(new ArkAPIError(new ArkAPIError.ArkErrorDetails(e.getMessage(), "", "", "InternalServiceError")), e, e.code(), requestId); + String responseBody = null; + try { + if (errorBody != null) { + responseBody = errorBody.string(); } + } catch (IOException ignored) { + // Preserve status and request ID even if the body cannot be read. } + ArkAPIError error = ArkAPIError.fromResponseBody( + mapper, responseBody, e.getMessage()); + throw new ArkHttpException(error, e, e.code(), requestId); } InputStream in = response.body().byteStream(); diff --git a/src/test/java/com/volcengine/ark/runtime/exception/ArkAPIErrorTest.java b/src/test/java/com/volcengine/ark/runtime/exception/ArkAPIErrorTest.java new file mode 100644 index 0000000..07dcd41 --- /dev/null +++ b/src/test/java/com/volcengine/ark/runtime/exception/ArkAPIErrorTest.java @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.exception; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +public class ArkAPIErrorTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + public void parsesWrappedError() { + ArkAPIError error = ArkAPIError.fromResponseBody( + mapper, + "{\"error\":{\"message\":\"model not found\",\"code\":\"InvalidModel\"}}", + "fallback"); + + assertEquals("model not found", error.getError().getMessage()); + assertEquals("InvalidModel", error.getError().getCode()); + } + + @Test + public void parsesDirectError() { + ArkAPIError error = ArkAPIError.fromResponseBody( + mapper, + "{\"message\":\"model not found\",\"code\":\"InvalidModel\"}", + "fallback"); + + assertEquals("model not found", error.getError().getMessage()); + assertEquals("InvalidModel", error.getError().getCode()); + } + + @Test + public void preservesNonstandardJsonBody() { + String body = "{\"detail\":\"model is invalid\"}"; + ArkAPIError error = ArkAPIError.fromResponseBody(mapper, body, "fallback"); + ArkHttpException exception = new ArkHttpException(error, null, 400, "request-id"); + + assertEquals(body, error.getError().getMessage()); + assertEquals(400, exception.statusCode); + assertEquals("request-id", exception.requestId); + assertEquals("HTTPError", exception.code); + } + + @Test + public void preservesPlainTextAndEmptyBodies() { + ArkAPIError plain = ArkAPIError.fromResponseBody(mapper, "bad gateway", "fallback"); + ArkAPIError empty = ArkAPIError.fromResponseBody(mapper, "", "HTTP 400"); + + assertEquals("bad gateway", plain.getError().getMessage()); + assertEquals("HTTP 400", empty.getError().getMessage()); + } + + @Test + public void exceptionConstructorHandlesMissingDetails() { + ArkHttpException exception = new ArkHttpException(new ArkAPIError(), null, 400, "request-id"); + + assertNotNull(exception.getMessage()); + assertEquals("HTTPError", exception.code); + assertEquals("request-id", exception.requestId); + } +} diff --git a/src/test/java/com/volcengine/ark/runtime/service/ArkServiceErrorTest.java b/src/test/java/com/volcengine/ark/runtime/service/ArkServiceErrorTest.java new file mode 100644 index 0000000..4b54758 --- /dev/null +++ b/src/test/java/com/volcengine/ark/runtime/service/ArkServiceErrorTest.java @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package com.volcengine.ark.runtime.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.volcengine.ark.runtime.Const; +import com.volcengine.ark.runtime.exception.ArkHttpException; +import io.reactivex.Completable; +import io.reactivex.Single; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.ResponseBody; +import org.junit.Test; +import retrofit2.HttpException; +import retrofit2.Response; + +public class ArkServiceErrorTest { + @Test + public void singlePreservesNonstandardBodyStatusAndServerRequestId() { + HttpException source = httpException("{\"detail\":\"model is invalid\"}", "server-request-id"); + + try { + ArkService.execute(Single.error(source)); + fail("expected ArkHttpException"); + } catch (ArkHttpException error) { + assertEquals(400, error.statusCode); + assertEquals("server-request-id", error.requestId); + assertTrue(error.getMessage().contains("model is invalid")); + } + } + + @Test + public void completablePreservesNonstandardBodyStatusAndServerRequestId() { + HttpException source = httpException("bad request", "server-request-id"); + + try { + ArkService.execute(Completable.error(source)); + fail("expected ArkHttpException"); + } catch (ArkHttpException error) { + assertEquals(400, error.statusCode); + assertEquals("server-request-id", error.requestId); + assertTrue(error.getMessage().contains("bad request")); + } + } + + private static HttpException httpException(String body, String requestId) { + Request request = new Request.Builder() + .url("https://example.com/api/v3/tokenization") + .header(Const.CLIENT_REQUEST_HEADER, "client-request-id") + .build(); + okhttp3.Response rawResponse = new okhttp3.Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(400) + .message("Bad Request") + .header(Const.SERVER_REQUEST_HEADER, requestId) + .build(); + ResponseBody responseBody = ResponseBody.create( + MediaType.get("application/json"), body); + Response response = Response.error(responseBody, rawResponse); + return new HttpException(response); + } +}