diff --git a/common/src/main/java/org/apache/gravitino/dto/responses/ErrorResponse.java b/common/src/main/java/org/apache/gravitino/dto/responses/ErrorResponse.java index df5afc81a45..f537ccf6eea 100644 --- a/common/src/main/java/org/apache/gravitino/dto/responses/ErrorResponse.java +++ b/common/src/main/java/org/apache/gravitino/dto/responses/ErrorResponse.java @@ -203,11 +203,21 @@ public static ErrorResponse internalError(String message) { * @return The new instance. */ public static ErrorResponse internalError(String message, Throwable throwable) { + return internalError(RuntimeException.class.getSimpleName(), message, throwable); + } + + /** + * Creates an internal error response with an explicit error type. + * + * @param type The type of the error. + * @param message The message of the error. + * @param throwable The throwable that caused the error, if available. + * @return The new error response. + */ + public static ErrorResponse internalError( + String type, String message, @Nullable Throwable throwable) { return new ErrorResponse( - ErrorConstants.INTERNAL_ERROR_CODE, - RuntimeException.class.getSimpleName(), - message, - getStackTrace(throwable)); + ErrorConstants.INTERNAL_ERROR_CODE, type, message, getStackTrace(throwable)); } /** diff --git a/common/src/test/java/org/apache/gravitino/json/TestResponseJsonSerDe.java b/common/src/test/java/org/apache/gravitino/json/TestResponseJsonSerDe.java index 9729fbf3d64..1bbf5baea68 100644 --- a/common/src/test/java/org/apache/gravitino/json/TestResponseJsonSerDe.java +++ b/common/src/test/java/org/apache/gravitino/json/TestResponseJsonSerDe.java @@ -35,6 +35,29 @@ public class TestResponseJsonSerDe { + /** + * Checks that custom internal error types and causes survive JSON serialization. + * + * @throws JsonProcessingException If serialization fails. + */ + @Test + public void testInternalErrorRetainsTypeAndCause() throws JsonProcessingException { + Error error = new NoClassDefFoundError("catalog class"); + error.initCause(new ClassNotFoundException("missing dependency")); + ErrorResponse response = + ErrorResponse.internalError("NoClassDefFoundError", "Server error", error); + String json = JsonUtils.objectMapper().writeValueAsString(response); + ErrorResponse restored = JsonUtils.objectMapper().readValue(json, ErrorResponse.class); + Assertions.assertEquals(response, restored); + Assertions.assertEquals("NoClassDefFoundError", restored.getType()); + Assertions.assertTrue( + String.join("\n", restored.getStack()) + .contains("Caused by: java.lang.ClassNotFoundException: missing dependency")); + Assertions.assertEquals( + "RuntimeException", ErrorResponse.internalError("existing behavior", error).getType()); + Assertions.assertNull(ErrorResponse.internalError("Error", "No stack", null).getStack()); + } + @Test public void testBaseResponseSerDe() throws JsonProcessingException { BaseResponse response = new BaseResponse(); diff --git a/core/src/main/java/org/apache/gravitino/utils/PrincipalUtils.java b/core/src/main/java/org/apache/gravitino/utils/PrincipalUtils.java index bd3210c5e7f..a324964f11c 100644 --- a/core/src/main/java/org/apache/gravitino/utils/PrincipalUtils.java +++ b/core/src/main/java/org/apache/gravitino/utils/PrincipalUtils.java @@ -52,12 +52,13 @@ public static T doAs(Principal principal, PrivilegedExceptionAction actio subject.getPrincipals().add(principal); return Subject.doAs(subject, action); } catch (PrivilegedActionException pae) { + LOG.error("doAs method encountered an exception", pae); Throwable cause = pae.getCause(); Throwables.propagateIfPossible(cause, Exception.class); throw new RuntimeException("doAs method encountered an unexpected exception", pae); - } catch (Error t) { - LOG.warn("doAs method encountered an unexpected error", t); - throw new RuntimeException("doAs method encountered an unexpected exception", t); + } catch (Error error) { + LOG.error("doAs method encountered an unexpected error", error); + throw error; } } diff --git a/core/src/test/java/org/apache/gravitino/utils/TestPrincipalUtils.java b/core/src/test/java/org/apache/gravitino/utils/TestPrincipalUtils.java index a2a285737d5..d05d51842b2 100644 --- a/core/src/test/java/org/apache/gravitino/utils/TestPrincipalUtils.java +++ b/core/src/test/java/org/apache/gravitino/utils/TestPrincipalUtils.java @@ -19,9 +19,20 @@ package org.apache.gravitino.utils; +import java.security.PrivilegedActionException; +import java.util.ArrayList; +import java.util.List; import org.apache.gravitino.UserPrincipal; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.config.LoggerConfig; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; public class TestPrincipalUtils { @@ -52,4 +63,117 @@ public void testThread() throws Exception { return null; }); } + + @Test + public void testErrorIsPropagated() { + UserPrincipal principal = new UserPrincipal("testErrorIsPropagated"); + AssertionError error = new AssertionError("test error"); + + AssertionError thrown = + Assertions.assertThrows( + AssertionError.class, + () -> + PrincipalUtils.doAs( + principal, + () -> { + throw error; + })); + + Assertions.assertSame(error, thrown); + } + + /** Checks that checked exceptions retain their identity and cause. */ + @Test + public void testCheckedExceptionIsPropagated() { + Exception cause = new Exception("root cause"); + Exception exception = new Exception("checked failure", cause); + Exception thrown = + Assertions.assertThrows( + Exception.class, + () -> + PrincipalUtils.doAs( + new UserPrincipal("test"), + () -> { + throw exception; + })); + Assertions.assertSame(exception, thrown); + Assertions.assertSame(cause, thrown.getCause()); + } + + /** Checks that runtime exceptions retain their identity and cause. */ + @Test + public void testRuntimeExceptionIsPropagated() { + Exception cause = new Exception("root cause"); + RuntimeException exception = new IllegalArgumentException("invalid argument", cause); + RuntimeException thrown = + Assertions.assertThrows( + RuntimeException.class, + () -> + PrincipalUtils.doAs( + new UserPrincipal("test"), + () -> { + throw exception; + })); + Assertions.assertSame(exception, thrown); + Assertions.assertSame(cause, thrown.getCause()); + } + + /** Checks that caught failures are logged at ERROR with the throwable. */ + @Test + public void testFailuresAreLoggedWithThrowable() { + LoggerContext context = + (LoggerContext) LogManager.getContext(PrincipalUtils.class.getClassLoader(), false); + Configuration configuration = context.getConfiguration(); + String loggerName = PrincipalUtils.class.getName(); + LoggerConfig previousConfig = configuration.getLoggers().get(loggerName); + Appender appender = Mockito.mock(Appender.class); + Mockito.when(appender.getName()).thenReturn("principalUtilsCapture"); + Mockito.when(appender.isStarted()).thenReturn(true); + List events = new ArrayList<>(); + Mockito.doAnswer( + invocation -> { + events.add(((LogEvent) invocation.getArgument(0)).toImmutable()); + return null; + }) + .when(appender) + .append(Mockito.any(LogEvent.class)); + LoggerConfig loggerConfig = new LoggerConfig(loggerName, Level.ERROR, false); + loggerConfig.addAppender(appender, Level.ERROR, null); + configuration.removeLogger(loggerName); + configuration.addLogger(loggerName, loggerConfig); + context.updateLoggers(); + try { + Error error = new AssertionError("request error"); + Assertions.assertThrows( + Error.class, + () -> + PrincipalUtils.doAs( + new UserPrincipal("test"), + () -> { + throw error; + })); + Exception exception = new Exception("checked failure", new Exception("root cause")); + Assertions.assertThrows( + Exception.class, + () -> + PrincipalUtils.doAs( + new UserPrincipal("test"), + () -> { + throw exception; + })); + Assertions.assertEquals(2, events.size()); + Assertions.assertEquals(Level.ERROR, events.get(0).getLevel()); + Assertions.assertSame(error, events.get(0).getThrown()); + Assertions.assertEquals(Level.ERROR, events.get(1).getLevel()); + Throwable logged = events.get(1).getThrown(); + Assertions.assertInstanceOf(PrivilegedActionException.class, logged); + Assertions.assertSame(exception, logged.getCause()); + } finally { + configuration.removeLogger(loggerName); + if (previousConfig != null) { + configuration.addLogger(loggerName, previousConfig); + } + context.updateLoggers(); + } + } } 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 dd00b9c4b68..c69a9d6dfb3 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 @@ -50,7 +50,7 @@ // Referred from Apache Iceberg's EXCEPTION_ERROR_CODES implementation // core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java @Provider -public class IcebergExceptionMapper implements ExceptionMapper { +public class IcebergExceptionMapper implements ExceptionMapper { private static final Logger LOG = LoggerFactory.getLogger(IcebergExceptionMapper.class); @@ -121,8 +121,14 @@ public static Exception convertToIcebergException(Exception e) { return new ServiceFailureException("%s", message); } + /** + * Maps an uncaught throwable to an Iceberg REST error response. + * + * @param ex the failure raised while processing the request + * @return the error response, defaulting to HTTP 500 for unmapped failures + */ @Override - public Response toResponse(Exception ex) { + public Response toResponse(Throwable ex) { return toRESTResponse(ex); } @@ -131,7 +137,7 @@ public static Response toRESTResponse(Throwable ex) { EXCEPTION_ERROR_CODES.getOrDefault( ex.getClass(), Status.INTERNAL_SERVER_ERROR.getStatusCode()); if (status == Status.INTERNAL_SERVER_ERROR.getStatusCode()) { - LOG.warn("Iceberg REST server unexpected exception:", ex); + LOG.error("Iceberg REST server unexpected failure:", ex); } else { LOG.info( "Iceberg REST server error maybe caused by user request, response http status: {}, exception: {}, exception message: {}", diff --git a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergErrorHandling.java b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergErrorHandling.java new file mode 100644 index 00000000000..4c8ab70eb76 --- /dev/null +++ b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergErrorHandling.java @@ -0,0 +1,122 @@ +/* + * 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 java.io.IOException; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Application; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.apache.gravitino.UserPrincipal; +import org.apache.gravitino.rest.RESTUtils; +import org.apache.gravitino.utils.PrincipalUtils; +import org.apache.iceberg.rest.responses.ErrorResponse; +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.test.JerseyTest; +import org.glassfish.jersey.test.TestProperties; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests Iceberg HTTP responses for errors raised on the request path. */ +public class TestIcebergErrorHandling extends JerseyTest { + + /** Simulates direct failures and failures inside the REST doAs boundary. */ + @Path("failure") + public static class FailureResource { + + /** + * Executes a request with an optional failure. + * + * @param mode whether to fail directly, within doAs, or return a successful response + * @return the request response + */ + @GET + @Path("{mode}") + public Response request(@PathParam("mode") String mode) { + if ("healthy".equals(mode)) { + return Response.ok().build(); + } + Error failure = new NoClassDefFoundError("catalog class"); + failure.initCause(new ClassNotFoundException("missing dependency")); + if ("direct".equals(mode)) { + throw failure; + } + try { + return PrincipalUtils.doAs( + new UserPrincipal("test"), + () -> { + throw failure; + }); + } catch (Exception e) { + return IcebergExceptionMapper.toRESTResponse(e); + } + } + } + + /** + * Registers the same error and JSON mappers as the Iceberg REST service. + * + * @return the test application + */ + @Override + protected Application configure() { + try { + forceSet( + TestProperties.CONTAINER_PORT, String.valueOf(RESTUtils.findAvailablePort(2000, 3000))); + } catch (IOException e) { + throw new RuntimeException(e); + } + return new ResourceConfig() + .register(FailureResource.class) + .register(IcebergExceptionMapper.class) + .register(IcebergObjectMapperProvider.class) + .register(JacksonFeature.class); + } + + /** + * Verifies diagnostics survive both request paths and later requests can still succeed. + * + * @throws IOException if the JSON response cannot be parsed + */ + @Test + public void testErrorResponsesAndSubsequentRequests() throws IOException { + for (String mode : new String[] {"direct", "do-as"}) { + try (Response response = + target("failure/" + mode).request(MediaType.APPLICATION_JSON_TYPE).get()) { + Assertions.assertEquals(500, response.getStatus()); + Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType()); + ErrorResponse error = + IcebergObjectMapper.getInstance() + .readValue(response.readEntity(String.class), ErrorResponse.class); + Assertions.assertEquals(500, error.code()); + Assertions.assertEquals("NoClassDefFoundError", error.type()); + Assertions.assertEquals("catalog class", error.message()); + Assertions.assertTrue( + String.join("\n", error.stack()) + .contains("Caused by: java.lang.ClassNotFoundException: missing dependency")); + } + try (Response response = target("failure/healthy").request().get()) { + Assertions.assertEquals(200, response.getStatus()); + } + } + } +} diff --git a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java index eef610f6b05..354f4d26bde 100644 --- a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java +++ b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java @@ -33,17 +33,13 @@ import org.apache.iceberg.exceptions.ServiceUnavailableException; import org.apache.iceberg.exceptions.UnprocessableEntityException; import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.rest.responses.ErrorResponse; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; public class TestIcebergExceptionMapper { private final IcebergExceptionMapper icebergExceptionMapper = new IcebergExceptionMapper(); - private void checkExceptionStatus(Exception exception, int statusCode) { - Response response = icebergExceptionMapper.toResponse(exception); - Assertions.assertEquals(statusCode, response.getStatus()); - } - @Test public void testIcebergExceptionMapper() { checkExceptionStatus(new IllegalArgumentException(""), 400); @@ -65,4 +61,30 @@ public void testIcebergExceptionMapper() { checkExceptionStatus(new ServiceUnavailableException(""), 503); checkExceptionStatus(new RuntimeException(), 500); } + + /** Checks that errors retain their type and nested causes in the response. */ + @Test + public void testErrorsRetainTypeAndCause() { + for (Error error : + new Error[] { + new OutOfMemoryError("Metaspace"), new StackOverflowError(), + new NoClassDefFoundError("catalog class"), new AssertionError("assertion") + }) { + error.initCause(new IllegalStateException("root cause")); + try (Response response = icebergExceptionMapper.toResponse(error)) { + Assertions.assertEquals(500, response.getStatus()); + ErrorResponse entity = (ErrorResponse) response.getEntity(); + Assertions.assertEquals(error.getClass().getSimpleName(), entity.type()); + Assertions.assertEquals(error.getMessage(), entity.message()); + Assertions.assertTrue( + String.join("\n", entity.stack()) + .contains("Caused by: java.lang.IllegalStateException: root cause")); + } + } + } + + private void checkExceptionStatus(Exception exception, int statusCode) { + Response response = icebergExceptionMapper.toResponse(exception); + Assertions.assertEquals(statusCode, response.getStatus()); + } } diff --git a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java index 8a45ce49392..6d0777a477d 100644 --- a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java +++ b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java @@ -34,6 +34,7 @@ import org.apache.gravitino.lance.common.config.LanceConfig; import org.apache.gravitino.lance.common.ops.LanceNamespaceBackend; import org.apache.gravitino.lance.common.ops.NamespaceWrapper; +import org.apache.gravitino.lance.service.LanceExceptionMapper; import org.apache.gravitino.lance.service.LanceHealthCheckPathMatcher; import org.apache.gravitino.lance.service.LanceServiceIdentityFilter; import org.apache.gravitino.lance.service.authorization.LanceAuthorizationMetadataFilter; @@ -108,6 +109,7 @@ public void serviceInit(Map properties, boolean auxMode) { ResourceConfig resourceConfig = new ResourceConfig(); resourceConfig.register(JacksonFeature.class); resourceConfig.packages(LANCE_REST_SPEC_PACKAGE); + resourceConfig.register(LanceExceptionMapper.class); resourceConfig.register( new AbstractBinder() { @Override diff --git a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java index 2078b75fe6f..47d0b7521a1 100644 --- a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java +++ b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java @@ -42,11 +42,11 @@ import org.slf4j.LoggerFactory; @Provider -public class LanceExceptionMapper implements ExceptionMapper { +public class LanceExceptionMapper implements ExceptionMapper { private static final Logger LOG = LoggerFactory.getLogger(LanceExceptionMapper.class); - public static Response toRESTResponse(String instance, Exception ex) { + public static Response toRESTResponse(String instance, Throwable ex) { LanceNamespaceException lanceException = ex instanceof LanceNamespaceException ? (LanceNamespaceException) ex @@ -61,11 +61,11 @@ public static Response toRESTResponse(String instance, Exception ex) { } @Override - public Response toResponse(Exception ex) { + public Response toResponse(Throwable ex) { return toRESTResponse("", ex); } - private static LanceNamespaceException toLanceNamespaceException(String instance, Exception ex) { + private static LanceNamespaceException toLanceNamespaceException(String instance, Throwable ex) { if (ex instanceof NoSuchTableException) { return new TableNotFoundException(ex.getMessage(), getStackTrace(ex), instance); diff --git a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java new file mode 100644 index 00000000000..fd3d98ded61 --- /dev/null +++ b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.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.gravitino.lance.service; + +import java.io.IOException; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Application; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.apache.gravitino.rest.RESTUtils; +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.test.JerseyTest; +import org.glassfish.jersey.test.TestProperties; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.lance.namespace.model.ErrorResponse; + +/** Tests for {@link LanceExceptionMapper}. */ +public class TestLanceExceptionMapper extends JerseyTest { + + /** A resource that raises an error outside the operation-level exception handlers. */ + @Path("error") + public static class ErrorResource { + + /** + * Raises an assertion error. + * + * @return never returns normally + */ + @GET + public String fail() { + AssertionError error = new AssertionError("assertion failure"); + error.initCause(new IllegalStateException("root cause")); + throw error; + } + } + + /** + * Configures the test resource and Lance exception mapper. + * + * @return the test application + */ + @Override + protected Application configure() { + try { + forceSet( + TestProperties.CONTAINER_PORT, String.valueOf(RESTUtils.findAvailablePort(2000, 3000))); + } catch (IOException e) { + throw new RuntimeException(e); + } + return new ResourceConfig() + .register(ErrorResource.class) + .register(LanceExceptionMapper.class) + .register(JacksonFeature.class); + } + + /** Verifies that an uncaught error is converted to a Lance internal error response. */ + @Test + public void testErrorResponse() { + try (Response response = target("error").request(MediaType.APPLICATION_JSON_TYPE).get()) { + Assertions.assertEquals( + Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); + ErrorResponse entity = response.readEntity(ErrorResponse.class); + Assertions.assertEquals("assertion failure", entity.getError()); + Assertions.assertEquals("", entity.getInstance()); + Assertions.assertTrue( + entity.getDetail().contains("java.lang.AssertionError: assertion failure")); + Assertions.assertTrue( + entity.getDetail().contains("Caused by: java.lang.IllegalStateException: root cause")); + } + } +} diff --git a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java index 628da89fd2f..30dfd34c189 100644 --- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java +++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java @@ -64,6 +64,7 @@ import org.apache.gravitino.server.web.VersioningFilter; import org.apache.gravitino.server.web.filter.AccessControlNotAllowedFilter; import org.apache.gravitino.server.web.filter.GravitinoInterceptionService; +import org.apache.gravitino.server.web.mapper.ErrorExceptionMapper; import org.apache.gravitino.server.web.mapper.JsonMappingExceptionMapper; import org.apache.gravitino.server.web.mapper.JsonParseExceptionMapper; import org.apache.gravitino.server.web.mapper.JsonProcessingExceptionMapper; @@ -186,6 +187,7 @@ protected void configure() { } }); register(JsonProcessingExceptionMapper.class); + register(ErrorExceptionMapper.class); register(JsonParseExceptionMapper.class); register(JsonMappingExceptionMapper.class); register(ParamExceptionMapper.class); diff --git a/server/src/main/java/org/apache/gravitino/server/web/mapper/ErrorExceptionMapper.java b/server/src/main/java/org/apache/gravitino/server/web/mapper/ErrorExceptionMapper.java new file mode 100644 index 00000000000..892cc8f6996 --- /dev/null +++ b/server/src/main/java/org/apache/gravitino/server/web/mapper/ErrorExceptionMapper.java @@ -0,0 +1,47 @@ +/* + * 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.server.web.mapper; + +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import org.apache.gravitino.dto.responses.ErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Reports errors on the request path as server errors without deciding process lifetime. */ +public class ErrorExceptionMapper implements ExceptionMapper { + private static final Logger LOG = LoggerFactory.getLogger(ErrorExceptionMapper.class); + + /** + * Returns a server error response retaining the original error type and complete stack trace. + * + * @param error The error raised while processing the request. + * @return The internal server error response. + */ + @Override + public Response toResponse(Error error) { + String message = "Server error while processing request: " + error; + LOG.error(message, error); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(ErrorResponse.internalError(error.getClass().getSimpleName(), message, error)) + .type(MediaType.APPLICATION_JSON_TYPE) + .build(); + } +} diff --git a/server/src/test/java/org/apache/gravitino/server/web/mapper/TestErrorExceptionMapper.java b/server/src/test/java/org/apache/gravitino/server/web/mapper/TestErrorExceptionMapper.java new file mode 100644 index 00000000000..78d264db46b --- /dev/null +++ b/server/src/test/java/org/apache/gravitino/server/web/mapper/TestErrorExceptionMapper.java @@ -0,0 +1,52 @@ +/* + * 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.server.web.mapper; + +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.apache.gravitino.dto.responses.ErrorResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Tests server error responses for errors raised on the request path. */ +public class TestErrorExceptionMapper { + /** Checks that error types and nested causes survive response construction. */ + @Test + public void testErrorsRetainTypeAndCause() { + for (Error error : + new Error[] { + new OutOfMemoryError("Metaspace"), new StackOverflowError(), + new NoClassDefFoundError("catalog class"), new AssertionError("assertion") + }) { + error.initCause(new IllegalStateException("root cause")); + try (Response response = new ErrorExceptionMapper().toResponse(error)) { + Assertions.assertEquals(500, response.getStatus()); + Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType()); + ErrorResponse entity = (ErrorResponse) response.getEntity(); + Assertions.assertEquals(error.getClass().getSimpleName(), entity.getType()); + Assertions.assertEquals( + "Server error while processing request: " + error, entity.getMessage()); + String stack = String.join("\n", entity.getStack()); + Assertions.assertTrue(stack.contains(error.toString())); + Assertions.assertTrue( + stack.contains("Caused by: java.lang.IllegalStateException: root cause")); + } + } + } +} diff --git a/server/src/test/java/org/apache/gravitino/server/web/rest/TestFilesetOperations.java b/server/src/test/java/org/apache/gravitino/server/web/rest/TestFilesetOperations.java index 8d286f24209..1887bfe6959 100644 --- a/server/src/test/java/org/apache/gravitino/server/web/rest/TestFilesetOperations.java +++ b/server/src/test/java/org/apache/gravitino/server/web/rest/TestFilesetOperations.java @@ -70,6 +70,7 @@ import org.apache.gravitino.file.FilesetChange; import org.apache.gravitino.lock.LockManager; import org.apache.gravitino.rest.RESTUtils; +import org.apache.gravitino.server.web.mapper.ErrorExceptionMapper; import org.glassfish.jersey.internal.inject.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.test.TestProperties; @@ -121,6 +122,7 @@ protected Application configure() { ResourceConfig resourceConfig = new ResourceConfig(); resourceConfig.register(FilesetOperations.class); + resourceConfig.register(ErrorExceptionMapper.class); resourceConfig.register( new AbstractBinder() { @Override @@ -392,20 +394,42 @@ public void testCreateFileset() { Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, errorResp3.getCode()); Assertions.assertEquals(RuntimeException.class.getSimpleName(), errorResp3.getType()); - // Test throw Error - doThrow(new Error("mock error")) + // A request error must retain its diagnostics without becoming an operation failure. + Error error = new NoClassDefFoundError("mock catalog class"); + error.initCause(new ClassNotFoundException("missing catalog dependency")); + Mockito.doThrow(error) + .doReturn(fileset) .when(dispatcher) .createMultipleLocationFileset(any(), any(), any(), any(), any(), any(), any()); - Response resp4 = + try (Response errorResponse = target(filesetPath(metalake, catalog, schema)) .request(MediaType.APPLICATION_JSON_TYPE) .accept("application/vnd.gravitino.v1+json") - .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE)); - Assertions.assertEquals( - Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp4.getStatus()); - ErrorResponse errorResp4 = resp4.readEntity(ErrorResponse.class); - Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, errorResp4.getCode()); - Assertions.assertEquals(RuntimeException.class.getSimpleName(), errorResp4.getType()); + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE))) { + Assertions.assertEquals(500, errorResponse.getStatus()); + Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, errorResponse.getMediaType()); + ErrorResponse entity = errorResponse.readEntity(ErrorResponse.class); + Assertions.assertEquals(ErrorConstants.INTERNAL_ERROR_CODE, entity.getCode()); + Assertions.assertEquals("NoClassDefFoundError", entity.getType()); + Assertions.assertEquals( + "Server error while processing request: java.lang.NoClassDefFoundError: mock catalog class", + entity.getMessage()); + String stack = String.join("\n", entity.getStack()); + Assertions.assertTrue(stack.contains("java.lang.NoClassDefFoundError: mock catalog class")); + Assertions.assertTrue( + stack.contains( + "Caused by: java.lang.ClassNotFoundException: missing catalog dependency")); + } + + try (Response recoveredResponse = + target(filesetPath(metalake, catalog, schema)) + .request(MediaType.APPLICATION_JSON_TYPE) + .accept("application/vnd.gravitino.v1+json") + .post(Entity.entity(req, MediaType.APPLICATION_JSON_TYPE))) { + Assertions.assertEquals(200, recoveredResponse.getStatus()); + Assertions.assertEquals( + "fileset1", recoveredResponse.readEntity(FilesetResponse.class).getFileset().name()); + } } @Test