This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch branch-1.2
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.2 by this push:
new ac071bf44e [Cherry-pick to branch-1.2] [#10667] fix(iceberg): Return
JSON error body instead of HTML for pre-JAX-RS errors (#10668) (#10679)
ac071bf44e is described below
commit ac071bf44e18648c1e3e957112f6c045010f2dae
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Apr 6 13:54:03 2026 +0800
[Cherry-pick to branch-1.2] [#10667] fix(iceberg): Return JSON error body
instead of HTML for pre-JAX-RS errors (#10668) (#10679)
**Cherry-pick Information:**
- Original commit: 922009a3f21f60d51f173d62068ab8238d4d806c
- Target branch: `branch-1.2`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: akshay thorat <[email protected]>
---
.../org/apache/gravitino/iceberg/RESTService.java | 9 +-
.../service/IcebergAuthenticationFilter.java | 61 ++++++++++
.../iceberg/service/IcebergExceptionMapper.java | 42 +++++++
.../iceberg/service/IcebergRESTUtils.java | 12 ++
.../service/TestIcebergAuthenticationFilter.java | 123 +++++++++++++++++++++
.../authentication/AuthenticationFilter.java | 23 +++-
.../apache/gravitino/server/web/JettyServer.java | 12 +-
7 files changed, 277 insertions(+), 5 deletions(-)
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
index 23f1fcf042..f4bcfedd8b 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
@@ -27,6 +27,7 @@ import org.apache.gravitino.Configs;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.auxiliary.GravitinoAuxiliaryService;
import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.iceberg.service.IcebergAuthenticationFilter;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
import org.apache.gravitino.iceberg.service.IcebergObjectMapperProvider;
@@ -79,7 +80,13 @@ public class RESTService implements
GravitinoAuxiliaryService {
private void initServer(IcebergConfig icebergConfig) {
JettyServerConfig serverConfig =
JettyServerConfig.fromConfig(icebergConfig);
- server = new JettyServer();
+ server =
+ new JettyServer() {
+ @Override
+ protected javax.servlet.Filter createAuthenticationFilter() {
+ return new IcebergAuthenticationFilter();
+ }
+ };
MetricsSystem metricsSystem = GravitinoEnv.getInstance().metricsSystem();
server.initialize(serverConfig, SERVICE_NAME, false /* shouldEnableUI */);
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergAuthenticationFilter.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergAuthenticationFilter.java
new file mode 100644
index 0000000000..20209b056e
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergAuthenticationFilter.java
@@ -0,0 +1,61 @@
+/*
+ * 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.gravitino.iceberg.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.server.authentication.AuthenticationFilter;
+import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.eclipse.jetty.http.HttpStatus;
+
+/**
+ * An {@link AuthenticationFilter} subclass for the Iceberg REST server that
produces JSON error
+ * responses conforming to the Iceberg REST API specification.
+ *
+ * <p>When authentication fails, the default {@link AuthenticationFilter}
calls {@code
+ * resp.sendError()} which produces HTML error pages via Jetty's default error
handler. This
+ * subclass overrides the error response to write a proper Iceberg {@link
ErrorResponse} JSON body,
+ * which Iceberg REST clients (e.g., the Java {@code RESTCatalog}) expect.
+ */
+public class IcebergAuthenticationFilter extends AuthenticationFilter {
+
+ private static final ObjectMapper MAPPER = IcebergObjectMapper.getInstance();
+
+ @Override
+ protected void sendAuthErrorResponse(HttpServletResponse response, Exception
exception)
+ throws IOException {
+ Exception icebergException =
IcebergExceptionMapper.convertToIcebergException(exception);
+ int status = IcebergExceptionMapper.getErrorCode(icebergException);
+ String message = icebergException.getMessage();
+ if (StringUtils.isBlank(message)) {
+ message = HttpStatus.getMessage(status);
+ }
+
+ String type = icebergException.getClass().getSimpleName();
+ ErrorResponse errorResponse = IcebergRESTUtils.errorResponse(status, type,
message);
+
+ response.setStatus(status);
+ response.setContentType("application/json");
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ MAPPER.writeValue(response.getWriter(), errorResponse);
+ }
+}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
index c1a443b24e..ecc16caca9 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
@@ -26,7 +26,9 @@ import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import org.apache.gravitino.exceptions.IllegalNameIdentifierException;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
+import org.apache.gravitino.exceptions.UnauthorizedException;
import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.exceptions.BadRequestException;
import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.exceptions.CommitStateUnknownException;
import org.apache.iceberg.exceptions.ForbiddenException;
@@ -36,6 +38,7 @@ import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.exceptions.NoSuchViewException;
import org.apache.iceberg.exceptions.NotAuthorizedException;
+import org.apache.iceberg.exceptions.ServiceFailureException;
import org.apache.iceberg.exceptions.ServiceUnavailableException;
import org.apache.iceberg.exceptions.UnprocessableEntityException;
import org.apache.iceberg.exceptions.ValidationException;
@@ -56,6 +59,7 @@ public class IcebergExceptionMapper implements
ExceptionMapper<Exception> {
.put(IllegalNameIdentifierException.class, 400)
.put(NamespaceNotEmptyException.class, 400)
.put(NotAuthorizedException.class, 401)
+ .put(UnauthorizedException.class, 401)
.put(org.apache.gravitino.exceptions.ForbiddenException.class, 403)
.put(ForbiddenException.class, 403)
.put(NoSuchNamespaceException.class, 404)
@@ -68,9 +72,47 @@ public class IcebergExceptionMapper implements
ExceptionMapper<Exception> {
.put(CommitFailedException.class, 409)
.put(UnprocessableEntityException.class, 422)
.put(CommitStateUnknownException.class, 500)
+ .put(ServiceFailureException.class, 500)
.put(ServiceUnavailableException.class, 503)
.build();
+ /**
+ * Returns the HTTP status code for the given exception based on the Iceberg
REST spec.
+ *
+ * @param ex the exception
+ * @return the HTTP status code, defaulting to 500 for unmapped exceptions
+ */
+ public static int getErrorCode(Exception ex) {
+ return EXCEPTION_ERROR_CODES.getOrDefault(
+ ex.getClass(), Status.INTERNAL_SERVER_ERROR.getStatusCode());
+ }
+
+ /**
+ * Converts a Gravitino or generic exception to the corresponding Iceberg
REST spec exception.
+ *
+ * @param e the original exception
+ * @return the equivalent Iceberg exception, or the original if already an
Iceberg exception
+ */
+ public static Exception convertToIcebergException(Exception e) {
+ String message = e.getMessage() != null ? e.getMessage() : "";
+ if (e instanceof UnauthorizedException) {
+ return new NotAuthorizedException("%s", message);
+ }
+ if (e instanceof org.apache.gravitino.exceptions.ForbiddenException) {
+ return new ForbiddenException("%s", message);
+ }
+ if (e instanceof IllegalArgumentException
+ || e instanceof IllegalNameIdentifierException
+ || e instanceof ValidationException
+ || e instanceof NamespaceNotEmptyException) {
+ return new BadRequestException("%s", message);
+ }
+ if (EXCEPTION_ERROR_CODES.containsKey(e.getClass())) {
+ return e;
+ }
+ return new ServiceFailureException("%s", message);
+ }
+
@Override
public Response toResponse(Exception ex) {
return toRESTResponse(ex);
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
index 84ea56cc37..78ffd7e801 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergRESTUtils.java
@@ -74,6 +74,18 @@ public class IcebergRESTUtils {
.build();
}
+ /**
+ * Build an Iceberg {@link ErrorResponse} for a given HTTP status code and
message, without an
+ * exception. Used by the Jetty error handler for pre-JAX-RS errors.
+ */
+ public static ErrorResponse errorResponse(int httpStatus, String type,
String message) {
+ return ErrorResponse.builder()
+ .responseCode(httpStatus)
+ .withType(type)
+ .withMessage(message)
+ .build();
+ }
+
public static Instant calculateNewTimestamp(Instant currentTimestamp, int
hours) {
LocalDateTime currentDateTime =
LocalDateTime.ofInstant(currentTimestamp, ZoneId.systemDefault());
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
new file mode 100644
index 0000000000..404ba94827
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergAuthenticationFilter.java
@@ -0,0 +1,123 @@
+/*
+ * 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.gravitino.iceberg.service;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.gravitino.exceptions.UnauthorizedException;
+import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestIcebergAuthenticationFilter {
+
+ private static final ObjectMapper MAPPER = IcebergObjectMapper.getInstance();
+
+ @Test
+ public void testUnauthorizedErrorReturnsJson() throws Exception {
+ IcebergAuthenticationFilter filter = new IcebergAuthenticationFilter();
+
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+
+ filter.sendAuthErrorResponse(
+ response, new UnauthorizedException("The provided credentials did not
support"));
+
+ verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(response).setContentType("application/json");
+ verify(response).setCharacterEncoding("UTF-8");
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ErrorResponse errorResponse = MAPPER.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(401, errorResponse.code());
+ Assertions.assertEquals("NotAuthorizedException", errorResponse.type());
+ Assertions.assertEquals("The provided credentials did not support",
errorResponse.message());
+ }
+
+ @Test
+ public void testInternalServerErrorReturnsJson() throws Exception {
+ IcebergAuthenticationFilter filter = new IcebergAuthenticationFilter();
+
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+
+ filter.sendAuthErrorResponse(response, new RuntimeException("Something
went wrong"));
+
+ verify(response).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ErrorResponse errorResponse = MAPPER.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(500, errorResponse.code());
+ Assertions.assertEquals("ServiceFailureException", errorResponse.type());
+ Assertions.assertEquals("Something went wrong", errorResponse.message());
+ }
+
+ @Test
+ public void testForbiddenExceptionReturnsJson() throws Exception {
+ IcebergAuthenticationFilter filter = new IcebergAuthenticationFilter();
+
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+
+ filter.sendAuthErrorResponse(
+ response, new
org.apache.gravitino.exceptions.ForbiddenException("Access denied"));
+
+ verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ErrorResponse errorResponse = MAPPER.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(403, errorResponse.code());
+ Assertions.assertEquals("ForbiddenException", errorResponse.type());
+ Assertions.assertEquals("Access denied", errorResponse.message());
+ }
+
+ @Test
+ public void testNullMessageUsesDefaultStatusMessage() throws Exception {
+ IcebergAuthenticationFilter filter = new IcebergAuthenticationFilter();
+
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+
+ filter.sendAuthErrorResponse(response, new RuntimeException((String)
null));
+
+ printWriter.flush();
+ String json = stringWriter.toString();
+ ErrorResponse errorResponse = MAPPER.readValue(json, ErrorResponse.class);
+ Assertions.assertEquals(500, errorResponse.code());
+ Assertions.assertEquals("ServiceFailureException", errorResponse.type());
+ Assertions.assertEquals("Server Error", errorResponse.message());
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
index 7df4e68d6a..ae234285cd 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
@@ -99,13 +99,32 @@ public class AuthenticationFilter implements Filter {
resp.setHeader(AuthConstants.HTTP_CHALLENGE_HEADER, challenge);
}
}
- resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, ue.getMessage());
+ sendAuthErrorResponse(resp, ue);
} catch (Exception e) {
HttpServletResponse resp = (HttpServletResponse) response;
- resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
+ sendAuthErrorResponse(resp, e);
}
}
+ /**
+ * Sends an error response when authentication fails. Subclasses can
override this to customize
+ * the error response format (e.g., Iceberg REST server returns JSON error
bodies).
+ *
+ * <p>TODO: Gravitino server should override this method to return a correct
JSON response
+ * following the Gravitino error response spec.
+ *
+ * @param response the HTTP servlet response
+ * @param exception the authentication exception
+ */
+ protected void sendAuthErrorResponse(HttpServletResponse response, Exception
exception)
+ throws IOException {
+ int status =
+ exception instanceof UnauthorizedException
+ ? HttpServletResponse.SC_UNAUTHORIZED
+ : HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+ response.sendError(status, exception.getMessage());
+ }
+
@Override
public void destroy() {}
}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
index e81b4fa9e1..1af9ed8259 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
@@ -64,7 +64,7 @@ import org.eclipse.jetty.webapp.WebAppContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public final class JettyServer {
+public class JettyServer {
private static final Logger LOG = LoggerFactory.getLogger(JettyServer.class);
@@ -470,6 +470,14 @@ public final class JettyServer {
servletContextHandler.addFilter(
CorsFilterHolder.create(serverConfig), pathSpec,
EnumSet.allOf(DispatcherType.class));
}
- addFilter(new AuthenticationFilter(), pathSpec);
+ addFilter(createAuthenticationFilter(), pathSpec);
+ }
+
+ /**
+ * Creates the authentication filter for this server. Subclasses can
override this to provide a
+ * custom authentication filter (e.g., one that returns Iceberg-spec JSON
error responses).
+ */
+ protected Filter createAuthenticationFilter() {
+ return new AuthenticationFilter();
}
}