Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,12 @@ public void abort()
catch (Exception ex) {
log.warn(ex, "[%s] Exception thrown while processing message, closing channel.", requestDesc);

// Complete the future with the exception itself rather than null: a handler (e.g. handleResponse)
// may throw a specific, meaningful exception (query capacity exceeded, interrupted, etc.) and
// completing with null discards it, leaving callers with a successful-looking null result instead
// of the real failure.
if (!retVal.isDone()) {
retVal.set(null);
retVal.setException(ex);
}
channel.close();
channelResourceContainer.returnResource();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import org.apache.druid.java.util.http.client.response.StatusResponseHolder;
import org.apache.druid.query.Queries;
import org.apache.druid.query.Query;
import org.apache.druid.query.QueryCapacityExceededException;
import org.apache.druid.query.QueryContext;
import org.apache.druid.query.QueryMetrics;
import org.apache.druid.query.QueryPlus;
Expand Down Expand Up @@ -243,6 +244,71 @@ public ClientResponse<InputStream> handleResponse(HttpResponse response, Traffic
{
trafficCopRef.set(trafficCop);
checkQueryTimeout();
// Handle 429/503 HTML before JSON parse to avoid JsonParseException 0x3c ('<')
final int statusCode = response.getStatus().getCode();
final String contentType = response.headers().get(HttpHeaders.Names.CONTENT_TYPE);
final ChannelBuffer contentBuffer = response.getContent();
boolean isHtmlContentType = contentType != null && StringUtils.toLowerCase(contentType).contains("text/html");
boolean isHtmlBody = false;
if (contentBuffer.readableBytes() > 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Chunked HTML can bypass prefix sniffing

handleResponse inspects only the initial response buffer, but later chunked bytes reach handleChunk without persistent classification. A chunked 503 HTML response whose first buffer is empty or lacks the identifying prefix can therefore reach JsonParserIterator and fail as a JSON parse error instead of producing the intended capacity error. Carry the sniff state across chunks or buffer and classify the prefix before handing the stream to the parser.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Chunked HTML responses can bypass classification

When a chunked response has an empty initial buffer, this check leaves isHtmlBody false. Subsequent handleChunk calls only enqueue bytes and do not retain or inspect the response prefix, so a later chunk beginning with HTML is fed to JsonParserIterator and becomes a generic JSON parse/interruption error. Persist the classification across chunks or buffer the prefix before enqueueing, and add an empty-initial-chunk test.

int readerIndex = contentBuffer.readerIndex();
int readable = contentBuffer.readableBytes();
for (int i = 0; i < readable; i++) {
byte b = contentBuffer.getByte(readerIndex + i);
if (b == ' ' || b == '\n' || b == '\r' || b == '\t') {
continue;
}
if (b == '<') {
isHtmlBody = true;
} else if (b != '{' && b != '[') {
// Not JSON start, but only treat '<' as HTML indicator
}
break;
}
}
// A 429/503 is only treated as capacity-exceeded here when the body is confirmed HTML/non-JSON; a
// 429/503 carrying a proper JSON error body (e.g. a genuine QueryCapacityExceededException from a data
// server) falls through to the normal JSON error handling below, which preserves the real error message.
if ((statusCode == 429 || statusCode == 503) && (isHtmlContentType || isHtmlBody)) {
String msg = StringUtils.format(
"Query[%s] url[%s] failed with status[%s] [%s]",
query.getId(),
url,
statusCode,
response.getStatus().getReasonPhrase()
);
if (contentBuffer.readableBytes() > 0) {
int len = Math.min(contentBuffer.readableBytes(), 512);
byte[] previewBytes = new byte[len];
contentBuffer.getBytes(contentBuffer.readerIndex(), previewBytes);
String preview = StringUtils.fromUtf8(previewBytes);
preview = preview.substring(0, Math.min(preview.length(), 256));
msg = StringUtils.format("%s: %s", msg, preview);
}
throw QueryCapacityExceededException.withErrorMessageAndResolvedHost(msg);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Handler exceptions complete the Netty future with null

With production NettyHttpClient, this throw occurs before the handler response is assigned. Netty then completes the future successfully with null and closes the channel, so JsonParserIterator treats the result as a possible scatter-gather limit and raises ResourceLimitExceededException. The intended capacity/interruption error is therefore lost. Propagate the exception through the future or return an error-bearing response, and test with the real Netty client.

}
if (isHtmlContentType || isHtmlBody) {
int len = Math.min(contentBuffer.readableBytes(), 512);
byte[] previewBytes = new byte[len];
if (len > 0) {
contentBuffer.getBytes(contentBuffer.readerIndex(), previewBytes);
}
String preview = len > 0 ? StringUtils.fromUtf8(previewBytes) : "";
preview = preview.substring(0, Math.min(preview.length(), 256));
throw new org.apache.druid.query.QueryInterruptedException(
org.apache.druid.query.QueryException.UNKNOWN_EXCEPTION_ERROR_CODE,
StringUtils.format(
"Query[%s] url[%s] returned HTML response instead of JSON with status[%s] contentType[%s] preview[%s]",
query.getId(),
url,
statusCode,
contentType,
preview
),
org.apache.druid.query.QueryInterruptedException.class.getName(),
host
);
}
checkTotalBytesLimit(response.getContent().readableBytes());

log.debug("Initial response from url[%s] for queryId[%s]", url, query.getId());
Expand Down
Loading