This is an automated email from the ASF dual-hosted git repository.

FrankChen021 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new def6c1735bb fix: Preserve user error classification from data servers 
(#20633)
def6c1735bb is described below

commit def6c1735bbcc9e34564c908c83a67de0a381886
Author: P_K <[email protected]>
AuthorDate: Wed Oct 21 18:09:40 2026 +0530

    fix: Preserve user error classification from data servers (#20633)
    
    * Fix user error propagation from data servers
    
    * fix: Preserve DruidException propagation and existing exception behavior
---
 .../epinephelinae/GroupByMergingQueryRunner.java   |  3 +
 .../druid/segment/projections/Projections.java     |  3 +
 .../groupby/GroupByQueryRunnerFailureTest.java     | 49 +++++++++++++
 .../druid/segment/CursorFactoryProjectionTest.java | 21 ++++++
 .../apache/druid/client/JsonParserIterator.java    | 28 +++++---
 .../druid/client/JsonParserIteratorTest.java       | 84 ++++++++++++++++++++++
 .../druid/sql/calcite/CalciteSubqueryTest.java     |  2 +-
 7 files changed, 181 insertions(+), 9 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/GroupByMergingQueryRunner.java
 
b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/GroupByMergingQueryRunner.java
index 699f659cede..791629bcd2c 100644
--- 
a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/GroupByMergingQueryRunner.java
+++ 
b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/GroupByMergingQueryRunner.java
@@ -406,6 +406,9 @@ public class GroupByMergingQueryRunner implements 
QueryRunner<ResultRow>
       if (cause instanceof TimeoutException) {
         throw new QueryTimeoutException(StringUtils.nonStrictFormat("Query 
timeout, cancelling pending results for query [%s]. Per-segment timeout 
exceeded.", query.getId()));
       }
+      if (cause instanceof DruidException) {
+        throw (DruidException) cause;
+      }
       throw new RuntimeException(e);
     }
   }
diff --git 
a/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
 
b/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
index 960adbb7107..66f9bead54e 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
@@ -143,6 +143,9 @@ public class Projections
       }
     }
     if (name != null) {
+      if (projections.stream().noneMatch(projection -> 
name.equals(projection.getSchema().getName()))) {
+        throw InvalidInput.exception("Projection[%s] does not exist", name);
+      }
       throw InvalidInput.exception("Projection[%s] specified, but does not 
satisfy query", name);
     }
     if 
(cursorBuildSpec.getQueryContext().getOrDefault(QueryContextParameters.FORCE_PROJECTIONS))
 {
diff --git 
a/processing/src/test/java/org/apache/druid/query/groupby/GroupByQueryRunnerFailureTest.java
 
b/processing/src/test/java/org/apache/druid/query/groupby/GroupByQueryRunnerFailureTest.java
index bb4a687bfc3..2357053e39d 100644
--- 
a/processing/src/test/java/org/apache/druid/query/groupby/GroupByQueryRunnerFailureTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/groupby/GroupByQueryRunnerFailureTest.java
@@ -26,6 +26,8 @@ import com.google.common.collect.ImmutableList;
 import org.apache.druid.collections.CloseableDefaultBlockingPool;
 import org.apache.druid.collections.CloseableStupidPool;
 import org.apache.druid.collections.ReferenceCountingResourceHolder;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.error.InvalidInput;
 import org.apache.druid.java.util.common.concurrent.Execs;
 import org.apache.druid.java.util.common.granularity.Granularities;
 import org.apache.druid.java.util.common.guava.Sequences;
@@ -340,6 +342,53 @@ public class GroupByQueryRunnerFailureTest
     Assertions.assertEquals("Query [test] timed out", ex.getMessage());
   }
 
+  @Test
+  @org.junit.jupiter.api.Timeout(value = 60_000L, unit = 
java.util.concurrent.TimeUnit.MILLISECONDS)
+  public void testInvalidInputExceptionOnQueryable_multiThreaded()
+  {
+    final GroupByQuery query = GroupByQuery
+        .builder()
+        .setDataSource(QueryRunnerTestHelper.DATA_SOURCE)
+        .setQuerySegmentSpec(QueryRunnerTestHelper.FIRST_TO_THIRD)
+        .setDimensions(new DefaultDimensionSpec("quality", "alias"))
+        .setAggregatorSpecs(new LongSumAggregatorFactory("rows", "rows"))
+        .setGranularity(Granularities.ALL)
+        .queryId("test-invalid-input")
+        .build();
+
+    GroupByQueryRunnerFactory factory = makeQueryRunnerFactory(
+        GroupByQueryRunnerTest.DEFAULT_MAPPER,
+        new GroupByQueryConfig()
+        {
+          @Override
+          public boolean isSingleThreaded()
+          {
+            return false;
+          }
+        }
+    );
+
+    DruidException expected = InvalidInput.exception(
+        "Projection[p] specified, but does not satisfy query"
+    );
+
+    QueryRunner<ResultRow> failingRunner = (queryPlus, responseContext) -> {
+      throw expected;
+    };
+
+    QueryRunner<ResultRow> mergeRunners = factory.mergeRunners(
+        processingPool,
+        List.of(runner, failingRunner)
+    );
+
+    DruidException actual = Assertions.assertThrows(
+        DruidException.class,
+        () -> GroupByQueryRunnerTestHelper.runQuery(factory, mergeRunners, 
query)
+    );
+
+    Assertions.assertSame(expected, actual);
+  }
+
   @Test
   @org.junit.jupiter.api.Timeout(value = 60_000L, unit = 
java.util.concurrent.TimeUnit.MILLISECONDS)
   public void testTimeoutExceptionOnQueryable_multiThreaded()
diff --git 
a/processing/src/test/java/org/apache/druid/segment/CursorFactoryProjectionTest.java
 
b/processing/src/test/java/org/apache/druid/segment/CursorFactoryProjectionTest.java
index ae3d8784a6f..ade6fdd3473 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/CursorFactoryProjectionTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/CursorFactoryProjectionTest.java
@@ -1448,6 +1448,27 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
     );
   }
 
+  @Test
+  public void testTimeseriesQueryMissingNamedProjection()
+  {
+    final TimeseriesQuery query = Druids.newTimeseriesQueryBuilder()
+                                        .dataSource("test")
+                                        
.intervals(ImmutableList.of(Intervals.ETERNITY))
+                                        .granularity(Granularities.DAY)
+                                        .aggregators(new 
LongSumAggregatorFactory("c_sum", "c"))
+                                        
.context(QueryContext.ofMap(QueryContextParameters.USE_PROJECTION, 
"does_not_exist"))
+                                        .build();
+
+    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, null);
+    DruidException e = Assertions.assertThrows(
+        DruidException.class,
+        () -> projectionsCursorFactory.makeCursorHolder(buildSpec)
+    );
+    Assertions.assertEquals(DruidException.Persona.USER, e.getTargetPersona());
+    Assertions.assertEquals(DruidException.Category.INVALID_INPUT, 
e.getCategory());
+    Assertions.assertEquals("Projection[does_not_exist] does not exist", 
e.getMessage());
+  }
+
   @Test
   public void testTimeseriesQueryOrderByNotCompatibleWithProjection()
   {
diff --git 
a/server/src/main/java/org/apache/druid/client/JsonParserIterator.java 
b/server/src/main/java/org/apache/druid/client/JsonParserIterator.java
index b590ca00ba4..362b56dae8e 100644
--- a/server/src/main/java/org/apache/druid/client/JsonParserIterator.java
+++ b/server/src/main/java/org/apache/druid/client/JsonParserIterator.java
@@ -22,8 +22,10 @@ package org.apache.druid.client;
 import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.core.JsonToken;
 import com.fasterxml.jackson.core.ObjectCodec;
+import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.druid.error.ErrorResponse;
 import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.logger.Logger;
@@ -41,6 +43,7 @@ import org.apache.druid.utils.CloseableUtils;
 import javax.annotation.Nullable;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.Map;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
@@ -202,7 +205,7 @@ public class JsonParserIterator<T> implements 
CloseableIterator<T>
           readNextToken();
           objectCodec = jp.getCodec();
         } else if (nextToken == JsonToken.START_OBJECT) {
-          throw convertException(readStructuredError());
+          throw readStructuredError();
         } else {
           String errMsg = jp.getValueAsString();
           if (errMsg != null) {
@@ -249,16 +252,25 @@ public class JsonParserIterator<T> implements 
CloseableIterator<T>
   }
 
   /**
-   * Deserializes the structured error body {@link #init()} found in place of 
a result array, with the same
-   * unwrapped-{@link QueryException} handling as {@link #createParser}: the 
body can span chunks, so a later-chunk
-   * failure can surface from inside this read rather than from the 
deserialized value. An exception that comes off
-   * the stream is converted here and thrown; a QueryException that was 
successfully deserialized is returned for the
-   * caller to convert, so neither path is converted twice.
+   * Reads a structured error from a data server. Modern DruidException 
responses must retain their original
+   * persona, category, and error code. Legacy QueryException responses still 
use the existing conversion logic.
+   *
+   * A QueryException can also arrive directly from the transport while a 
response body is being read; normalize
+   * those exceptions to this iterator's host, as in the other parser methods.
    */
-  private QueryException readStructuredError() throws IOException
+  private RuntimeException readStructuredError() throws IOException
   {
     try {
-      return jp.getCodec().readValue(jp, QueryException.class);
+      final Map<String, Object> error = jp.getCodec().readValue(
+          jp,
+          new TypeReference<Map<String, Object>>()
+          {
+          }
+      );
+      if ("druidException".equals(error.get("error"))) {
+        return ErrorResponse.fromMap(error).getUnderlyingException();
+      }
+      return convertException(objectMapper.convertValue(error, 
QueryException.class));
     }
     catch (QueryException e) {
       throw convertException(e);
diff --git 
a/server/src/test/java/org/apache/druid/client/JsonParserIteratorTest.java 
b/server/src/test/java/org/apache/druid/client/JsonParserIteratorTest.java
index f454fe6f5bf..cc46b2a695e 100644
--- a/server/src/test/java/org/apache/druid/client/JsonParserIteratorTest.java
+++ b/server/src/test/java/org/apache/druid/client/JsonParserIteratorTest.java
@@ -22,9 +22,13 @@ package org.apache.druid.client;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.smile.SmileFactory;
 import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.AbstractFuture;
 import com.google.common.util.concurrent.Futures;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.error.ErrorResponse;
+import org.apache.druid.error.InvalidInput;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.concurrent.Execs;
@@ -47,6 +51,7 @@ import org.mockito.Mockito;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
@@ -58,6 +63,85 @@ public class JsonParserIteratorTest
   private static final String HOST = "host";
   private static final ObjectMapper OBJECT_MAPPER = new DefaultObjectMapper();
 
+  @Test
+  public void testModernDruidExceptionJsonResponse() throws IOException
+  {
+    assertModernErrorResponse(OBJECT_MAPPER);
+  }
+
+  @Test
+  public void testModernDruidExceptionSmileResponse() throws IOException
+  {
+    assertModernErrorResponse(new ObjectMapper(new SmileFactory()));
+  }
+
+  @Test
+  public void testLegacyQueryTimeoutSmileResponse() throws IOException
+  {
+    final ObjectMapper mapper = new ObjectMapper(new SmileFactory());
+    final JsonParserIterator<Object> iterator = new JsonParserIterator<>(
+        JAVA_TYPE,
+        Futures.immediateFuture(new 
ByteArrayInputStream(mapper.writeValueAsBytes(
+            Map.of(
+                "error", QueryException.QUERY_TIMEOUT_ERROR_CODE,
+                "errorMessage", "legacy timeout",
+                "errorClass", QueryTimeoutException.class.getName(),
+                "host", "data-server-host"
+            )
+        ))),
+        URL,
+        null,
+        HOST,
+        mapper
+    );
+
+    QueryTimeoutException actual = 
Assertions.assertThrows(QueryTimeoutException.class, iterator::hasNext);
+    Assertions.assertEquals("legacy timeout", actual.getMessage());
+    Assertions.assertEquals(HOST, actual.getHost());
+  }
+
+  @Test
+  public void testMalformedModernErrorResponseIsDefensive() throws IOException
+  {
+    final JsonParserIterator<Object> iterator = new JsonParserIterator<>(
+        JAVA_TYPE,
+        Futures.immediateFuture(new 
ByteArrayInputStream(OBJECT_MAPPER.writeValueAsBytes(
+            Map.of("error", "druidException", "errorMessage", "missing 
persona, category, and error code")
+        ))),
+        URL,
+        null,
+        HOST,
+        OBJECT_MAPPER
+    );
+
+    DruidException actual = Assertions.assertThrows(DruidException.class, 
iterator::hasNext);
+    Assertions.assertEquals(DruidException.Category.DEFENSIVE, 
actual.getCategory());
+  }
+
+  private static void assertModernErrorResponse(ObjectMapper mapper) throws 
IOException
+  {
+    DruidException expected = InvalidInput.exception("Projection[p] specified, 
but does not satisfy query");
+    expected.withContext("projection", "p");
+
+    JsonParserIterator<Object> iterator = new JsonParserIterator<>(
+        JAVA_TYPE,
+        Futures.immediateFuture(new ByteArrayInputStream(
+            mapper.writeValueAsBytes(new ErrorResponse(expected).getAsMap())
+        )),
+        URL,
+        null,
+        HOST,
+        mapper
+    );
+
+    DruidException actual = Assertions.assertThrows(DruidException.class, 
iterator::hasNext);
+    Assertions.assertEquals(expected.getErrorCode(), actual.getErrorCode());
+    Assertions.assertEquals(DruidException.Persona.USER, 
actual.getTargetPersona());
+    Assertions.assertEquals(DruidException.Category.INVALID_INPUT, 
actual.getCategory());
+    Assertions.assertEquals(expected.getMessage(), actual.getMessage());
+    Assertions.assertEquals("p", actual.getContext().get("projection"));
+  }
+
   @SuppressWarnings("ResultOfMethodCallIgnored")
   public static class FutureExceptionTest
   {
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteSubqueryTest.java 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteSubqueryTest.java
index 256a794fb78..61ab7f3bbed 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteSubqueryTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteSubqueryTest.java
@@ -1317,7 +1317,7 @@ public class CalciteSubqueryTest extends 
BaseCalciteQueryTest
     testQueryThrows(
         "SELECT  count(*) FROM wikipedia where channel = (select channel from 
wikipedia order by __time desc LIMIT 2 OFFSET 6)",
         RuntimeException.class,
-        "java.util.concurrent.ExecutionException: 
org.apache.druid.error.DruidException: Subquery expression returned more than 
one row"
+            "Subquery expression returned more than one row"
     );
   }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to