This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new c30a0e55fa [Cherry-pick to branch-1.3] [#14419][#14425][#14426] fix:
classify Iceberg and Lance client errors (#14556) (#14624)
c30a0e55fa is described below
commit c30a0e55fad9678432550503ccb0afba251102c9
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Nov 4 22:53:41 2026 +0800
[Cherry-pick to branch-1.3] [#14419][#14425][#14426] fix: classify Iceberg
and Lance client errors (#14556) (#14624)
### What changes were proposed in this pull request?
Backport #14556 (`5f4a64e77b946d2e81bc6203371a12f04d70ab5b`) to
`branch-1.3` and resolve the conflicts in the Lance implementation,
tests, and documentation.
Preserve Iceberg rename-conflict classification, Lance request/error
envelopes and protocol headers, JSON/body validation, and ordered
column-name preflight. Adapt the preflight to 1.3's existing
drop/rename/index operations, retaining its OOM health checks and
configuration behavior.
### Why are the changes needed?
The automatic cherry-pick left conflict markers and references to newer
main-branch functionality. This resolution makes the client-error fixes
usable on 1.3.
### Does this PR introduce _any_ user-facing change?
Iceberg rename conflicts return 409. Invalid Lance column names and
request bodies return 400; optional missing-column deletes are skipped.
Unsupported Lance routes return code 0 / HTTP 406, typed entity misses
remain 404, and throttling returns 429. Expected backend client errors
use INFO logs. Server failures retain the original exception in ERROR
logs; unexpected failures and framework 5xx return a generic error body.
Details explicitly supplied by native Lance exceptions are preserved.
Lance drop/rename operations are validated as top-level changes before
mutation. Adding columns remains unsupported on 1.3. Preflight prevents
partial edits caused by invalid names, while backend failures and
concurrent external changes retain the existing non-atomic behavior.
This backport uses the existing 1.3 error-detail behavior and adds no
stack-trace configuration option.
### How was this patch tested?
- 155 filtered cases passed with no failures/errors/skips:
`TestLanceTableOperations` (51), `TestIcebergTable` (11),
`TestLanceExceptionMapper` (45), `TestLanceNamespaceOperations` (27),
`TestLanceRESTService` (1), `TestLanceAuxiliaryAuthentication` (3), and
`LanceRESTServiceIT` (17).
- The final Lance run includes embedded HTTP integration coverage for
invalid JSON/bodies, unsupported operations, and column-name errors.
Database/Spark integration matrices outside these classes were not run.
- On the unmodified 1.3 base, 9 of 11 selected column-preflight
regression cases fail; all 11 pass with the backport.
- Scoped Spotless and conflict-marker/diff checks passed. No full core
suite or unfiltered aggregate test task was run.
---------
Co-authored-by: Qi Yu <[email protected]>
---
.../lakehouse/lance/LanceTableOperations.java | 60 +++-
.../lakehouse/lance/TestLanceTableOperations.java | 237 ++++++++++++
.../iceberg/IcebergCatalogOperations.java | 7 +-
.../lakehouse/iceberg/TestIcebergTable.java | 56 +++
docs/lance-rest-service.md | 17 +
.../apache/gravitino/lance/LanceRESTService.java | 4 +-
.../lance/service/LanceExceptionMapper.java | 121 +++++--
.../service/LanceRequestBodyReaderInterceptor.java | 51 +++
.../service/rest/LanceNamespaceOperations.java | 3 +
.../lance/service/rest/LanceTableOperations.java | 13 +-
.../lance/integration/test/LanceRESTServiceIT.java | 115 +++++-
.../lance/service/TestLanceExceptionMapper.java | 397 ++++++++++++++++++++-
.../service/rest/TestLanceNamespaceOperations.java | 106 +++++-
13 files changed, 1120 insertions(+), 67 deletions(-)
diff --git
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
index 344fcefe44..30aae6f356 100644
---
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
+++
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
@@ -26,11 +26,13 @@ import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -800,13 +802,20 @@ public class LanceTableOperations extends
ManagedTableOperations {
* @return the new version id of the Lance dataset after applying the changes
*/
long handleLanceTableChange(Table table, TableChange[] changes) {
+ // Validate the whole request before any dataset mutation. The name
preflight below uses
+ // top-level fields, matching the columns Gravitino stores for Lance
tables.
+ validateLanceTableChanges(changes);
String location = table.properties().get(Table.PROPERTY_LOCATION);
Map<String, String> storageOptions =
LancePropertiesUtils.resolveLanceStorageOptions(catalogProperties,
table.properties());
try (Dataset dataset = openDataset(location, storageOptions)) {
- for (TableChange change : changes) {
+ List<TableChange> changesToApply =
+ Arrays.stream(changes).anyMatch(change -> change instanceof
TableChange.ColumnChange)
+ ? validateLanceColumnNames(dataset, changes)
+ : Arrays.asList(changes);
+ for (TableChange change : changesToApply) {
if (change instanceof TableChange.DeleteColumn deleteColumn) {
- dataset.dropColumns(List.of(String.join(".",
deleteColumn.fieldName())));
+ dataset.dropColumns(List.of(deleteColumn.fieldName()[0]));
} else if (change instanceof TableChange.AddIndex addIndex) {
IndexType indexType = IndexType.valueOf(addIndex.getType().name());
IndexParams indexParams = getIndexParamsByIndexType(indexType);
@@ -843,6 +852,53 @@ public class LanceTableOperations extends
ManagedTableOperations {
}
}
+ private static List<TableChange> validateLanceColumnNames(
+ Dataset dataset, TableChange[] changes) {
+ // Use the dataset schema, including out-of-band changes, and validate the
whole ordered
+ // request before applying any changes. A late name conflict must not
leave earlier edits.
+ Set<String> columnNames =
+
dataset.getSchema().getFields().stream().map(Field::getName).collect(Collectors.toSet());
+ List<TableChange> changesToApply = new ArrayList<>(changes.length);
+ for (TableChange change : changes) {
+ if (change instanceof TableChange.DeleteColumn deleteColumn) {
+ String name = deleteColumn.fieldName()[0];
+ boolean exists = columnNames.remove(name);
+ Preconditions.checkArgument(
+ exists || Boolean.TRUE.equals(deleteColumn.getIfExists()),
+ "Column does not exist: %s",
+ name);
+ if (!exists) {
+ // Honor ifExists before reaching Lance, whose dropColumns rejects
missing fields.
+ continue;
+ }
+ } else if (change instanceof TableChange.RenameColumn renameColumn) {
+ String oldName = renameColumn.fieldName()[0];
+ String newName = renameColumn.getNewName();
+ Preconditions.checkArgument(
+ columnNames.remove(oldName), "Column does not exist: %s", oldName);
+ Preconditions.checkArgument(columnNames.add(newName), "Column already
exists: %s", newName);
+ }
+ changesToApply.add(change);
+ }
+ return changesToApply;
+ }
+
+ private static void validateLanceTableChanges(TableChange[] changes) {
+ for (TableChange change : changes) {
+ if (change instanceof TableChange.DeleteColumn
+ || change instanceof TableChange.RenameColumn) {
+ String[] fieldName = ((TableChange.ColumnChange) change).fieldName();
+ Preconditions.checkArgument(
+ fieldName.length == 1,
+ "Lance only supports dropping or renaming top-level columns: %s",
+ String.join(".", fieldName));
+ } else if (!(change instanceof TableChange.AddIndex)) {
+ throw new UnsupportedOperationException(
+ "Unsupported changes to lance table: " +
change.getClass().getSimpleName());
+ }
+ }
+ }
+
Dataset openDataset(String location) {
return openDataset(location, Map.of());
}
diff --git
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
index 530cfe9bd2..1aec39b551 100644
---
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
+++
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
@@ -39,10 +39,13 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
+import java.util.stream.Collectors;
import org.apache.arrow.vector.types.pojo.ArrowType;
import org.apache.arrow.vector.types.pojo.Field;
import org.apache.arrow.vector.types.pojo.Schema;
@@ -52,6 +55,7 @@ import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.StringIdentifier;
import org.apache.gravitino.UserPrincipal;
import org.apache.gravitino.catalog.ManagedSchemaOperations;
+import org.apache.gravitino.lance.common.ops.gravitino.LanceDataTypeConverter;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.ColumnEntity;
import org.apache.gravitino.meta.TableEntity;
@@ -676,6 +680,8 @@ public class TestLanceTableOperations {
});
Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema())
+ .thenReturn(new Schema(List.of(Field.nullable("id", new
ArrowType.Int(32, true)))));
Version version = mock(Version.class);
when(dataset.getVersion()).thenReturn(version);
when(version.getId()).thenReturn(9L);
@@ -797,12 +803,221 @@ public class TestLanceTableOperations {
.update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
}
+ @Test
+ public void testHandleLanceTableChangeValidatesBeforeChangingDataset() {
+ Table table = mock(Table.class);
+ when(table.properties()).thenReturn(Map.of(Table.PROPERTY_LOCATION,
"location"));
+ Dataset dataset = mock(Dataset.class);
+ Mockito.doReturn(dataset).when(lanceTableOps).openDataset("location",
Map.of());
+
+ // Each request starts with a valid change, which must not reach the
dataset when a later
+ // change in the same request is rejected.
+ List<TableChange> rejectedChanges =
+ List.of(
+ TableChange.renameColumn(new String[] {"parent", "nested"},
"renamed"),
+ TableChange.deleteColumn(new String[] {"parent", "nested"}, false),
+ TableChange.addColumn(new String[] {"required"},
Types.StringType.get(), false),
+ TableChange.updateColumnComment(new String[] {"col1"}, "comment"));
+ for (TableChange rejected : rejectedChanges) {
+ Assertions.assertThrows(
+ RuntimeException.class,
+ () ->
+ lanceTableOps.handleLanceTableChange(
+ table,
+ new TableChange[] {
+ TableChange.deleteColumn(new String[] {"col1"}, false),
rejected
+ }));
+ }
+ verify(lanceTableOps, never()).openDataset("location", Map.of());
+ verify(dataset, never()).dropColumns(anyList());
+ verify(dataset, never()).alterColumns(anyList());
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "rename",
+ "deleteThenRenameMissing",
+ "renameThenRename",
+ "renameThenDeleteMissing",
+ "deleteTwice",
+ "deleteMissingDefault"
+ })
+ void testInvalidColumnNamesRejectWholeRequest(String path) throws Exception {
+ NameIdentifier ident = NameIdentifier.of("schema", "conflict");
+ String location = tempDir.resolve("conflict").toString();
+ Column[] columns = {
+ Column.of("id", Types.LongType.get()), Column.of("name",
Types.StringType.get())
+ };
+ long version;
+ try (Dataset dataset = createDatasetForColumnNameValidation(location,
columns)) {
+ version = dataset.getVersion().getId();
+ }
+ List<ColumnEntity> storedColumns = new ArrayList<>();
+ for (int i = 0; i < columns.length; i++) {
+ storedColumns.add(
+ ColumnEntity.builder()
+ .withId(10L + i)
+ .withName(columns[i].name())
+ .withDataType(columns[i].dataType())
+ .withPosition(i)
+ .withNullable(true)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build());
+ }
+ TableEntity entity =
+ tableEntity(
+ ident,
+ storedColumns,
+ Map.of(
+ Table.PROPERTY_TABLE_FORMAT,
+ "lance",
+ Table.PROPERTY_LOCATION,
+ location,
+ LANCE_TABLE_VERSION,
+ String.valueOf(version)));
+ when(store.get(ident, Entity.EntityType.TABLE,
TableEntity.class)).thenReturn(entity);
+ TableChange rename = TableChange.renameColumn(new String[] {"id"}, "name");
+ TableChange[] changes =
+ switch (path) {
+ case "deleteThenRenameMissing" -> new TableChange[] {
+ TableChange.deleteColumn(new String[] {"id"}, false),
+ TableChange.renameColumn(new String[] {"id"}, "extra")
+ };
+ case "renameThenRename" -> new TableChange[] {
+ TableChange.renameColumn(new String[] {"id"}, "extra"),
+ TableChange.renameColumn(new String[] {"extra"}, "name")
+ };
+ case "renameThenDeleteMissing" -> new TableChange[] {
+ TableChange.renameColumn(new String[] {"id"}, "extra"),
+ TableChange.deleteColumn(new String[] {"missing"}, false)
+ };
+ case "deleteTwice" -> new TableChange[] {
+ TableChange.deleteColumn(new String[] {"id"}, false),
+ TableChange.deleteColumn(new String[] {"id"}, false)
+ };
+ case "deleteMissingDefault" -> new TableChange[] {
+ TableChange.deleteColumn(new String[] {"missing"}, null)
+ };
+ default -> new TableChange[] {rename};
+ };
+ IllegalArgumentException error =
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
lanceTableOps.alterTable(ident, changes));
+ String invalidName =
+ switch (path) {
+ case "renameThenDeleteMissing", "deleteMissingDefault" -> "missing";
+ case "deleteTwice", "deleteThenRenameMissing" -> "id";
+ default -> "name";
+ };
+ Assertions.assertTrue(error.getMessage().contains(invalidName));
+ verify(store, never())
+ .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE),
any());
+ try (Dataset dataset = lanceTableOps.openDataset(location)) {
+ Assertions.assertEquals(version, dataset.getVersion().getId());
+ Assertions.assertEquals(
+ List.of("id", "name"),
+ dataset.getSchema().getFields().stream()
+ .map(Field::getName)
+ .collect(Collectors.toList()));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testColumnNamesCanBeReusedAfterEarlierChanges(boolean drop) {
+ String location = tempDir.resolve("reuse").toString();
+ Column[] columns = {
+ Column.of("id", Types.LongType.get()), Column.of("name",
Types.StringType.get())
+ };
+ try (Dataset ignored = createDatasetForColumnNameValidation(location,
columns)) {
+ // Use real Lance mutations to verify that preflight follows the request
order.
+ }
+ Table table = mock(Table.class);
+ when(table.properties()).thenReturn(Map.of(Table.PROPERTY_LOCATION,
location));
+ TableChange[] changes =
+ drop
+ ? new TableChange[] {
+ TableChange.deleteColumn(new String[] {"name"}, false),
+ TableChange.renameColumn(new String[] {"id"}, "name")
+ }
+ : new TableChange[] {
+ TableChange.renameColumn(new String[] {"id"}, "new"),
+ TableChange.renameColumn(new String[] {"name"}, "id")
+ };
+ lanceTableOps.handleLanceTableChange(table, changes);
+ try (Dataset dataset = lanceTableOps.openDataset(location)) {
+ Assertions.assertEquals(
+ drop ? List.of("name") : List.of("new", "id"),
+ dataset.getSchema().getFields().stream()
+ .map(Field::getName)
+ .collect(Collectors.toList()));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"alone", "twice", "thenRename", "renameThenDrop"})
+ void testOptionalDeletesRespectRequestOrder(String path) {
+ String location = tempDir.resolve("optional-delete").toString();
+ Column[] columns = {
+ Column.of("id", Types.LongType.get()), Column.of("name",
Types.StringType.get())
+ };
+ long version;
+ try (Dataset dataset = createDatasetForColumnNameValidation(location,
columns)) {
+ version = dataset.getVersion().getId();
+ }
+ Table table = mock(Table.class);
+ when(table.properties()).thenReturn(Map.of(Table.PROPERTY_LOCATION,
location));
+ TableChange[] changes =
+ switch (path) {
+ case "twice" -> new TableChange[] {
+ TableChange.deleteColumn(new String[] {"name"}, false),
+ TableChange.deleteColumn(new String[] {"name"}, true)
+ };
+ case "thenRename" -> new TableChange[] {
+ TableChange.deleteColumn(new String[] {"extra"}, true),
+ TableChange.renameColumn(new String[] {"id"}, "extra")
+ };
+ case "renameThenDrop" -> new TableChange[] {
+ TableChange.renameColumn(new String[] {"id"}, "extra"),
+ TableChange.deleteColumn(new String[] {"extra"}, true)
+ };
+ default -> new TableChange[] {TableChange.deleteColumn(new String[]
{"missing"}, true)};
+ };
+ long result = lanceTableOps.handleLanceTableChange(table, changes);
+ if (path.equals("alone")) {
+ Assertions.assertEquals(version, result);
+ } else {
+ Assertions.assertTrue(result > version);
+ }
+ try (Dataset dataset = lanceTableOps.openDataset(location)) {
+ List<String> expected =
+ switch (path) {
+ case "twice" -> List.of("id");
+ case "thenRename" -> List.of("extra", "name");
+ case "renameThenDrop" -> List.of("name");
+ default -> List.of("id", "name");
+ };
+ Assertions.assertEquals(
+ expected,
+ dataset.getSchema().getFields().stream()
+ .map(Field::getName)
+ .collect(Collectors.toList()));
+ }
+ }
+
@Test
public void testHandleLanceTableChangeRespectsOrder() {
Table table = mock(Table.class);
when(table.properties()).thenReturn(Map.of(Table.PROPERTY_LOCATION,
"location"));
Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema())
+ .thenReturn(
+ new Schema(
+ List.of(
+ Field.nullable("old", new ArrowType.Utf8()),
+ Field.nullable("col1", new ArrowType.Utf8()))));
Version version = mock(Version.class);
when(dataset.getVersion()).thenReturn(version);
when(version.getId()).thenReturn(7L);
@@ -842,6 +1057,12 @@ public class TestLanceTableOperations {
"table-key"));
Dataset dataset = mock(Dataset.class);
+ when(dataset.getSchema())
+ .thenReturn(
+ new Schema(
+ List.of(
+ Field.nullable("old", new ArrowType.Utf8()),
+ Field.nullable("col1", new ArrowType.Utf8()))));
Version version = mock(Version.class);
when(dataset.getVersion()).thenReturn(version);
when(version.getId()).thenReturn(9L);
@@ -1301,4 +1522,20 @@ public class TestLanceTableOperations {
.mode(WriteParams.WriteMode.CREATE)
.execute();
}
+
+ private Dataset createDatasetForColumnNameValidation(String location,
Column[] columns) {
+ Schema schema =
+ new Schema(
+ Arrays.stream(columns)
+ .map(
+ column ->
+ LanceDataTypeConverter.CONVERTER.toArrowField(
+ column.name(), column.dataType(),
column.nullable()))
+ .collect(Collectors.toList()));
+ return Dataset.write()
+ .uri(location)
+ .schema(schema)
+ .mode(WriteParams.WriteMode.CREATE)
+ .execute();
+ }
}
diff --git
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
index d881404ef1..732c7f9bad 100644
---
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
+++
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
@@ -488,8 +488,8 @@ public class IcebergCatalogOperations
*/
private Table renameTable(NameIdentifier tableIdent, TableChange.RenameTable
renameTable)
throws NoSuchTableException, IllegalArgumentException {
+ Namespace destNamespace = tableIdent.namespace();
try {
- Namespace destNamespace = tableIdent.namespace();
if (renameTable.getNewSchemaName().isPresent()) {
String[] namespaceLevels = tableIdent.namespace().levels();
String[] destLevels = Arrays.copyOf(namespaceLevels,
namespaceLevels.length);
@@ -511,6 +511,11 @@ public class IcebergCatalogOperations
icebergCatalogWrapper.renameTable(renameTableRequest);
return loadTable(
NameIdentifier.of(ArrayUtils.add(destNamespace.levels(),
renameTable.getNewName())));
+ } catch (AlreadyExistsException e) {
+ throw new TableAlreadyExistsException(
+ e,
+ "Table already exists: %s",
+ NameIdentifier.of(destNamespace, renameTable.getNewName()));
} catch (org.apache.iceberg.exceptions.NoSuchTableException e) {
throw new NoSuchTableException(e, ICEBERG_TABLE_DOES_NOT_EXIST_MSG,
tableIdent.name());
}
diff --git
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergTable.java
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergTable.java
index 5b1b0a2874..6b0c9e320a 100644
---
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergTable.java
+++
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergTable.java
@@ -56,10 +56,13 @@ import
org.apache.gravitino.rel.expressions.sorts.SortOrders;
import org.apache.gravitino.rel.expressions.transforms.Transform;
import org.apache.gravitino.rel.types.Types;
import org.apache.iceberg.DistributionMode;
+import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
public class TestIcebergTable {
@@ -490,6 +493,59 @@ public class TestIcebergTable {
Assertions.assertArrayEquals(createdTable.partitioning(),
alteredTable.partitioning());
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testRenameTableToExistingNamePreservesBothTables(boolean acrossSchemas)
{
+ NameIdentifier source =
+ NameIdentifier.of(
+ schemaIdent.namespace().level(0),
+ ICEBERG_CATALOG_NAME,
+ ICEBERG_SCHEMA_NAME,
+ "rename_source");
+ NameIdentifier targetSchema =
+ NameIdentifier.of(
+ META_LAKE_NAME,
+ ICEBERG_CATALOG_NAME,
+ acrossSchemas ? "rename_target_schema" : ICEBERG_SCHEMA_NAME);
+ NameIdentifier target =
+ NameIdentifier.of(
+ META_LAKE_NAME, ICEBERG_CATALOG_NAME, targetSchema.name(),
"rename_target");
+ if (acrossSchemas) {
+ icebergCatalogOperations.createSchema(targetSchema, "target schema",
Map.of());
+ }
+ try {
+ Column[] columns = {Column.of("id", Types.LongType.get())};
+ for (NameIdentifier ident : List.of(source, target)) {
+ icebergCatalogOperations.createTable(
+ ident,
+ columns,
+ ident.name(),
+ Map.of(),
+ new Transform[0],
+ Distributions.NONE,
+ new SortOrder[0]);
+ }
+ TableAlreadyExistsException error =
+ Assertions.assertThrows(
+ TableAlreadyExistsException.class,
+ () ->
+ icebergCatalogOperations.alterTable(
+ source,
+ acrossSchemas
+ ? TableChange.rename(target.name(),
targetSchema.name())
+ : TableChange.rename(target.name())));
+ Assertions.assertInstanceOf(AlreadyExistsException.class,
error.getCause());
+ Assertions.assertEquals("Table already exists: " + target,
error.getMessage());
+ Assertions.assertEquals(source.name(),
icebergCatalogOperations.loadTable(source).comment());
+ Assertions.assertEquals(target.name(),
icebergCatalogOperations.loadTable(target).comment());
+ } finally {
+ if (acrossSchemas) {
+ icebergCatalogOperations.dropTable(target);
+ icebergCatalogOperations.dropSchema(targetSchema, false);
+ }
+ }
+ }
+
@Test
public void testRenameTableAcrossSchema() {
NameIdentifier tableIdentifier =
diff --git a/docs/lance-rest-service.md b/docs/lance-rest-service.md
index 933861aba1..75cd2e726d 100644
--- a/docs/lance-rest-service.md
+++ b/docs/lance-rest-service.md
@@ -279,6 +279,23 @@ URL encoded: lance_catalog%24schema%24table01
- Namespace deletion is recursive and irreversible
:::
+## Error responses
+
+Malformed requests and backend validation errors return HTTP `400` without
generated stack traces
+in `detail`. Details explicitly supplied by a backend Lance exception are
preserved. Expected
+backend client errors are logged at INFO; server failures are logged at ERROR
with the original
+exception for diagnosis. Unexpected failures return a generic
internal-server-error response.
+
+Unrecognized or unimplemented routes under the Lance REST servlet return HTTP
`406` with Lance
+error code `0` (`Unsupported`), allowing clients to select a fallback. The
error identifies the HTTP
+method and path. Parameter conversion failures remain HTTP `400`
(`InvalidInput`); missing tables
+and namespaces reported by operation handlers retain HTTP `404` and their
entity-specific codes.
+
+In 1.3, Lance column changes support dropping and renaming top-level columns;
adding columns remains
+unsupported. Column-name validation checks the ordered request against the
current dataset before
+applying changes. Missing optional deletes are skipped. This prevents partial
edits from invalid
+column names, but does not make backend failures or concurrent external
changes atomic.
+
## Examples
The following examples demonstrate how to interact with Lance REST service
using different programming languages and tools.
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 3c41ba4f4f..e1ad2fc146 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
@@ -35,6 +35,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.LanceRequestBodyReaderInterceptor;
import org.apache.gravitino.lance.service.LanceServiceIdentityFilter;
import
org.apache.gravitino.lance.service.authorization.LanceAuthorizationMetadataFilter;
import
org.apache.gravitino.lance.service.authorization.LanceRESTAuthInterceptionService;
@@ -106,7 +107,8 @@ public class LanceRESTService implements
GravitinoAuxiliaryService {
}
ResourceConfig resourceConfig = new ResourceConfig();
- resourceConfig.register(JacksonFeature.class);
+ resourceConfig.register(JacksonFeature.withoutExceptionMappers());
+ resourceConfig.register(LanceRequestBodyReaderInterceptor.class);
resourceConfig.packages(LANCE_REST_SPEC_PACKAGE);
resourceConfig.register(LanceExceptionMapper.class);
resourceConfig.register(new OutOfMemoryErrorListener());
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 c5b136604b..b39f5f5a76 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
@@ -20,17 +20,18 @@ package org.apache.gravitino.lance.service;
import static org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace;
-import javax.ws.rs.NotAcceptableException;
-import javax.ws.rs.NotAllowedException;
-import javax.ws.rs.NotSupportedException;
import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Request;
import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import org.apache.gravitino.exceptions.NoSuchTableException;
import org.apache.gravitino.exceptions.NotFoundException;
import org.apache.gravitino.server.web.ServerHealth;
+import org.glassfish.jersey.server.ParamException;
import org.lance.namespace.errors.ConcurrentModificationException;
import org.lance.namespace.errors.InternalException;
import org.lance.namespace.errors.InvalidInputException;
@@ -42,6 +43,7 @@ import org.lance.namespace.errors.PermissionDeniedException;
import org.lance.namespace.errors.ServiceUnavailableException;
import org.lance.namespace.errors.TableAlreadyExistsException;
import org.lance.namespace.errors.TableNotFoundException;
+import org.lance.namespace.errors.ThrottlingException;
import org.lance.namespace.errors.UnauthenticatedException;
import org.lance.namespace.model.ErrorResponse;
import org.slf4j.Logger;
@@ -52,42 +54,95 @@ public class LanceExceptionMapper implements
ExceptionMapper<Throwable> {
private static final Logger LOG =
LoggerFactory.getLogger(LanceExceptionMapper.class);
+ @Context private UriInfo uriInfo;
+ @Context private Request request;
+
public static Response toRESTResponse(String instance, Throwable ex) {
ServerHealth.getInstance().recordFailure(ex);
- if (ex instanceof NotAllowedException
- || ex instanceof NotAcceptableException
- || ex instanceof NotSupportedException) {
- WebApplicationException routingError = (WebApplicationException) ex;
- InvalidInputException invalidInput =
- new InvalidInputException(ex.getMessage(), getStackTrace(ex),
instance);
- try (Response mapped = handleLanceNamespaceException(invalidInput)) {
- Response.ResponseBuilder response =
- Response.status(routingError.getResponse().getStatus())
- .entity(mapped.getEntity())
- .type(MediaType.APPLICATION_JSON_TYPE);
- if (ex instanceof NotAllowedException) {
- response.allow(routingError.getResponse().getAllowedMethods());
- }
- return response.build();
- }
+ if (ex instanceof WebApplicationException webException) {
+ return handleWebApplicationException(instance, webException);
}
-
LanceNamespaceException lanceException =
ex instanceof LanceNamespaceException
? (LanceNamespaceException) ex
: toLanceNamespaceException(instance, ex);
- LOG.error(
- "Operate Lance instance with id '{}' got exception: {}",
- instance,
- lanceException.getMessage());
+ if (toHttpStatus(lanceException) >= 500) {
+ LOG.error("Operate Lance instance with id '{}' failed", instance, ex);
+ } else {
+ LOG.info(
+ "Operate Lance instance with id '{}' got exception: {}",
+ instance,
+ lanceException.getMessage());
+ }
return handleLanceNamespaceException(lanceException);
}
+ /** {@inheritDoc} */
@Override
public Response toResponse(Throwable ex) {
- return toRESTResponse("", ex);
+ String instance = "";
+ if (isRouteMiss(ex) && request != null && uriInfo != null) {
+ instance = request.getMethod() + " " + uriInfo.getPath(false);
+ }
+ return toRESTResponse(instance, ex);
+ }
+
+ private static boolean isRouteMiss(Throwable ex) {
+ return ex instanceof WebApplicationException webException
+ && !(ex instanceof ParamException)
+ && webException.getResponse().getStatus() ==
Response.Status.NOT_FOUND.getStatusCode();
+ }
+
+ private static Response handleWebApplicationException(
+ String instance, WebApplicationException exception) {
+ int status = exception.getResponse().getStatus();
+ String message = exception.getMessage();
+ boolean routeMiss = isRouteMiss(exception);
+ if (exception instanceof ParamException parameterException) {
+ // Jersey can use 404 for parameter conversion failures, but these are
invalid input.
+ status = Response.Status.BAD_REQUEST.getStatusCode();
+ message = "Invalid value for parameter '" +
parameterException.getParameterName() + "'";
+ } else if (routeMiss) {
+ // Unmatched routes include unimplemented Namespace operations. Code 0 /
HTTP 406 lets
+ // Lance clients recognize Unsupported and fall back; domain not-found
errors stay 404.
+ status = Response.Status.NOT_ACCEPTABLE.getStatusCode();
+ message = "Unsupported Lance REST operation" + (instance.isEmpty() ? ""
: ": " + instance);
+ }
+ LanceNamespaceException error;
+ if (routeMiss) {
+ error = new
org.lance.namespace.errors.UnsupportedOperationException(message, "", instance);
+ } else if (status >= 400 && status < 500) {
+ error =
+ switch (status) {
+ case 401 -> new UnauthenticatedException(message, "", instance);
+ case 403 -> new PermissionDeniedException(message, "", instance);
+ case 409 -> new ConcurrentModificationException(message, "",
instance);
+ case 429 -> new ThrottlingException(message, "", instance);
+ default -> new InvalidInputException(message, "", instance);
+ };
+ } else {
+ LOG.error("Lance REST framework failure:", exception);
+ error = new InternalException("Internal server error", "", instance);
+ }
+ // Keep protocol headers such as Allow, WWW-Authenticate and Retry-After,
while replacing any
+ // framework entity with the Lance error envelope. Never expose a
framework stack or 5xx detail.
+ Response.ResponseBuilder response =
Response.fromResponse(exception.getResponse());
+ // The old entity's framing, representation metadata and validators do not
describe our JSON.
+ for (String header :
+ new String[] {
+ "Content-Length", "Content-Encoding", "Content-Language",
"Content-Location",
+ "Content-Range", "Content-Disposition", "Transfer-Encoding", "ETag",
+ "Last-Modified", "Content-MD5", "Digest"
+ }) {
+ response.header(header, null);
+ }
+ return response
+ .status(status)
+ .entity(errorResponse(error))
+ .type(MediaType.APPLICATION_JSON_TYPE)
+ .build();
}
private static LanceNamespaceException toLanceNamespaceException(String
instance, Throwable ex) {
@@ -98,7 +153,8 @@ public class LanceExceptionMapper implements
ExceptionMapper<Throwable> {
return new NamespaceNotFoundException(ex.getMessage(),
getStackTrace(ex), instance);
} else if (ex instanceof IllegalArgumentException) {
- return new InvalidInputException(ex.getMessage(), getStackTrace(ex),
instance);
+ // Validation failures never expose generated stacks in the client
response.
+ return new InvalidInputException(ex.getMessage(), "", instance);
} else if (ex instanceof
org.apache.gravitino.exceptions.TableAlreadyExistsException) {
return new TableAlreadyExistsException(ex.getMessage(),
getStackTrace(ex), instance);
@@ -108,8 +164,7 @@ public class LanceExceptionMapper implements
ExceptionMapper<Throwable> {
ex.getMessage(), getStackTrace(ex), instance);
} else {
- LOG.warn("Lance REST server unexpected exception:", ex);
- return new InternalException(ex.getMessage(), getStackTrace(ex),
instance);
+ return new InternalException("Internal server error", "", instance);
}
}
@@ -137,6 +192,10 @@ public class LanceExceptionMapper implements
ExceptionMapper<Throwable> {
return Response.Status.UNAUTHORIZED.getStatusCode();
}
+ if (exception instanceof ThrottlingException) {
+ return 429;
+ }
+
if (exception instanceof ServiceUnavailableException) {
return Response.Status.SERVICE_UNAVAILABLE.getStatusCode();
}
@@ -150,11 +209,15 @@ public class LanceExceptionMapper implements
ExceptionMapper<Throwable> {
}
private static Response
handleLanceNamespaceException(LanceNamespaceException ex) {
+ return Response.status(toHttpStatus(ex)).entity(errorResponse(ex)).build();
+ }
+
+ private static ErrorResponse errorResponse(LanceNamespaceException ex) {
ErrorResponse errResp = new ErrorResponse();
errResp.setCode(ex.getCode());
errResp.setError(ex.getMessage());
errResp.setDetail(ex.getDetail());
errResp.setInstance(ex.getInstance());
- return Response.status(toHttpStatus(ex)).entity(errResp).build();
+ return errResp;
}
}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceRequestBodyReaderInterceptor.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceRequestBodyReaderInterceptor.java
new file mode 100644
index 0000000000..1cdb682dbc
--- /dev/null
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceRequestBodyReaderInterceptor.java
@@ -0,0 +1,51 @@
+/*
+ * 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 com.fasterxml.jackson.core.JsonProcessingException;
+import java.io.IOException;
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.ext.Provider;
+import javax.ws.rs.ext.ReaderInterceptor;
+import javax.ws.rs.ext.ReaderInterceptorContext;
+
+/** Keeps JSON request decoding failures in the Lance error-response format. */
+@Provider
+public class LanceRequestBodyReaderInterceptor implements ReaderInterceptor {
+
+ /**
+ * Converts JSON decoding failures before a resource method is invoked.
+ *
+ * @param context the request entity reader context
+ * @return the decoded request entity
+ * @throws IOException if a non-JSON input failure occurs
+ * @throws WebApplicationException if the JSON request cannot be decoded
+ */
+ @Override
+ public Object aroundReadFrom(ReaderInterceptorContext context)
+ throws IOException, WebApplicationException {
+ try {
+ return context.proceed();
+ } catch (JsonProcessingException invalidJson) {
+ // Keep the cause for diagnostics without echoing request contents in
the HTTP error body.
+ throw new BadRequestException("Invalid JSON request", invalidJson);
+ }
+ }
+}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceNamespaceOperations.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceNamespaceOperations.java
index 0b5a769f8e..4fe9edd595 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceNamespaceOperations.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceNamespaceOperations.java
@@ -27,6 +27,7 @@ import static
org.apache.gravitino.server.authorization.expression.Authorization
import com.codahale.metrics.annotation.ResponseMetered;
import com.codahale.metrics.annotation.Timed;
+import com.google.common.base.Preconditions;
import java.util.regex.Pattern;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
@@ -132,6 +133,7 @@ public class LanceNamespaceOperations {
@DefaultValue(NAMESPACE_DELIMITER_DEFAULT) @QueryParam("delimiter")
String delimiter,
CreateNamespaceRequest request) {
try {
+ Preconditions.checkArgument(request != null, "Request body must not be
null");
CreateNamespaceResponse response =
lanceNamespace
.asNamespaceOps()
@@ -156,6 +158,7 @@ public class LanceNamespaceOperations {
@DefaultValue(NAMESPACE_DELIMITER_DEFAULT) @QueryParam("delimiter")
String delimiter,
DropNamespaceRequest request) {
try {
+ Preconditions.checkArgument(request != null, "Request body must not be
null");
DropNamespaceResponse response =
lanceNamespace
.asNamespaceOps()
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
index fb763de1fb..fcc48afdb0 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceTableOperations.java
@@ -328,9 +328,8 @@ public class LanceTableOperations {
}
}
- private void validateDeclareTableRequest(
- @SuppressWarnings("unused") DeclareTableRequest request) {
- // No specific fields to validate for now
+ private void validateDeclareTableRequest(DeclareTableRequest request) {
+ Preconditions.checkArgument(request != null, "Request body must not be
null");
}
private void validateRegisterTableRequest(RegisterTableRequest request) {
@@ -361,6 +360,8 @@ public class LanceTableOperations {
}
private void validateDropColumnsRequest(AlterTableDropColumnsRequest
request) {
+ Preconditions.checkArgument(request != null, "Request body must not be
null");
+ Preconditions.checkArgument(request.getColumns() != null, "columns must
not be null");
Preconditions.checkArgument(
!request.getColumns().isEmpty(), "Columns to drop cannot be empty.");
Preconditions.checkArgument(
@@ -369,10 +370,14 @@ public class LanceTableOperations {
}
private void validateAlterColumnsRequest(AlterTableAlterColumnsRequest
request) {
+ Preconditions.checkArgument(request != null, "Request body must not be
null");
+ Preconditions.checkArgument(request.getAlterations() != null, "alterations
must not be null");
Preconditions.checkArgument(
!request.getAlterations().isEmpty(), "Columns to alter cannot be
empty.");
- for (AlterColumnsEntry alteration : request.getAlterations()) {
+ for (int i = 0; i < request.getAlterations().size(); i++) {
+ AlterColumnsEntry alteration = request.getAlterations().get(i);
+ Preconditions.checkArgument(alteration != null, "alterations[%s] must
not be null", i);
Preconditions.checkArgument(
StringUtils.isNotBlank(alteration.getPath()), "Column path to alter
cannot be empty.");
Preconditions.checkArgument(
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceRESTServiceIT.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceRESTServiceIT.java
index b4762fedf0..db06aedc23 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceRESTServiceIT.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceRESTServiceIT.java
@@ -24,6 +24,10 @@ import com.google.common.collect.Sets;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -52,6 +56,7 @@ import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.lance.common.utils.ArrowUtils;
import org.apache.gravitino.lance.common.utils.LanceConstants;
import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableChange;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
@@ -64,6 +69,7 @@ import org.lance.namespace.client.apache.ApiException;
import org.lance.namespace.client.apache.api.TableApi;
import org.lance.namespace.errors.ErrorCode;
import org.lance.namespace.errors.LanceNamespaceException;
+import org.lance.namespace.errors.UnsupportedOperationException;
import org.lance.namespace.model.AlterColumnsEntry;
import org.lance.namespace.model.AlterTableAlterColumnsRequest;
import org.lance.namespace.model.AlterTableAlterColumnsResponse;
@@ -89,6 +95,8 @@ import org.lance.namespace.model.ListNamespacesRequest;
import org.lance.namespace.model.ListNamespacesResponse;
import org.lance.namespace.model.ListTablesRequest;
import org.lance.namespace.model.NamespaceExistsRequest;
+import org.lance.namespace.model.QueryTableRequest;
+import org.lance.namespace.model.QueryTableRequestVector;
import org.lance.namespace.model.RegisterTableRequest;
import org.lance.namespace.model.RegisterTableResponse;
import org.lance.namespace.model.TableExistsRequest;
@@ -671,16 +679,31 @@ public class LanceRESTServiceIT extends BaseIT {
AlterTableDropColumnsRequest dropNonExistingColumnsRequest = new
AlterTableDropColumnsRequest();
dropNonExistingColumnsRequest.setId(ids);
dropNonExistingColumnsRequest.setColumns(List.of("non_existing_column"));
- Exception dropColumnException =
+ long versionBeforeRejectedDrop;
+ try (Dataset dataset = Dataset.open().uri(newLocation).build()) {
+ versionBeforeRejectedDrop = dataset.version();
+ }
+ ApiException dropColumnException =
Assertions.assertThrows(
- Exception.class,
+ ApiException.class,
() ->
tableApi.alterTableDropColumns(
String.join(delimiter, ids),
dropNonExistingColumnsRequest, delimiter));
- Assertions.assertTrue(
- dropColumnException
- .getMessage()
- .contains("Column non_existing_column does not exist in the
dataset"));
+ Assertions.assertEquals(400, dropColumnException.getCode());
+ ErrorResponse error =
+ JsonUtils.anyFieldMapper()
+ .readValue(dropColumnException.getResponseBody(),
ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.INVALID_INPUT.getCode(),
error.getCode());
+ Assertions.assertTrue(error.getError().contains("non_existing_column"));
+ Assertions.assertEquals("", error.getDetail());
+ Assertions.assertEquals(
+ List.of("id"),
+ ns.describeTable(describeTableRequest).getSchema().getFields().stream()
+ .map(JsonArrowField::getName)
+ .toList());
+ try (Dataset dataset = Dataset.open().uri(newLocation).build()) {
+ Assertions.assertEquals(versionBeforeRejectedDrop, dataset.version());
+ }
}
@Test
@@ -723,6 +746,59 @@ public class LanceRESTServiceIT extends BaseIT {
Assertions.assertFalse(loadTable.getMetadata().containsKey("lance.storage.secret_access_key"));
}
+ @Test
+ void testUnimplementedOperationUsesUnsupportedError() throws Exception {
+ QueryTableRequest request = new QueryTableRequest();
+ request.setId(List.of("missing", "schema", "table"));
+ request.setK(1);
+ request.setVector(new QueryTableRequestVector().singleVector(List.of(1.0f,
2.0f)));
+ request.setVectorColumn("vector");
+ UnsupportedOperationException error =
+ Assertions.assertThrows(UnsupportedOperationException.class, () ->
ns.queryTable(request));
+ Assertions.assertEquals(ErrorCode.UNSUPPORTED.getCode(), error.getCode());
+ // The native client classifies by code but does not retain instance;
inspect diagnostics
+ // on the wire independently rather than requiring it to preserve every
response field.
+ HttpRequest httpRequest =
+ HttpRequest.newBuilder(
+ URI.create(getLanceRestServiceUrl() +
"/v1/table/missing.schema.table/query"))
+ .header("Content-Type", "application/json")
+ .POST(
+ HttpRequest.BodyPublishers.ofString(
+ JsonUtils.anyFieldMapper().writeValueAsString(request)))
+ .build();
+ HttpResponse<String> response =
+ HttpClient.newHttpClient().send(httpRequest,
HttpResponse.BodyHandlers.ofString());
+ Assertions.assertEquals(406, response.statusCode());
+ ErrorResponse body = JsonUtils.anyFieldMapper().readValue(response.body(),
ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.UNSUPPORTED.getCode(), body.getCode());
+ Assertions.assertTrue(body.getInstance().startsWith("POST "));
+ Assertions.assertTrue(body.getInstance().endsWith("/query"));
+ Assertions.assertTrue(body.getError().contains(body.getInstance()));
+ Assertions.assertEquals("", body.getDetail());
+ }
+
+ @Test
+ void testInvalidJsonRequestUsesLanceEnvelope() throws Exception {
+ HttpClient http = HttpClient.newHttpClient();
+ for (String body : List.of("{", "[]")) {
+ HttpRequest request =
+ HttpRequest.newBuilder(
+ URI.create(getLanceRestServiceUrl() +
"/v1/namespace/missing/create"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build();
+ HttpResponse<String> response = http.send(request,
HttpResponse.BodyHandlers.ofString());
+ Assertions.assertEquals(400, response.statusCode());
+ Assertions.assertTrue(
+
response.headers().firstValue("Content-Type").orElse("").contains("application/json"));
+ ErrorResponse error =
+ JsonUtils.anyFieldMapper().readValue(response.body(),
ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.INVALID_INPUT.getCode(),
error.getCode());
+ Assertions.assertEquals("Invalid JSON request", error.getError());
+ Assertions.assertEquals("", error.getDetail());
+ }
+ }
+
@Test
void testAlterColumns() throws Exception {
catalog = createCatalog(CATALOG_NAME);
@@ -742,6 +818,33 @@ public class LanceRESTServiceIT extends BaseIT {
TableApi tableApi = createTableApi();
String delimiter = DELIMITER;
+ AlterTableAlterColumnsRequest conflictRequest = new
AlterTableAlterColumnsRequest();
+ conflictRequest.setId(ids);
+ AlterColumnsEntry conflict = new AlterColumnsEntry();
+ conflict.setPath("id");
+ conflict.setRename("value");
+ conflictRequest.setAlterations(List.of(conflict));
+ ApiException conflictError =
+ Assertions.assertThrows(
+ ApiException.class,
+ () ->
+ tableApi.alterTableAlterColumns(
+ String.join(delimiter, ids), conflictRequest, delimiter));
+ Assertions.assertEquals(400, conflictError.getCode());
+ ErrorResponse conflictBody =
+ JsonUtils.anyFieldMapper().readValue(conflictError.getResponseBody(),
ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.INVALID_INPUT.getCode(),
conflictBody.getCode());
+ Assertions.assertTrue(conflictBody.getError().contains("value"));
+ Assertions.assertEquals("", conflictBody.getDetail());
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ catalog
+ .asTableCatalog()
+ .alterTable(
+ NameIdentifier.of(SCHEMA_NAME, ids.get(2)),
+ TableChange.renameColumn(new String[] {"id"}, "value")));
+
AlterTableAlterColumnsRequest alterRequest = new
AlterTableAlterColumnsRequest();
alterRequest.setId(ids);
AlterColumnsEntry columnAlteration = new AlterColumnsEntry();
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
index edaa1fbed9..16d985dcb8 100644
---
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
@@ -18,24 +18,51 @@
*/
package org.apache.gravitino.lance.service;
+import com.fasterxml.jackson.core.JsonParseException;
import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
+import javax.ws.rs.WebApplicationException;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Application;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ReaderInterceptorContext;
+import org.apache.gravitino.exceptions.NoSuchTableException;
import org.apache.gravitino.rest.RESTUtils;
import org.apache.gravitino.server.web.ServerHealth;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.AbstractConfiguration;
+import org.apache.logging.log4j.core.config.LoggerConfig;
+import org.apache.logging.log4j.core.config.Property;
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.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.lance.namespace.errors.ErrorCode;
+import org.lance.namespace.errors.ErrorFactory;
+import org.lance.namespace.errors.InternalException;
+import org.lance.namespace.errors.InvalidInputException;
+import org.lance.namespace.errors.NamespaceNotFoundException;
+import org.lance.namespace.errors.TableNotFoundException;
+import org.lance.namespace.errors.ThrottlingException;
+import org.lance.namespace.errors.UnsupportedOperationException;
+import org.lance.namespace.model.CreateNamespaceRequest;
import org.lance.namespace.model.ErrorResponse;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -106,6 +133,50 @@ public class TestLanceExceptionMapper extends JerseyTest {
public String consume(String body) {
return body;
}
+
+ /**
+ * Converts a framework response whose headers describe its original
entity.
+ *
+ * @return the mapped Lance error response
+ */
+ @GET
+ @Path("framework")
+ public Response frameworkFailure() {
+ try (Response original =
+ Response.status(415)
+ .header("content-encoding", "gzip")
+ .header("content-length", "3")
+ .header("Retry-After", "3")
+ .entity("old")
+ .build()) {
+ return LanceExceptionMapper.toRESTResponse(
+ "table", new WebApplicationException("request rejected",
original));
+ }
+ }
+ /**
+ * Requires a numeric path value so Jersey exercises path conversion
failures.
+ *
+ * @param version the version parsed by Jersey
+ * @return the parsed version
+ */
+ @GET
+ @Path("path/{version}")
+ public String version(@PathParam("version") int version) {
+ return String.valueOf(version);
+ }
+
+ /**
+ * Reads a JSON request using the same Jackson reader configuration as the
service.
+ *
+ * @param request the decoded namespace request
+ * @return its mode
+ */
+ @POST
+ @Path("json")
+ @Consumes(MediaType.APPLICATION_JSON)
+ public String json(CreateNamespaceRequest request) {
+ return request.getMode();
+ }
}
/**
@@ -124,7 +195,8 @@ public class TestLanceExceptionMapper extends JerseyTest {
return new ResourceConfig()
.register(ErrorResource.class)
.register(LanceExceptionMapper.class)
- .register(JacksonFeature.class);
+ .register(JacksonFeature.withoutExceptionMappers())
+ .register(LanceRequestBodyReaderInterceptor.class);
}
@Test
@@ -132,7 +204,8 @@ public class TestLanceExceptionMapper extends JerseyTest {
try (Response response = target("error").request().put(Entity.json("{}")))
{
Assertions.assertEquals(405, response.getStatus());
Assertions.assertTrue(response.getAllowedMethods().contains("GET"));
- Assertions.assertEquals(13,
response.readEntity(ErrorResponse.class).getCode());
+ Assertions.assertEquals(
+ ErrorCode.INVALID_INPUT.getCode(),
response.readEntity(ErrorResponse.class).getCode());
}
}
@@ -140,7 +213,8 @@ public class TestLanceExceptionMapper extends JerseyTest {
void testUnsupportedMediaType() {
try (Response response =
target("error/consume").request().post(Entity.text("body"))) {
Assertions.assertEquals(415, response.getStatus());
- Assertions.assertEquals(13,
response.readEntity(ErrorResponse.class).getCode());
+ Assertions.assertEquals(
+ ErrorCode.INVALID_INPUT.getCode(),
response.readEntity(ErrorResponse.class).getCode());
}
}
@@ -148,6 +222,8 @@ public class TestLanceExceptionMapper extends JerseyTest {
void testNotAcceptable() {
try (Response response =
target("error").request(MediaType.TEXT_PLAIN).get()) {
Assertions.assertEquals(406, response.getStatus());
+ Assertions.assertEquals(
+ ErrorCode.INVALID_INPUT.getCode(),
response.readEntity(ErrorResponse.class).getCode());
}
}
@@ -158,12 +234,317 @@ public class TestLanceExceptionMapper extends JerseyTest
{
Assertions.assertEquals(
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
response.getStatus());
ErrorResponse entity = response.readEntity(ErrorResponse.class);
- Assertions.assertEquals("assertion failure", entity.getError());
+ Assertions.assertEquals("Internal server error", 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"));
+ Assertions.assertEquals("", entity.getDetail());
+ }
+ }
+
+ /** Verifies unexpected exceptions do not expose internal details in the
response. */
+ @Test
+ public void testInternalFailureDoesNotExposeException() {
+ try (Response response =
+ LanceExceptionMapper.toRESTResponse(
+ "catalog.schema.table", new
RuntimeException("private-backend-detail"))) {
+ Assertions.assertEquals(500, response.getStatus());
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ Assertions.assertEquals("Internal server error", error.getError());
+ Assertions.assertEquals("", error.getDetail());
+ }
+ }
+
+ /** Verifies intentional protocol validation details remain available to
callers. */
+ @Test
+ public void testProtocolValidationDetailsArePreserved() {
+ try (Response response =
+ LanceExceptionMapper.toRESTResponse(
+ "table",
+ new InvalidInputException("Invalid field", "field must be
positive", "table"))) {
+ Assertions.assertEquals(400, response.getStatus());
+ Assertions.assertEquals(
+ "field must be positive", ((ErrorResponse)
response.getEntity()).getDetail());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {400, 401, 403, 405, 406, 409, 415, 429})
+ void testFrameworkClientStatusAndHeadersArePreserved(int status) {
+ try (Response original =
+ Response.status(status)
+ .header("Allow", "GET")
+ .header("WWW-Authenticate", "Basic realm=\"lance\"")
+ .header("Retry-After", "3")
+ .entity("framework entity")
+ .build();
+ Response response =
+ LanceExceptionMapper.toRESTResponse(
+ "table", new WebApplicationException("request rejected",
original))) {
+ Assertions.assertEquals(status, response.getStatus());
+ Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
response.getMediaType());
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ Assertions.assertEquals("request rejected", error.getError());
+ Assertions.assertEquals("table", error.getInstance());
+ Assertions.assertEquals("", error.getDetail());
+ Assertions.assertNotEquals(new InternalException("", "", "").getCode(),
error.getCode());
+ Assertions.assertEquals("GET", response.getHeaderString("Allow"));
+ Assertions.assertEquals(
+ "Basic realm=\"lance\"",
response.getHeaderString("WWW-Authenticate"));
+ Assertions.assertEquals("3", response.getHeaderString("Retry-After"));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "Content-Length", "Content-Encoding", "Content-Language",
"Content-Location",
+ "Content-Range", "Content-Disposition", "Transfer-Encoding", "ETag",
+ "Last-Modified", "Content-MD5", "Digest"
+ })
+ void testFrameworkEntityHeadersAreRemoved(String header) {
+ try (Response original =
+ Response.status(415).header(header,
"original-entity-metadata").entity("old").build();
+ Response response =
+ LanceExceptionMapper.toRESTResponse(
+ "table", new WebApplicationException("request rejected",
original))) {
+ Assertions.assertNull(response.getHeaderString(header));
+ Assertions.assertEquals(415, response.getStatus());
+ Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
response.getMediaType());
+ Assertions.assertEquals(
+ "request rejected", ((ErrorResponse)
response.getEntity()).getError());
+ }
+ }
+
+ @Test
+ void testMappedFrameworkEntityCanBeReadOverHttp() {
+ try (Response response = target("error/framework").request().get()) {
+ Assertions.assertEquals(415, response.getStatus());
+ Assertions.assertNull(response.getHeaderString("Content-Encoding"));
+ Assertions.assertEquals("3", response.getHeaderString("Retry-After"));
+ Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
response.getMediaType());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertEquals("request rejected", error.getError());
+ Assertions.assertEquals("table", error.getInstance());
+ }
+ }
+
+ @Test
+ void testFrameworkServerErrorDoesNotExposeDetails() {
+ try (Response response =
+ LanceExceptionMapper.toRESTResponse(
+ "table", new WebApplicationException("private-backend-detail",
503))) {
+ Assertions.assertEquals(503, response.getStatus());
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ Assertions.assertEquals("Internal server error", error.getError());
+ Assertions.assertEquals("", error.getDetail());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "missing",
+ "v1/table/catalog$schema$table/missing",
+ "v1/namespace/catalog$schema/missing"
+ })
+ void testUnknownRouteUsesUnsupportedEnvelope(String path) {
+ try (Response response =
target(path).request(MediaType.APPLICATION_JSON_TYPE).get()) {
+ Assertions.assertEquals(406, response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.UNSUPPORTED.getCode(),
error.getCode());
+ Assertions.assertEquals("GET " + path, error.getInstance());
+ Assertions.assertTrue(error.getError().contains(error.getInstance()));
+ Assertions.assertEquals("", error.getDetail());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"query", "create_index"})
+ void testUnimplementedOperationsAreClassifiedAsUnsupported(String operation)
{
+ String path = "v1/table/catalog$schema$table/" + operation;
+ try (Response response = target(path).request().post(Entity.json("{}"))) {
+ Assertions.assertEquals(406, response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.UNSUPPORTED.getCode(),
error.getCode());
+ Assertions.assertEquals("POST " + path, error.getInstance());
+ Assertions.assertTrue(error.getError().contains(error.getInstance()));
+ Assertions.assertInstanceOf(
+ UnsupportedOperationException.class,
+ ErrorFactory.fromErrorCode(
+ error.getCode(), error.getError(), error.getDetail(),
error.getInstance()));
+ }
+ }
+
+ @Test
+ void testFramework404IsUnsupportedAndPreservesHeaders() {
+ try (Response original = Response.status(404).header("Retry-After",
"3").build();
+ Response response =
+ LanceExceptionMapper.toRESTResponse(
+ "POST v1/table/table/query", new
WebApplicationException(original))) {
+ Assertions.assertEquals(406, response.getStatus());
+ Assertions.assertEquals("3", response.getHeaderString("Retry-After"));
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ Assertions.assertEquals(ErrorCode.UNSUPPORTED.getCode(),
error.getCode());
+ Assertions.assertTrue(error.getError().contains("POST
v1/table/table/query"));
+ }
+ }
+
+ @Test
+ void testValidationErrorsNeverIncludeStackTraces() {
+ IllegalArgumentException failure =
+ new IllegalArgumentException(
+ "Invalid column name", new
IllegalStateException("private-backend-detail"));
+ try (Response response = LanceExceptionMapper.toRESTResponse("table",
failure)) {
+ Assertions.assertEquals(400, response.getStatus());
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ Assertions.assertEquals(ErrorCode.INVALID_INPUT.getCode(),
error.getCode());
+ Assertions.assertEquals("Invalid column name", error.getError());
+ Assertions.assertEquals("", error.getDetail());
+ }
+ }
+
+ @Test
+ void testLogLevelsSeparateClientErrorsFromServerFailures() {
+ IllegalStateException internal = new
IllegalStateException("private-backend-detail");
+ WebApplicationException framework = new WebApplicationException("framework
failure", 503);
+ List<LogEvent> events =
+ captureMapperLogs(
+ () -> {
+ for (Exception failure :
+ new Exception[] {
+ new IllegalArgumentException("Invalid column name"),
+ new NoSuchTableException("Table missing"),
+ internal,
+ framework
+ }) {
+ try (Response ignored =
LanceExceptionMapper.toRESTResponse("table", failure)) {
+ // Collect logs from the same mapper entry point used by
operation handlers.
+ }
+ }
+ });
+ Assertions.assertEquals(
+ List.of(Level.INFO, Level.INFO, Level.ERROR, Level.ERROR),
+ events.stream().map(LogEvent::getLevel).toList());
+ Assertions.assertNull(events.get(0).getThrown());
+ Assertions.assertNull(events.get(1).getThrown());
+ Assertions.assertSame(internal, events.get(2).getThrown());
+ Assertions.assertSame(framework, events.get(3).getThrown());
+ }
+
+ @Test
+ void testInvalidPathParameterIsInvalidInput() {
+ try (Response response =
target("error/path/not-a-number").request().get()) {
+ Assertions.assertEquals(400, response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.INVALID_INPUT.getCode(),
error.getCode());
+ Assertions.assertTrue(error.getError().contains("version"));
+ }
+ }
+
+ @Test
+ void testTypedEntityNotFoundErrorsKeepTheirCodes() {
+ for (Exception exception :
+ new Exception[] {
+ new TableNotFoundException("table absent", "", "table"),
+ new NamespaceNotFoundException("namespace absent", "", "namespace")
+ }) {
+ try (Response response = LanceExceptionMapper.toRESTResponse("instance",
exception)) {
+ Assertions.assertEquals(404, response.getStatus());
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ Assertions.assertEquals(
+ exception instanceof TableNotFoundException
+ ? ErrorCode.TABLE_NOT_FOUND.getCode()
+ : ErrorCode.NAMESPACE_NOT_FOUND.getCode(),
+ error.getCode());
+ }
+ }
+ }
+
+ @Test
+ void testBackendThrottlingReturns429() {
+ try (Response response =
+ LanceExceptionMapper.toRESTResponse(
+ "table", new ThrottlingException("Slow down", "retry later",
"table"))) {
+ Assertions.assertEquals(429, response.getStatus());
+ ErrorResponse error = (ErrorResponse) response.getEntity();
+ Assertions.assertEquals(ErrorCode.THROTTLING.getCode(), error.getCode());
+ Assertions.assertEquals("retry later", error.getDetail());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"{", "[]",
"{\"mode\":{\"secret\":\"private-value\"}}"})
+ void testInvalidJsonUsesLanceEnvelope(String json) {
+ try (Response response =
target("error/json").request().post(Entity.json(json))) {
+ Assertions.assertEquals(400, response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.INVALID_INPUT.getCode(),
error.getCode());
+ Assertions.assertEquals("Invalid JSON request", error.getError());
+ Assertions.assertEquals("", error.getDetail());
+ }
+ }
+
+ @Test
+ void testValidJsonStillReachesResource() {
+ try (Response response =
+
target("error/json").request().post(Entity.json("{\"mode\":\"create\"}"))) {
+ Assertions.assertEquals(200, response.getStatus());
+ Assertions.assertEquals("create", response.readEntity(String.class));
+ }
+ }
+
+ @Test
+ void testReaderPreservesCauseAndDoesNotTranslateOtherFailures() throws
IOException {
+ ReaderInterceptorContext context =
Mockito.mock(ReaderInterceptorContext.class);
+ JsonParseException invalid = new JsonParseException(null, "private-value");
+ Mockito.when(context.proceed()).thenThrow(invalid);
+ LanceRequestBodyReaderInterceptor reader = new
LanceRequestBodyReaderInterceptor();
+ BadRequestException response =
+ Assertions.assertThrows(BadRequestException.class, () ->
reader.aroundReadFrom(context));
+ Assertions.assertSame(invalid, response.getCause());
+ Assertions.assertEquals("Invalid JSON request", response.getMessage());
+ IOException readFailure = new IOException("stream failed");
+ Mockito.doThrow(readFailure).when(context).proceed();
+ Assertions.assertSame(
+ readFailure,
+ Assertions.assertThrows(IOException.class, () ->
reader.aroundReadFrom(context)));
+ try (Response backend = LanceExceptionMapper.toRESTResponse("table",
invalid)) {
+ Assertions.assertEquals(500, backend.getStatus());
+ Assertions.assertEquals(
+ "Internal server error", ((ErrorResponse)
backend.getEntity()).getError());
+ }
+ }
+
+ private static List<LogEvent> captureMapperLogs(Runnable action) {
+ String name = LanceExceptionMapper.class.getName();
+ LoggerContext context =
+ (LoggerContext)
LogManager.getContext(LanceExceptionMapper.class.getClassLoader(), false);
+ AbstractConfiguration configuration = (AbstractConfiguration)
context.getConfiguration();
+ LoggerConfig previous = configuration.getLoggers().get(name);
+ List<LogEvent> events = new CopyOnWriteArrayList<>();
+ AbstractAppender appender =
+ new AbstractAppender("lanceMapperCapture", null, null, true,
Property.EMPTY_ARRAY) {
+ @Override
+ public void append(LogEvent event) {
+ events.add(event.toImmutable());
+ }
+ };
+ try {
+ appender.start();
+ configuration.addAppender(appender);
+ LoggerConfig logger = new LoggerConfig(name, Level.ALL, false);
+ logger.addAppender(appender, Level.ALL, null);
+ configuration.addLogger(name, logger);
+ context.updateLoggers();
+ action.run();
+ return events;
+ } finally {
+ configuration.removeLogger(name);
+ if (previous != null) {
+ configuration.addLogger(name, previous);
+ }
+ configuration.removeAppender(appender.getName());
+ appender.stop();
+ context.updateLoggers();
}
}
}
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java
index a80c70a06b..2947bbb3ee 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java
@@ -38,6 +38,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
+import java.util.stream.Stream;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Application;
@@ -47,6 +48,8 @@ import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.lance.common.ops.LanceTableOperations;
import org.apache.gravitino.lance.common.ops.NamespaceWrapper;
import org.apache.gravitino.lance.common.utils.LanceConstants;
+import org.apache.gravitino.lance.service.LanceExceptionMapper;
+import org.apache.gravitino.lance.service.LanceRequestBodyReaderInterceptor;
import org.apache.gravitino.rest.RESTUtils;
import org.glassfish.jersey.CommonProperties;
import org.glassfish.jersey.internal.inject.AbstractBinder;
@@ -56,6 +59,10 @@ import org.glassfish.jersey.test.TestProperties;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.lance.namespace.errors.ErrorCode;
import org.lance.namespace.errors.TableNotFoundException;
import org.lance.namespace.model.AlterColumnsEntry;
import org.lance.namespace.model.AlterTableAlterColumnsRequest;
@@ -120,6 +127,8 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
JacksonJaxbJsonProvider provider = new JacksonJaxbJsonProvider();
provider.setMapper(mapper);
resourceConfig.register(provider);
+ resourceConfig.register(LanceExceptionMapper.class);
+ resourceConfig.register(LanceRequestBodyReaderInterceptor.class);
resourceConfig.register(LanceNamespaceOperations.class);
resourceConfig.register(org.apache.gravitino.lance.service.rest.LanceTableOperations.class);
resourceConfig.register(
@@ -140,6 +149,61 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
when(namespaceWrapper.asTableOps()).thenReturn(tableOps);
}
+ @Test
+ void testUnsupportedCreateContentTypeReturns415() {
+ try (Response response =
+
target("/v1/table/catalog$schema$table/create").request().post(Entity.json("{}")))
{
+ Assertions.assertEquals(415, response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertEquals("", error.getDetail());
+ Assertions.assertNotEquals(18, error.getCode());
+ }
+ }
+
+ @Test
+ void testInvalidListLimitReturns400() {
+ try (Response response =
+ target("/v1/namespace/catalog$schema/table/list")
+ .queryParam("limit", "x")
+ .request()
+ .get()) {
+ Assertions.assertEquals(400, response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertTrue(error.getError().contains("limit"));
+ Assertions.assertEquals("", error.getDetail());
+ Assertions.assertNotEquals(18, error.getCode());
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("nullRequestInputs")
+ void testNullRequestInputsReturn400(String path, String json, String
expectedMessage) {
+ Mockito.clearInvocations(namespaceWrapper, namespaceOps, tableOps);
+ try (Response response = target(path).request().post(Entity.json(json))) {
+ Assertions.assertEquals(400, response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.INVALID_INPUT.getCode(),
error.getCode());
+ Assertions.assertTrue(error.getError().contains(expectedMessage),
error.getError());
+ Assertions.assertEquals("", error.getDetail());
+ }
+ Mockito.verifyNoInteractions(namespaceWrapper, namespaceOps, tableOps);
+ }
+
+ private static Stream<Arguments> nullRequestInputs() {
+ String namespace = "/v1/namespace/catalog$schema/";
+ String table = "/v1/table/catalog$schema$table/";
+ return Stream.of(
+ Arguments.of(namespace + "create", "null", "Request body"),
+ Arguments.of(namespace + "drop", "null", "Request body"),
+ Arguments.of(table + "declare", "null", "Request body"),
+ Arguments.of(table + "drop_columns", "null", "Request body"),
+ Arguments.of(table + "alter_columns", "null", "Request body"),
+ Arguments.of(table + "drop_columns", "{\"columns\":null}", "columns"),
+ Arguments.of(table + "alter_columns", "{\"alterations\":null}",
"alterations"),
+ Arguments.of(table + "alter_columns", "{\"alterations\":[null]}",
"alterations[0]"),
+ Arguments.of(table + "drop_columns", "{\"columns\":[null]}", "cannot
be blank"));
+ }
+
@Test
public void testListNamespaces() {
String namespaceId = "ns1.ns2";
@@ -208,10 +272,9 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
Assertions.assertEquals(18, errorResp.getCode());
- Assertions.assertEquals("Test exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
Assertions.assertEquals("ns1.ns2", errorResp.getInstance());
- Assertions.assertNotNull(errorResp.getDetail());
- Assertions.assertTrue(errorResp.getDetail().contains("Test exception"));
// root endpoint should use explicit root identifier instead of delimiter
in error instance
resp =
@@ -262,18 +325,22 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
Assertions.assertEquals(18, errorResp.getCode());
- Assertions.assertEquals("Test exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
}
@Test
public void testDescribeNamespaceOnRootUnsupported() {
Mockito.clearInvocations(namespaceOps);
- Response resp =
-
target("/v1/namespace/describe").request(MediaType.APPLICATION_JSON_TYPE).post(null);
-
- Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(),
resp.getStatus());
- Mockito.verifyNoInteractions(namespaceOps);
+ try (Response resp =
+
target("/v1/namespace/describe").request(MediaType.APPLICATION_JSON_TYPE).post(null))
{
+ Assertions.assertEquals(Response.Status.NOT_ACCEPTABLE.getStatusCode(),
resp.getStatus());
+ ErrorResponse error = resp.readEntity(ErrorResponse.class);
+ Assertions.assertEquals(ErrorCode.UNSUPPORTED.getCode(),
error.getCode());
+ Assertions.assertEquals("POST v1/namespace/describe",
error.getInstance());
+ Mockito.verifyNoInteractions(namespaceOps);
+ }
}
@Test
@@ -321,7 +388,8 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
Assertions.assertEquals(18, errorResp.getCode());
- Assertions.assertEquals("Test exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
}
@Test
@@ -393,7 +461,8 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
Assertions.assertEquals(18, errorResp.getCode());
- Assertions.assertEquals("Test exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
}
@Test
@@ -457,7 +526,8 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
resp.getStatus());
Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
resp.getMediaType());
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
- Assertions.assertEquals("Runtime exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
}
@Test
@@ -513,7 +583,8 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
resp.getStatus());
Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
resp.getMediaType());
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
- Assertions.assertEquals("Runtime exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
}
@Test
@@ -620,7 +691,8 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
resp.getStatus());
Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
resp.getMediaType());
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
- Assertions.assertEquals("Runtime exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
}
@Test
@@ -677,7 +749,8 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
resp.getStatus());
Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
resp.getMediaType());
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
- Assertions.assertEquals("Runtime exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
}
@Test
@@ -999,6 +1072,7 @@ public class TestLanceNamespaceOperations extends
JerseyTest {
Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
resp.getStatus());
Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
resp.getMediaType());
ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
- Assertions.assertEquals("Runtime exception", errorResp.getError());
+ Assertions.assertEquals("Internal server error", errorResp.getError());
+ Assertions.assertEquals("", errorResp.getDetail());
}
}