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