From 283b2df195e97881d94ffbbdce3ac54cbb5f2aab Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Sat, 7 Nov 2026 10:10:30 -0400 Subject: [PATCH] fix(client-v2): throw on in-band server exception row in JSONEachRowFormatReader With http_write_exception_in_output_format a query that fails after the response started ends with an extra {"exception": "Code: ..."} row. The reader now throws ServerException from next() for that row instead of returning it as data. Signed-off-by: Alex Castillo --- .../data_formats/JSONEachRowFormatReader.java | 36 +++++++++++ .../JSONEachRowFormatReaderTest.java | 60 +++++++++++++++++++ docs/features.md | 1 + history/latest/3220.md | 2 + 4 files changed, 99 insertions(+) create mode 100644 history/latest/3220.md diff --git a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/JSONEachRowFormatReader.java b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/JSONEachRowFormatReader.java index c96de07bca..a1478fcf44 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/data_formats/JSONEachRowFormatReader.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/data_formats/JSONEachRowFormatReader.java @@ -1,6 +1,7 @@ package com.clickhouse.client.api.data_formats; import com.clickhouse.client.api.ClientException; +import com.clickhouse.client.api.ServerException; import com.clickhouse.client.api.data_formats.internal.NumberConverter; import com.clickhouse.client.api.internal.SchemaUtils; import com.clickhouse.client.api.metadata.TableSchema; @@ -115,6 +116,14 @@ private void readNextRow() { try { nextRow = parser.nextRow(); hasNext = nextRow != null; + if (isServerExceptionRow(nextRow)) { + // With http_write_exception_in_output_format (default) the server reports a + // failure after the response started as one more row: {"exception": "Code: ..."}. + String message = (String) nextRow.get("exception"); + nextRow = null; + hasNext = true; + pendingException = new ServerException(parseServerCode(message), message, 200, ""); + } } catch (Exception e) { nextRow = null; // Keep hasNext true so a hasNext()/next() loop calls next() again and @@ -124,6 +133,33 @@ private void readNextRow() { } } + private static int parseServerCode(String message) { + // message starts with "Code: " and is followed by the numeric server error code and a dot. + int end = message.indexOf('.', 6); + if (end > 6) { + try { + return Integer.parseInt(message.substring(6, end).trim()); + } catch (NumberFormatException ignored) { + // fall through + } + } + return ServerException.CODE_UNKNOWN; + } + + private boolean isServerExceptionRow(Map row) { + // JSONEachRow writes every selected column in every row, so a data row has one key per + // schema column. A one-key error row can only be data when the schema is exactly + // [exception], i.e. the query selects only its own "exception" column. + if (row == null || row.size() != 1) { + return false; + } + if (schema.getColumns().size() == 1 && "exception".equals(schema.getColumns().get(0).getColumnName())) { + return false; + } + Object value = row.get("exception"); + return value instanceof String && ((String) value).startsWith("Code: "); + } + @Override public String getString(String colName) { Object val = currentRow.get(colName); diff --git a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/JSONEachRowFormatReaderTest.java b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/JSONEachRowFormatReaderTest.java index 304d553be1..2ff8ab4f3e 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/data_formats/JSONEachRowFormatReaderTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/data_formats/JSONEachRowFormatReaderTest.java @@ -228,6 +228,66 @@ public void close() { } } } + @Test + public void testInBandServerExceptionRowIsThrown() throws Exception { + String msg = "Code: 395. DB::Exception: Value passed to 'throwIf' function is non-zero. (FUNCTION_THROW_IF_VALUE_IS_NON_ZERO)"; + try (JSONEachRowFormatReader reader = readerOf(row("id", 1), row("id", 2), row("exception", msg))) { + Assert.assertNotNull(reader.next()); + Assert.assertNotNull(reader.next()); + Assert.assertTrue(reader.hasNext(), "the error must surface through next()"); + try { + reader.next(); + Assert.fail("Expected the server exception row to be thrown"); + } catch (RuntimeException e) { + Assert.assertTrue(e instanceof com.clickhouse.client.api.ServerException, "Unexpected type: " + e); + Assert.assertEquals(((com.clickhouse.client.api.ServerException) e).getCode(), 395); + Assert.assertTrue(e.getMessage().contains("Code: 395"), "Unexpected message: " + e.getMessage()); + } + Assert.assertFalse(reader.hasNext()); + } + } + + @Test + public void testInBandServerExceptionRowWithExceptionColumnInSchemaIsThrown() throws Exception { + String msg = "Code: 395. DB::Exception: Value passed to 'throwIf' function is non-zero. (FUNCTION_THROW_IF_VALUE_IS_NON_ZERO)"; + try (JSONEachRowFormatReader reader = readerOf(row("query_id", "a", "exception", ""), row("exception", msg))) { + Assert.assertNotNull(reader.next()); + Assert.assertTrue(reader.hasNext()); + try { + reader.next(); + Assert.fail("Expected the server exception row to be thrown"); + } catch (RuntimeException e) { + Assert.assertTrue(e instanceof com.clickhouse.client.api.ServerException, "Unexpected type: " + e); + Assert.assertEquals(((com.clickhouse.client.api.ServerException) e).getCode(), 395); + } + } + } + + @Test + public void testExceptionColumnWithOtherDataIsNotAnError() throws Exception { + try (JSONEachRowFormatReader reader = readerOf(row("exception", "Code: 1 is a value"), row("exception", "ok"))) { + Assert.assertNotNull(reader.next()); + Assert.assertNotNull(reader.next()); + Assert.assertFalse(reader.hasNext()); + } + try (JSONEachRowFormatReader reader = readerOf(row("id", 1, "exception", "Code: 1"))) { + Assert.assertNotNull(reader.next()); + Assert.assertFalse(reader.hasNext()); + } + } + + @Test + public void testSingleExceptionColumnWithErrorLikeValuesIsData() throws Exception { + try (JSONEachRowFormatReader reader = readerOf( + row("exception", "x"), + row("exception", "Code: 60. DB::Exception: Table doesn't exist. (UNKNOWN_TABLE)"))) { + Assert.assertNotNull(reader.next()); + Assert.assertTrue(reader.hasNext()); + Assert.assertNotNull(reader.next()); + Assert.assertFalse(reader.hasNext()); + } + } + // --------------------------------------------------------------------- // Row navigation, readValue, hasValue // --------------------------------------------------------------------- diff --git a/docs/features.md b/docs/features.md index 1bd3588ca2..5fd090979f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -65,6 +65,7 @@ Compatibility-sensitive traits: - Certificate-as-content support is compatibility-sensitive: any certificate or key value containing a PEM begin marker (`-----BEGIN`) is treated as inline PEM content, otherwise it is treated as a file path (also searched in the home directory and on the classpath). - JSONEachRow reading depends on the selected parser factory and request format settings: parser materialization determines Java value types, the reader infers minimal schema from the first row, and JSON number server settings are applied only when `QuerySettings` resolves to `ClickHouseFormat.JSONEachRow` and `json_disable_number_quoting` is enabled. - JSONEachRow schema inference is intentionally best-effort: scalar values use Java-to-ClickHouse type mappings, while JSON arrays and objects are identified structurally as `Array` and `Map`. For arrays, maps, and some nested or ambiguous values, the inferred type may not include the most specific element, key, value, or nested ClickHouse type. +- JSONEachRow reading reports a failure that the server writes into the response body: with `http_write_exception_in_output_format` (default on) a query that fails after the response started ends with an extra row `{"exception": "Code: ..."}`. The reader throws a `ServerException` (with the parsed server error code) from `next()` for that row instead of returning it as data; rows read before it are still returned. A row is treated this way only when `exception` is its only key and the schema has other columns. - Binary string support is scope-sensitive and off by default: it only changes how top-level `String`/`FixedString` columns are read (to `StringValue` instead of `String`); nested string values are always read as `String`. `StringValue` is produced only by the read path and has no public constructor (it cannot be instantiated by user code). It is intentionally a mutable, zero-copy holder — it wraps (does not copy) the bytes it is given and `toByteArray()` returns the live backing array when the value spans the whole array (otherwise an exact-size copy), so callers needing an independent snapshot must copy the bytes themselves. diff --git a/history/latest/3220.md b/history/latest/3220.md new file mode 100644 index 0000000000..5dfc6bb6b8 --- /dev/null +++ b/history/latest/3220.md @@ -0,0 +1,2 @@ +- **[client-v2]** `JSONEachRowFormatReader` now throws a `ServerException` when the server reports a failure after the response started. With `http_write_exception_in_output_format` (default on) the server appends a row `{"exception": "Code: ..."}` and ends the response normally; the reader returned it as an ordinary all-null data row, so the failure was silent. The rows read before the error are still returned, and the exception surfaces on the next `next()` call with the server error code. A query that selects only an `exception` column is not affected. + (https://github.com/ClickHouse/clickhouse-java/issues/3220)