diff --git a/plugin/sql/sql-shared-client/src/main/java/org/elasticsearch/xpack/sql/client/shared/RemoteFailure.java b/plugin/sql/sql-shared-client/src/main/java/org/elasticsearch/xpack/sql/client/shared/RemoteFailure.java index 1bf19847867..f9eccb4f157 100644 --- a/plugin/sql/sql-shared-client/src/main/java/org/elasticsearch/xpack/sql/client/shared/RemoteFailure.java +++ b/plugin/sql/sql-shared-client/src/main/java/org/elasticsearch/xpack/sql/client/shared/RemoteFailure.java @@ -10,16 +10,23 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; + import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.unmodifiableList; +import static java.util.Collections.unmodifiableMap; /** * A failure that happened on the remote server. @@ -77,13 +84,20 @@ public class RemoteFailure { private final String reason; private final String remoteTrace; private final Map headers; + private final Map> metadata; private final RemoteFailure cause; - RemoteFailure(String type, String reason, String remoteTrace, Map headers, RemoteFailure cause) { + RemoteFailure(String type, + String reason, + String remoteTrace, + Map headers, + Map> metadata, + RemoteFailure cause) { this.type = type; this.reason = reason; this.remoteTrace = remoteTrace; this.headers = headers; + this.metadata = metadata; this.cause = cause; } @@ -110,6 +124,13 @@ public class RemoteFailure { return headers; } + /** + * Metadata sent by the remote failure. + */ + public Map> metadata() { + return metadata; + } + /** * Cause of the remote failure. Mostly just useful for dbuegging errors that happen to be bugs. */ @@ -162,6 +183,7 @@ public class RemoteFailure { String remoteTrace = null; Map headers = emptyMap(); RemoteFailure cause = null; + final Map> metadata = new LinkedHashMap<>(); JsonToken token; String fieldName = null; @@ -212,8 +234,7 @@ public class RemoteFailure { type = parser.getText(); break; default: - throw new IOException("Expected one of [caused_by, reason, root_cause, stack_trace, type] but got [" - + fieldName + "]"); + metadata.putAll(parseMetadata(parser)); } } } @@ -223,7 +244,7 @@ public class RemoteFailure { if (remoteTrace == null) { throw new IOException("expected [stack_trace] cannot but didn't see it"); } - return new RemoteFailure(type, reason, remoteTrace, headers, cause); + return new RemoteFailure(type, reason, remoteTrace, headers, metadata, cause); } private static Map parseHeaders(JsonParser parser) throws IOException { @@ -246,6 +267,41 @@ public class RemoteFailure { return headers; } + private static Map> parseMetadata(final JsonParser parser) throws IOException { + final Map> metadata = new HashMap<>(); + final String currentFieldName = parser.getCurrentName(); + + JsonToken token = parser.currentToken(); + if (token == JsonToken.VALUE_STRING) { + metadata.put(currentFieldName, singletonList(parser.getText())); + + } else if (token == JsonToken.START_ARRAY) { + // Parse the array and add each item to the corresponding list of metadata. + // Arrays of objects are not supported yet and just ignored and skipped. + final List values = new ArrayList<>(); + while ((token = parser.nextToken()) != JsonToken.END_ARRAY) { + if (token ==JsonToken.VALUE_STRING) { + values.add(parser.getText()); + } else { + parser.skipChildren(); + } + } + if (values.size() > 0) { + if (metadata.containsKey(currentFieldName)) { + values.addAll(metadata.get(currentFieldName)); + } + metadata.put(currentFieldName, unmodifiableList(values)); + } + + } else { + // Any additional metadata object added by the metadataToXContent method is ignored + // and skipped, so that the parser does not fail on unknown fields. The parser only + // support metadata key-pairs and metadata arrays of values. + parser.skipChildren(); + } + return unmodifiableMap(metadata); + } + /** * Build an error message from a parse failure. */ @@ -276,6 +332,6 @@ public class RemoteFailure { parserLocation = " at [line " + parser.getTokenLocation().getLineNr() + " col " + parser.getTokenLocation().getColumnNr() + "]"; } - return "Can't parse error from Elasticearch [" + message + "]" + parserLocation + ". " + responseMessage; + return "Can't parse error from Elasticsearch [" + message + "]" + parserLocation + ". " + responseMessage; } } diff --git a/plugin/sql/sql-shared-client/src/test/java/org/elasticsearch/xpack/sql/client/shared/RemoteFailureTests.java b/plugin/sql/sql-shared-client/src/test/java/org/elasticsearch/xpack/sql/client/shared/RemoteFailureTests.java index 516cc56af85..ab0bc40f3ba 100644 --- a/plugin/sql/sql-shared-client/src/test/java/org/elasticsearch/xpack/sql/client/shared/RemoteFailureTests.java +++ b/plugin/sql/sql-shared-client/src/test/java/org/elasticsearch/xpack/sql/client/shared/RemoteFailureTests.java @@ -11,9 +11,11 @@ import org.elasticsearch.test.ESTestCase; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; +import java.util.Arrays; import java.util.Locale; import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.startsWith; @@ -57,45 +59,45 @@ public class RemoteFailureTests extends ESTestCase { failure.headers()); } - public void testNoError() throws IOException { + public void testNoError() { IOException e = expectThrows(IOException.class, () -> parse("no_error.json")); assertEquals( - "Can't parse error from Elasticearch [Expected [error] but didn't see it.] at [line 1 col 2]. Response:\n{}", + "Can't parse error from Elasticsearch [Expected [error] but didn't see it.] at [line 1 col 2]. Response:\n{}", e.getMessage()); } - public void testBogusError() throws IOException { + public void testBogusError() { IOException e = expectThrows(IOException.class, () -> parse("bogus_error.json")); assertEquals( - "Can't parse error from Elasticearch [Expected [error] to be an object but was [VALUE_STRING][bogus]] " + "Can't parse error from Elasticsearch [Expected [error] to be an object but was [VALUE_STRING][bogus]] " + "at [line 1 col 12]. Response:\n" + "{ \"error\": \"bogus\" }", e.getMessage()); } - public void testNoStack() throws IOException { + public void testNoStack() { IOException e = expectThrows(IOException.class, () -> parse("no_stack.json")); assertThat(e.getMessage(), - startsWith("Can't parse error from Elasticearch [expected [stack_trace] cannot but " + startsWith("Can't parse error from Elasticsearch [expected [stack_trace] cannot but " + "didn't see it] at [line 5 col 3]. Response:\n{")); } - public void testNoType() throws IOException { + public void testNoType() { IOException e = expectThrows(IOException.class, () -> parse("no_type.json")); assertThat(e.getMessage(), - startsWith("Can't parse error from Elasticearch [expected [type] but didn't see it] at [line 5 col 3]. Response:\n{")); + startsWith("Can't parse error from Elasticsearch [expected [type] but didn't see it] at [line 5 col 3]. Response:\n{")); } - public void testInvalidJson() throws IOException { + public void testInvalidJson() { IOException e = expectThrows(IOException.class, () -> parse("invalid_json.txt")); assertEquals( - "Can't parse error from Elasticearch [Unrecognized token 'I': was expecting 'null', 'true', 'false' or NaN] " + "Can't parse error from Elasticsearch [Unrecognized token 'I': was expecting 'null', 'true', 'false' or NaN] " + "at [line 1 col 1]. Response:\n" + "I'm not json at all", e.getMessage()); } - public void testExceptionBuildingParser() throws IOException { + public void testExceptionBuildingParser() { IOException e = expectThrows(IOException.class, () -> RemoteFailure.parseFromResponse(new InputStream() { @Override public int read() throws IOException { @@ -103,22 +105,22 @@ public class RemoteFailureTests extends ESTestCase { } })); assertEquals( - "Can't parse error from Elasticearch [Testing error]. Attempted to include response but failed because [Testing error].", + "Can't parse error from Elasticsearch [Testing error]. Attempted to include response but failed because [Testing error].", e.getMessage()); } - public void testTotalGarbage() throws IOException { + public void testTotalGarbage() { IOException e = expectThrows(IOException.class, () -> RemoteFailure.parseFromResponse(new BytesArray(new byte[] { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF, // The UTF-8 BOM (byte) 0xFF // An invalid UTF-8 character }).streamInput())); assertThat(e.getMessage(), - startsWith("Can't parse error from Elasticearch [Unrecognized token 'ÿ': " + startsWith("Can't parse error from Elasticsearch [Unrecognized token 'ÿ': " + "was expecting ('true', 'false' or 'null')] at [line 1 col 1]. Response:\n")); } - public void testTooBig() throws IOException { + public void testTooBig() { StringBuilder tooBig = new StringBuilder(RemoteFailure.MAX_RAW_RESPONSE); tooBig.append("{\n"); tooBig.append("\"error\" : {\n"); @@ -136,11 +138,80 @@ public class RemoteFailureTests extends ESTestCase { IOException e = expectThrows(IOException.class, () -> RemoteFailure.parseFromResponse(new BytesArray(tooBig.toString()).streamInput())); assertEquals( - "Can't parse error from Elasticearch [expected [stack_trace] cannot but didn't see it] " + "Can't parse error from Elasticsearch [expected [stack_trace] cannot but didn't see it] " + "at [line 7951 col 1]. Attempted to include response but failed because [Response too large].", e.getMessage()); } + public void testFailureWithMetadata() throws IOException { + final StringBuilder json = new StringBuilder(); + json.append("{"); + json.append("\"error\":{"); + json.append(" \"root_cause\":[],"); + json.append(" \"type\":\"search_phase_execution_exception\","); + json.append(" \"reason\":\"all shards failed\","); + json.append(" \"phase\":\"query\","); + json.append(" \"grouped\":true,"); + json.append(" \"failed_shards\":[],"); + json.append(" \"stack_trace\":\"Failed to execute phase [query], all shards failed at...\""); + json.append(" },"); + json.append(" \"status\":503"); + json.append("}"); + + RemoteFailure failure = RemoteFailure.parseFromResponse(new BytesArray(json.toString()).streamInput()); + assertEquals("search_phase_execution_exception", failure.type()); + assertEquals("all shards failed", failure.reason()); + assertThat(failure.remoteTrace(), containsString("Failed to execute phase [query], all shards failed")); + assertNull(failure.cause()); + assertEquals(emptyMap(), failure.headers()); + assertNotNull(failure.metadata()); + assertEquals(1, failure.metadata().size()); + assertEquals(singletonList("query"), failure.metadata().get("phase")); + } + + public void testFailureWithMetadataAndRootCause() throws IOException { + final StringBuilder json = new StringBuilder(); + json.append("{"); + json.append("\"error\":{"); + json.append(" \"caused_by\":{"); + json.append(" \"type\":\"invalid_index_name_exception\","); + json.append(" \"reason\":\"Invalid index name [_root], must not start with '_'.\","); + json.append(" \"index_uuid\":\"_na_root\","); + json.append(" \"index\":[\"_root\",\"_foo\"],"); + json.append(" \"stack_trace\":\"[_root] InvalidIndexNameException[Invalid index name [_root], must not start with '_'.]\""); + json.append(" },"); + json.append(" \"type\":\"invalid_index_name_exception\","); + json.append(" \"reason\":\"Invalid index name [_foo], must not start with '_'.\","); + json.append(" \"index_uuid\":\"_na_foo\","); + json.append(" \"index\":[\"_foo\"],"); + json.append(" \"stack_trace\":\"[_foo] InvalidIndexNameException[Invalid index name [_foo], must not start with '_'.]\""); + json.append(" },"); + json.append(" \"status\":400"); + json.append("}"); + + RemoteFailure failure = RemoteFailure.parseFromResponse(new BytesArray(json.toString()).streamInput()); + assertEquals("invalid_index_name_exception", failure.type()); + assertEquals("Invalid index name [_foo], must not start with '_'.", failure.reason()); + assertThat(failure.remoteTrace(), + containsString("[_foo] InvalidIndexNameException[Invalid index name [_foo], must not start with '_'.]")); + assertEquals(emptyMap(), failure.headers()); + assertNotNull(failure.metadata()); + assertEquals(2, failure.metadata().size()); + assertEquals(singletonList("_na_foo"), failure.metadata().get("index_uuid")); + assertEquals(singletonList("_foo"), failure.metadata().get("index")); + + RemoteFailure cause = failure.cause(); + assertEquals("invalid_index_name_exception", cause.type()); + assertEquals("Invalid index name [_root], must not start with '_'.", cause.reason()); + assertThat(cause.remoteTrace(), + containsString("[_root] InvalidIndexNameException[Invalid index name [_root], must not start with '_'.]")); + assertEquals(emptyMap(), failure.headers()); + assertNotNull(cause.metadata()); + assertEquals(2, cause.metadata().size()); + assertEquals(singletonList("_na_root"), cause.metadata().get("index_uuid")); + assertEquals(Arrays.asList("_root", "_foo"), cause.metadata().get("index")); + } + private RemoteFailure parse(String fileName) throws IOException { try (InputStream in = Files.newInputStream(getDataPath("/remote_failure/" + fileName))) { return RemoteFailure.parseFromResponse(in);