This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new ff8670eca2 [api] Fall back to the HTTP status when the error body
omits code (#9661)
ff8670eca2 is described below
commit ff8670eca2e5e56a1e756528ca819d6c0baaf750
Author: jackylee <[email protected]>
AuthorDate: Sat Sep 12 21:33:27 2026 +0800
[api] Fall back to the HTTP status when the error body omits code (#9661)
---
.../apache/paimon/rest/DefaultErrorHandler.java | 5 +-
.../paimon/rest/responses/ErrorResponse.java | 9 ++-
.../paimon/rest/responses/ErrorResponseTest.java | 90 ++++++++++++++++++++++
.../paimon/rest/DefaultErrorHandlerTest.java | 14 ++++
.../org/apache/paimon/rest/HttpClientTest.java | 24 ++++++
5 files changed, 140 insertions(+), 2 deletions(-)
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/DefaultErrorHandler.java
b/paimon-api/src/main/java/org/apache/paimon/rest/DefaultErrorHandler.java
index 67ce6ced18..c1fd929736 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/DefaultErrorHandler.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/DefaultErrorHandler.java
@@ -42,7 +42,10 @@ public class DefaultErrorHandler extends ErrorHandler {
@Override
public void accept(ErrorResponse error, String requestId) {
- int code = error.getCode();
+ Integer errorCode = error.getCode();
+ // HttpClient always resolves the code before calling this, but the
response may also be
+ // deserialized directly, and then "code" is absent whenever the
server omits it.
+ int code = errorCode == null ? 0 : errorCode;
String message;
if (DEFAULT_REQUEST_ID.equals(requestId)) {
message = error.getMessage();
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
index bfc9e3bf4e..899b0896af 100644
---
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
@@ -73,6 +73,7 @@ public class ErrorResponse implements RESTResponse {
@JsonProperty(FIELD_MESSAGE)
private final String message;
+ @Nullable
@JsonProperty(FIELD_CODE)
private final Integer code;
@@ -81,13 +82,18 @@ public class ErrorResponse implements RESTResponse {
@Nullable @JsonProperty(FIELD_RESOURCE_TYPE) String resourceType,
@Nullable @JsonProperty(FIELD_RESOURCE_NAME) String resourceName,
@JsonProperty(FIELD_MESSAGE) String message,
- @JsonProperty(FIELD_CODE) int code) {
+ @Nullable @JsonProperty(FIELD_CODE) Integer code) {
this.resourceType = resourceType;
this.resourceName = resourceName;
this.message = message;
this.code = code;
}
+ /** Retained for callers compiled against the primitive {@code code}
descriptor. */
+ public ErrorResponse(String resourceType, String resourceName, String
message, int code) {
+ this(resourceType, resourceName, message, (Integer) code);
+ }
+
@JsonGetter(FIELD_MESSAGE)
public String getMessage() {
return message;
@@ -103,6 +109,7 @@ public class ErrorResponse implements RESTResponse {
return resourceName;
}
+ @Nullable
@JsonGetter(FIELD_CODE)
public Integer getCode() {
return code;
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/responses/ErrorResponseTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/responses/ErrorResponseTest.java
new file mode 100644
index 0000000000..7b977a0a32
--- /dev/null
+++
b/paimon-api/src/test/java/org/apache/paimon/rest/responses/ErrorResponseTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.rest.responses;
+
+import org.apache.paimon.rest.RESTApi;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Constructor;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/** Tests for {@link ErrorResponse}. */
+public class ErrorResponseTest {
+
+ private static final Class<?>[] PRIMITIVE_CODE_CTOR = {
+ String.class, String.class, String.class, int.class
+ };
+
+ @Test
+ public void testPrimitiveCodeConstructorDescriptorIsRetained() {
+ // The descriptor REST server implementations compiled against an
earlier paimon-api
+ // invoke. A source level new ErrorResponse(a, b, c, 404) would still
compile if it were
+ // deleted, because javac boxes into the Integer overload, so assert
it reflectively.
+ assertThatCode(() ->
ErrorResponse.class.getConstructor(PRIMITIVE_CODE_CTOR))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ public void testExactlyOneJsonCreatorAndItAcceptsNullableCode() throws
Exception {
+ List<Constructor<?>> creators =
+ Arrays.stream(ErrorResponse.class.getDeclaredConstructors())
+ .filter(c -> c.isAnnotationPresent(JsonCreator.class))
+ .collect(Collectors.toList());
+
+ assertThat(creators).hasSize(1);
+ assertThat(creators.get(0).getParameterTypes())
+ .containsExactly(String.class, String.class, String.class,
Integer.class);
+ // the primitive overload must stay invisible to Jackson, otherwise an
absent code
+ // deserializes to 0 again
+ assertThat(
+ ErrorResponse.class
+ .getConstructor(PRIMITIVE_CODE_CTOR)
+ .isAnnotationPresent(JsonCreator.class))
+ .isFalse();
+ }
+
+ @Test
+ public void testCodeIsAbsentOnTheWireRatherThanZero() throws Exception {
+ assertThat(RESTApi.fromJson("{\"message\":\"x\"}",
ErrorResponse.class).getCode()).isNull();
+ assertThat(
+ RESTApi.fromJson("{\"message\":\"x\",\"code\":null}",
ErrorResponse.class)
+ .getCode())
+ .isNull();
+ assertThat(
+ RESTApi.fromJson("{\"message\":\"x\",\"code\":404}",
ErrorResponse.class)
+ .getCode())
+ .isEqualTo(404);
+ }
+
+ @Test
+ public void testBothConstructorsAgree() throws Exception {
+ assertThat(new ErrorResponse("TABLE", "t", "m",
404).getCode()).isEqualTo(404);
+ assertThat(new ErrorResponse("TABLE", "t", "m", (Integer)
null).getCode()).isNull();
+ assertThat(RESTApi.toJson(new ErrorResponse(null, null, "m", (Integer)
null)))
+ .contains("\"code\":null");
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/DefaultErrorHandlerTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/DefaultErrorHandlerTest.java
index 9f8c690466..3cdfe7c938 100644
---
a/paimon-core/src/test/java/org/apache/paimon/rest/DefaultErrorHandlerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/rest/DefaultErrorHandlerTest.java
@@ -103,6 +103,20 @@ public class DefaultErrorHandlerTest {
}
}
+ @Test
+ public void testNullCodeDoesNotNpeAndFallsThrough() {
+ // the code is optional in the error schema, so an omitted one reaches
the handler as
+ // null and must not unbox
+ RESTException exception =
+ assertThrows(
+ RESTException.class,
+ () ->
+ defaultErrorHandler.accept(
+ new ErrorResponse(null, null,
"message", (Integer) null),
+ DEFAULT_REQUEST_ID));
+ assertTrue(exception.getMessage().contains("message"));
+ }
+
private ErrorResponse generateErrorResponse(int code) {
return new ErrorResponse(null, null, "message", code);
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/HttpClientTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/HttpClientTest.java
index 5bdc553fdf..9aab6e5043 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/HttpClientTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/HttpClientTest.java
@@ -23,6 +23,8 @@ import org.apache.paimon.rest.auth.BearTokenAuthProvider;
import org.apache.paimon.rest.auth.RESTAuthFunction;
import org.apache.paimon.rest.auth.RESTAuthParameter;
import org.apache.paimon.rest.exceptions.BadRequestException;
+import org.apache.paimon.rest.exceptions.ForbiddenException;
+import org.apache.paimon.rest.exceptions.NoSuchResourceException;
import org.apache.paimon.rest.exceptions.RESTException;
import org.apache.paimon.rest.responses.ErrorResponse;
@@ -45,6 +47,7 @@ import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Test for {@link HttpClient}. */
@@ -255,6 +258,23 @@ public class HttpClientTest {
assertEquals(restAuthParameter.parameters().get(queryKey),
queryParameters.get(queryKey));
}
+ @Test
+ public void testErrorCodeFallsBackToHttpStatus() throws Exception {
+ // "code" is optional in the error schema, so an error body may omit
it. The HTTP status
+ // has to be used then, otherwise a 404 no longer maps to
NoSuchResourceException.
+ assertNull(RESTApi.fromJson("{\"message\":\"x\"}",
ErrorResponse.class).getCode());
+ server.enqueueResponse("{\"message\":\"Table t does not exist\"}",
404);
+ assertThrows(
+ NoSuchResourceException.class,
+ () -> httpClient.get(MOCK_PATH, MockRESTData.class,
restAuthFunction));
+
+ // classification follows the status, so a different one maps
differently
+ server.enqueueResponse("{\"message\":\"denied\"}", 403);
+ assertThrows(
+ ForbiddenException.class,
+ () -> httpClient.get(MOCK_PATH, MockRESTData.class,
restAuthFunction));
+ }
+
private Map<String, String> getParameters(String path) {
String[] paths = path.split("\\?");
if (paths.length == 1) {
@@ -293,6 +313,10 @@ public class HttpClientTest {
Assertions.assertTrue(
e.getMessage().contains("Empty error message"),
"Parsed-but-empty message must not be labelled
unparseable");
+ Assertions.assertTrue(
+ e.getMessage().contains("403"),
+ "The HTTP status must be reported, not the absent body
code: "
+ + e.getMessage());
}
}