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 42f49fd80a9 fix: return 400 for native queries that fail JSON binding 
(#20830)
42f49fd80a9 is described below

commit 42f49fd80a9e31a35e254e554507423dab355f06
Author: Frank Chen <[email protected]>
AuthorDate: Sat Nov 7 11:08:38 2026 +0800

    fix: return 400 for native queries that fail JSON binding (#20830)
    
    * fix: return 400 for native queries that fail JSON binding
    
    QueryResource#readQuery converts JsonParseException and 
ValueInstantiationException
    into BadJsonQueryException, but other JsonMappingExceptions raised for 
well-formed JSON
    (e.g. an unknown queryType, or a field of the wrong type) fell through to 
the catch-all
    in doPost. The broker logged a full stack trace at WARN and returned 500
    "Unhandled exception made it to the top" without saying what was wrong.
    
    Catch JsonMappingException as well, and report it as a 400 "Json parse 
failed" with the
    original message and the reference chain of the offending field.
    
    * Rename getErrorMessage overload to avoid confusing overloading
    
    * Add BadJsonQueryException unit tests for JsonMappingException
    
    * Test JSON binding errors with real queries instead of hand-built 
exceptions
    
    * Keep InvalidDefinitionException as a server error when reading native 
queries
    
    * Keep server errors wrapped in JsonMappingException as 500 when reading 
native queries
---
 .../apache/druid/query/BadJsonQueryException.java  |  15 +++
 .../druid/query/BadJsonQueryExceptionTest.java     |  53 +++++++++
 .../org/apache/druid/server/QueryResource.java     |  35 ++++++
 .../org/apache/druid/server/QueryResourceTest.java | 122 +++++++++++++++++++++
 4 files changed, 225 insertions(+)

diff --git 
a/processing/src/main/java/org/apache/druid/query/BadJsonQueryException.java 
b/processing/src/main/java/org/apache/druid/query/BadJsonQueryException.java
index 7722bb70be5..c89f45b8864 100644
--- a/processing/src/main/java/org/apache/druid/query/BadJsonQueryException.java
+++ b/processing/src/main/java/org/apache/druid/query/BadJsonQueryException.java
@@ -22,6 +22,7 @@ package org.apache.druid.query;
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.databind.JsonMappingException;
 import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
 import com.google.common.base.Strings;
 
@@ -39,6 +40,11 @@ public class BadJsonQueryException extends BadQueryException
     this(e, JSON_PARSE_ERROR_CODE, getErrorMessage(e), e.getClass().getName());
   }
 
+  public BadJsonQueryException(JsonMappingException e)
+  {
+    this(e, JSON_PARSE_ERROR_CODE, getMappingErrorMessage(e), 
e.getClass().getName());
+  }
+
   @JsonCreator
   private BadJsonQueryException(
       @JsonProperty("error") String errorCode,
@@ -75,4 +81,13 @@ public class BadJsonQueryException extends BadQueryException
     }
     return "Invalid native query: " + errorMessage;
   }
+
+  private static String getMappingErrorMessage(JsonMappingException e)
+  {
+    final String path = e.getPathReference();
+    if (Strings.isNullOrEmpty(path)) {
+      return "Invalid native query: " + e.getOriginalMessage();
+    }
+    return "Invalid native query: " + e.getOriginalMessage() + " (through 
reference chain: " + path + ")";
+  }
 }
diff --git 
a/processing/src/test/java/org/apache/druid/query/BadJsonQueryExceptionTest.java
 
b/processing/src/test/java/org/apache/druid/query/BadJsonQueryExceptionTest.java
index 2e73eaed002..350d79a3266 100644
--- 
a/processing/src/test/java/org/apache/druid/query/BadJsonQueryExceptionTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/BadJsonQueryExceptionTest.java
@@ -19,8 +19,12 @@
 
 package org.apache.druid.query;
 
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
+import com.fasterxml.jackson.databind.exc.MismatchedInputException;
 import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
 import com.fasterxml.jackson.databind.type.TypeFactory;
+import org.apache.druid.segment.TestHelper;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -61,6 +65,55 @@ public class BadJsonQueryExceptionTest
     );
   }
 
+  @Test
+  public void testUnknownQueryType()
+  {
+    final BadJsonQueryException exception = readBadQuery(
+        
"{\"queryType\":\"noSuchQueryType\",\"dataSource\":\"ds\",\"intervals\":[\"2000/P1D\"]}"
+    );
+
+    Assertions.assertEquals(QueryException.JSON_PARSE_ERROR_CODE, 
exception.getErrorCode());
+    Assertions.assertEquals(InvalidTypeIdException.class.getName(), 
exception.getErrorClass());
+    // Fails at the root, so there is no reference chain to report.
+    Assertions.assertTrue(exception.getMessage().startsWith("Invalid native 
query: "), exception.getMessage());
+    Assertions.assertTrue(
+        exception.getMessage().contains("Could not resolve type id 
'noSuchQueryType'"),
+        exception.getMessage()
+    );
+    Assertions.assertFalse(exception.getMessage().contains("reference chain"), 
exception.getMessage());
+  }
+
+  @Test
+  public void testFieldOfWrongType()
+  {
+    final BadJsonQueryException exception = readBadQuery(
+        
"{\"queryType\":\"timeseries\",\"dataSource\":\"ds\",\"intervals\":[\"2000/P1D\"],\"aggregations\":\"rows\"}"
+    );
+
+    Assertions.assertEquals(QueryException.JSON_PARSE_ERROR_CODE, 
exception.getErrorCode());
+    Assertions.assertEquals(MismatchedInputException.class.getName(), 
exception.getErrorClass());
+    Assertions.assertTrue(exception.getMessage().startsWith("Invalid native 
query: "), exception.getMessage());
+    Assertions.assertTrue(
+        exception.getMessage().endsWith(
+            "(through reference chain: 
org.apache.druid.query.timeseries.TimeseriesQuery[\"aggregations\"])"
+        ),
+        exception.getMessage()
+    );
+  }
+
+  /**
+   * Reads a query that is well-formed JSON but cannot be bound to a {@link 
Query}, and wraps the resulting
+   * {@link JsonMappingException} the same way QueryResource does.
+   */
+  private static BadJsonQueryException readBadQuery(final String json)
+  {
+    final JsonMappingException e = Assertions.assertThrows(
+        JsonMappingException.class,
+        () -> TestHelper.JSON_MAPPER.readValue(json, Query.class)
+    );
+    return new BadJsonQueryException(e);
+  }
+
   private static ValueInstantiationException 
valueInstantiationException(Throwable cause)
   {
     return ValueInstantiationException.from(
diff --git a/server/src/main/java/org/apache/druid/server/QueryResource.java 
b/server/src/main/java/org/apache/druid/server/QueryResource.java
index 97679d1edcf..7d15050437c 100644
--- a/server/src/main/java/org/apache/druid/server/QueryResource.java
+++ b/server/src/main/java/org/apache/druid/server/QueryResource.java
@@ -21,8 +21,11 @@ package org.apache.druid.server;
 
 import com.fasterxml.jackson.core.JsonGenerator;
 import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
 import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
 import com.fasterxml.jackson.jaxrs.smile.SmileMediaTypes;
 import com.google.common.annotations.VisibleForTesting;
@@ -287,6 +290,25 @@ public class QueryResource implements 
QueryCountStatsProvider
     void incrementTimedOut();
   }
 
+  /**
+   * Whether {@code e} wraps an exception that Druid code threw while binding 
a query, such as a
+   * {@link NullPointerException} from a deserializer or setter, rather than a 
rejection of the input. Jackson wraps
+   * those in a plain {@link JsonMappingException} with the original exception 
as its cause, while its own input
+   * errors are subclasses such as {@link 
com.fasterxml.jackson.databind.exc.MismatchedInputException}.
+   */
+  private static boolean isServerErrorDuringBinding(final JsonMappingException 
e)
+  {
+    final Throwable cause = e.getCause();
+    if (e.getClass() != JsonMappingException.class || cause == null) {
+      return false;
+    }
+    if (cause instanceof IllegalArgumentException || cause instanceof 
JsonProcessingException) {
+      return false;
+    }
+    return !(cause instanceof DruidException
+             && ((DruidException) cause).getTargetPersona() == 
DruidException.Persona.USER);
+  }
+
   private Query<?> readQuery(
       final HttpServletRequest req,
       final InputStream in,
@@ -300,6 +322,19 @@ public class QueryResource implements 
QueryCountStatsProvider
     catch (ValueInstantiationException e) {
       throw new BadJsonQueryException(e);
     }
+    catch (InvalidDefinitionException e) {
+      // A query class that Jackson cannot bind to is a server-side defect, 
not a bad request, so leave it to the
+      // catch-all in doPost to log and return as a 500.
+      throw e;
+    }
+    catch (JsonMappingException e) {
+      if (isServerErrorDuringBinding(e)) {
+        // Leave it to the catch-all in doPost to log and return as a 500.
+        throw e;
+      }
+      // Well-formed JSON that cannot be bound to a query, such as an unknown 
query type or a field of the wrong type.
+      throw new BadJsonQueryException(e);
+    }
     catch (JsonParseException e) {
       throw new BadJsonQueryException(e);
     }
diff --git 
a/server/src/test/java/org/apache/druid/server/QueryResourceTest.java 
b/server/src/test/java/org/apache/druid/server/QueryResourceTest.java
index b37593b14ff..5d228b17adb 100644
--- a/server/src/test/java/org/apache/druid/server/QueryResourceTest.java
+++ b/server/src/test/java/org/apache/druid/server/QueryResourceTest.java
@@ -19,10 +19,18 @@
 
 package org.apache.druid.server;
 
+import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
+import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
 import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
+import com.fasterxml.jackson.databind.jsontype.NamedType;
+import com.fasterxml.jackson.databind.module.SimpleModule;
 import com.fasterxml.jackson.jaxrs.smile.SmileMediaTypes;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -42,9 +50,11 @@ import org.apache.druid.guice.GuiceInjectors;
 import org.apache.druid.guice.annotations.Smile;
 import org.apache.druid.jackson.DefaultObjectMapper;
 import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.concurrent.Execs;
+import org.apache.druid.java.util.common.granularity.Granularity;
 import org.apache.druid.java.util.common.guava.Accumulator;
 import org.apache.druid.java.util.common.guava.BaseSequence;
 import org.apache.druid.java.util.common.guava.LazySequence;
@@ -1054,6 +1064,83 @@ public class QueryResourceTest
     Assertions.assertEquals("Invalid native query: dataSource can't be null", 
e.getMessage());
   }
 
+  @Test
+  public void testQueryThatFailsJsonBinding() throws IOException
+  {
+    // Well-formed JSON that cannot be bound to a query is a user error, not a 
500 from the catch-all handler.
+    // The error message itself is covered by BadJsonQueryExceptionTest.
+    final Response response = queryResource.doPost(
+        new ByteArrayInputStream(
+            StringUtils.replace(SIMPLE_TIMESERIES_QUERY, "\"timeseries\"", 
"\"noSuchQueryType\"")
+                       .getBytes(StandardCharsets.UTF_8)
+        ),
+        null /*pretty*/,
+        testServletRequest
+    );
+    Assertions.assertNotNull(response);
+    Assertions.assertEquals(Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+    final QueryException e = jsonMapper.readValue((byte[]) 
response.getEntity(), QueryException.class);
+    Assertions.assertEquals(QueryException.JSON_PARSE_ERROR_CODE, 
e.getErrorCode());
+    Assertions.assertEquals(InvalidTypeIdException.class.getName(), 
e.getErrorClass());
+  }
+
+  @Test
+  public void testQueryWithBrokenDefinitionIsServerError() throws IOException
+  {
+    // A registered query type that Jackson cannot instantiate is a 
server-side defect, so it must not be reported
+    // as a bad request.
+    jsonMapper = jsonMapper.copy();
+    jsonMapper.registerSubtypes(new NamedType(BrokenQuery.class, 
"brokenQuery"));
+    queryResource = createQueryResource(ResponseContextConfig.newConfig(true));
+    final MockHttpServletResponse asyncResponse = 
MockHttpServletResponse.forRequest(testServletRequest);
+    testServletRequest.remoteHost = "localhost";
+
+    final Response response = queryResource.doPost(
+        new ByteArrayInputStream(
+            StringUtils.replace(SIMPLE_TIMESERIES_QUERY, "\"timeseries\"", 
"\"brokenQuery\"")
+                       .getBytes(StandardCharsets.UTF_8)
+        ),
+        null /*pretty*/,
+        testServletRequest
+    );
+    Assertions.assertNull(response);
+    Assertions.assertEquals(Status.INTERNAL_SERVER_ERROR.getStatusCode(), 
asyncResponse.getStatus());
+    final QueryException e = 
jsonMapper.readValue(asyncResponse.baos.toByteArray(), QueryException.class);
+    Assertions.assertEquals(QueryException.UNKNOWN_EXCEPTION_ERROR_CODE, 
e.getErrorCode());
+    Assertions.assertEquals(InvalidDefinitionException.class.getName(), 
e.getErrorClass());
+  }
+
+  @Test
+  public void testBadInputRejectedByDeserializerIsBadRequest() throws 
IOException
+  {
+    for (final RuntimeException exception : List.of(
+        new IAE("bad granularity"),
+        InvalidInput.exception("bad granularity")
+    )) {
+      final Response response = 
postWithFailingGranularityDeserializer(exception);
+      Assertions.assertNotNull(response);
+      Assertions.assertEquals(Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      final QueryException e = jsonMapper.readValue((byte[]) 
response.getEntity(), QueryException.class);
+      Assertions.assertEquals(QueryException.JSON_PARSE_ERROR_CODE, 
e.getErrorCode());
+      Assertions.assertEquals(JsonMappingException.class.getName(), 
e.getErrorClass());
+      Assertions.assertTrue(e.getMessage().contains("bad granularity"), 
e.getMessage());
+    }
+  }
+
+  @Test
+  public void testBugInDeserializerIsServerError() throws IOException
+  {
+    final MockHttpServletResponse asyncResponse = 
MockHttpServletResponse.forRequest(testServletRequest);
+    testServletRequest.remoteHost = "localhost";
+
+    final Response response = postWithFailingGranularityDeserializer(new 
NullPointerException());
+    Assertions.assertNull(response);
+    Assertions.assertEquals(Status.INTERNAL_SERVER_ERROR.getStatusCode(), 
asyncResponse.getStatus());
+    final QueryException e = 
jsonMapper.readValue(asyncResponse.baos.toByteArray(), QueryException.class);
+    Assertions.assertEquals(QueryException.UNKNOWN_EXCEPTION_ERROR_CODE, 
e.getErrorCode());
+    Assertions.assertEquals(JsonMappingException.class.getName(), 
e.getErrorClass());
+  }
+
   @Test
   public void testResourceLimitExceeded() throws IOException
   {
@@ -2119,4 +2206,39 @@ public class QueryResourceTest
   {
     return queryResource.doPost(new ByteArrayInputStream(bytes), null, req);
   }
+
+  /**
+   * Posts {@link #SIMPLE_TIMESERIES_QUERY} with a granularity deserializer 
that throws {@code exception}. Jackson wraps
+   * it in a plain {@link JsonMappingException}, as it does for any exception 
thrown while deserializing a property.
+   */
+  private Response postWithFailingGranularityDeserializer(final 
RuntimeException exception) throws IOException
+  {
+    jsonMapper = jsonMapper.copy();
+    jsonMapper.registerModule(
+        new SimpleModule().addDeserializer(
+            Granularity.class,
+            new JsonDeserializer<>()
+            {
+              @Override
+              public Granularity deserialize(final JsonParser p, final 
DeserializationContext ctxt)
+              {
+                throw exception;
+              }
+            }
+        )
+    );
+    queryResource = createQueryResource(ResponseContextConfig.newConfig(true));
+    return queryResource.doPost(
+        new 
ByteArrayInputStream(SIMPLE_TIMESERIES_QUERY.getBytes(StandardCharsets.UTF_8)),
+        null /*pretty*/,
+        testServletRequest
+    );
+  }
+
+  /**
+   * A query type with no creator, which Jackson rejects with an {@link 
InvalidDefinitionException}.
+   */
+  private abstract static class BrokenQuery implements Query<Object>
+  {
+  }
 }


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

Reply via email to