From 3fe8fc5280bcb0ced976a698fcae2dc977450ce6 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Mon, 28 Sep 2026 07:05:50 +0000
Subject: [PATCH] [#1074] Keep what says the connection is gone through the redaction of a connect failure (#1107)

---
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CatalogConnectionTestCase.java |   63 ++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java  |  162 +++++++++++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java          |  101 ++++++++++++++--
 opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java               |   20 ++
 4 files changed, 330 insertions(+), 16 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java
index 814304a..05efb36 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/CachedConnection.java
@@ -258,7 +258,8 @@
      * How many links of the cause and getNextException() chains of a failure the two walks that
      * have a reason to stop look at: {@link #holdsCredentials} answers "yes" past it, since the
      * cost of the other answer is the password of the backend in the log, and
-     * {@link #redactedCopy} rebuilds that far and names the rest in one link. The walk whose verdict
+     * {@link #redactedCopy} rebuilds that far and names the rest in one link - carrying, where the
+     * rest says the connection is gone, what says it (#1074). The walk whose verdict
      * decides something, {@link #isWorthRetrying}, is not one of them: a count does not leave that
      * question unanswered, it answers it with the verdict of a failure that carries nothing
      * (issue #1076), and the visited set of every walk here terminates it on its own.
@@ -2135,8 +2136,11 @@
      * prints a failure prints the causes along with it - a debug build of
      * stackTraceToSingleLineString walks them, the config manager traces them, and
      * RootContainer.open() makes the message of the cause the message of what it throws - so a
-     * link left as it stands would carry the password past the wrapper. The SQLState and the
-     * vendor code of every link survive it: they are what tells a caller what happened.
+     * link left as it stands would carry the password past the wrapper. The SQLState, the vendor
+     * code and the standard JDBC type of every link survive it: they are what tells a caller what
+     * happened, and JDBCStorage.write() asks the type before the state whether the connection is
+     * gone. Past the budget of the rebuild the one link standing for the rest says that much of it
+     * where the rest says so (#1074).
      * <p>
      * The whole chain is the causes, the further exceptions of {@code getNextException()} and what
      * was suppressed on each of them - the last being where this class puts the failure of a close
@@ -2223,40 +2227,110 @@
     private static SQLException redactedCopy(SQLException e, String connectionString, int[] budget) {
         budget[0]--;
         final SQLException copy =
-            new SQLException(redact(e.getMessage(), connectionString), e.getSQLState(), e.getErrorCode());
+            sameKind(e, redact(e.getMessage(), connectionString), e.getSQLState(), e.getErrorCode());
         copy.setStackTrace(e.getStackTrace());
         if (e.getNextException() != null) {
             copy.setNextException(budget[0] > 0
-                ? redactedCopy(e.getNextException(), connectionString, budget) : droppedTail());
+                ? redactedCopy(e.getNextException(), connectionString, budget)
+                : droppedTail(Collections.<Throwable>singletonList(e.getNextException())));
         }
         if (e.getCause() != null) {
-            copy.initCause(budget[0] > 0 ? redactedLink(e.getCause(), connectionString, budget) : droppedTail());
+            copy.initCause(budget[0] > 0 ? redactedLink(e.getCause(), connectionString, budget)
+                : droppedTail(Collections.singletonList(e.getCause())));
         }
         copySuppressed(e, copy, connectionString, budget);
         return copy;
     }
 
+    /**
+     * A new exception of the kind JDBC names the given one as. Not the class of the driver itself,
+     * which this cannot be sure of building, but the standard type it extends: that type is what
+     * the JDBC contract gives a driver to say what happened beside the SQLState, and JDBCStorage
+     * reads it first - a SQLRecoverableException says the connection is gone whatever state it
+     * carries, and a copy made as a plain SQLException says nothing of the kind (#1074).
+     */
+    private static SQLException sameKind(SQLException e, String message, String sqlState, int vendorCode) {
+        if (e instanceof SQLRecoverableException) {
+            return new SQLRecoverableException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLNonTransientConnectionException) {
+            return new SQLNonTransientConnectionException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLTransientConnectionException) {
+            return new SQLTransientConnectionException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLTimeoutException) {
+            return new SQLTimeoutException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLTransactionRollbackException) {
+            return new SQLTransactionRollbackException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLFeatureNotSupportedException) {
+            return new SQLFeatureNotSupportedException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLIntegrityConstraintViolationException) {
+            return new SQLIntegrityConstraintViolationException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLInvalidAuthorizationSpecException) {
+            return new SQLInvalidAuthorizationSpecException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLSyntaxErrorException) {
+            return new SQLSyntaxErrorException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLDataException) {
+            return new SQLDataException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLTransientException) {
+            return new SQLTransientException(message, sqlState, vendorCode);
+        }
+        if (e instanceof SQLNonTransientException) {
+            return new SQLNonTransientException(message, sqlState, vendorCode);
+        }
+        return new SQLException(message, sqlState, vendorCode);
+    }
+
     // The suppressed links of a failure are rebuilt with the rest of it and counted against the
     // same budget. They are not decoration here: the failure of a close that could not be made
     // rides on the failure being unwound (establish(), #929), and a rebuild that dropped them
     // would lose it at the one deployment whose log is redacted - which is the deployment whose
     // url has a password in it, the log that is worth reading.
     private static void copySuppressed(Throwable from, Throwable to, String connectionString, int[] budget) {
-        for (final Throwable suppressed : from.getSuppressed()) {
+        final Throwable[] suppressed = from.getSuppressed();
+        for (int i = 0; i < suppressed.length; i++) {
             if (budget[0] <= 0) {
-                to.addSuppressed(droppedTail());
+                to.addSuppressed(droppedTail(Arrays.asList(suppressed).subList(i, suppressed.length)));
                 return;
             }
-            to.addSuppressed(redactedLink(suppressed, connectionString, budget));
+            to.addSuppressed(redactedLink(suppressed[i], connectionString, budget));
         }
     }
 
     // What stands where the budget ran out. Without it the same failure logs its root cause when
     // the url of the backend has no password in it and loses it without a word when it has, which
     // is a report of a connect nobody can read against a report of one they can.
-    private static SQLException droppedTail() {
-        return new SQLException("the rest of this failure was left out: a chain of more than "
-            + MAX_CHAIN_LENGTH + " links is rebuilt only that far");
+    // It also says what the rest says about the connection, where the rest says it is gone: that
+    // is a verdict JDBCStorage.write() reads off every link of the failure - it replays the attempt
+    // and distrusts the pool on it - and a tail saying nothing would answer it with the "no" of a
+    // failure that carries nothing (#1074, the #961 thesis on the budget of this rebuild). Only
+    // that, and only where the rest does say it, so that the cut makes no failure say it either.
+    // The rest is read by the very walk isConnectionFailure() answers from, to its end, rather than
+    // by a copy of it here: the two would read the same edges today and drift the day one of them
+    // changes. It builds nothing, so what it costs is a walk of the links the driver has already
+    // allocated.
+    private static SQLException droppedTail(List<Throwable> rest) {
+        final String message = "the rest of this failure was left out: a chain of more than "
+            + MAX_CHAIN_LENGTH + " links is rebuilt only that far";
+        SQLException gone = null;
+        for (final Throwable t : rest) {
+            gone = JDBCStorage.connectionFailureLink(t);
+            if (gone != null) {
+                break;
+            }
+        }
+        return gone == null
+            ? new SQLException(message)
+            : sameKind(gone, message + "; a link of it says the connection is gone", gone.getSQLState(),
+                gone.getErrorCode());
     }
 
     // A link that is no SQLException keeps its class name in the message: its type is not one this
@@ -2270,7 +2344,8 @@
             + (t.getMessage() == null ? "" : ": " + redact(t.getMessage(), connectionString)));
         copy.setStackTrace(t.getStackTrace());
         if (t.getCause() != null) {
-            copy.initCause(budget[0] > 0 ? redactedLink(t.getCause(), connectionString, budget) : droppedTail());
+            copy.initCause(budget[0] > 0 ? redactedLink(t.getCause(), connectionString, budget)
+                : droppedTail(Collections.singletonList(t.getCause())));
         }
         copySuppressed(t, copy, connectionString, budget);
         return copy;
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
index e38d3cf..d044534 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java
@@ -4842,7 +4842,20 @@
 	 * failover took, and {@link #replayReason} fails an attempt that was worth replaying (issue #961).
 	 */
 	static boolean isConnectionFailure(Throwable failure) {
-		return firstLinkMatching(failure, WITH_THE_RELEASE, EVERY_LINK, JDBCStorage::saysTheConnectionIsGone)!=null;
+		return connectionFailureLink(failure)!=null;
+	}
+
+	/**
+	 * The link of a failure that says the connection is gone, found by the walk {@link #isConnectionFailure} answers
+	 * from, or null where none says so.
+	 * <p>
+	 * Package-private for the one other reader it has, the redaction of a connect failure in {@link
+	 * CachedConnection}: what that rebuild leaves in place of a chain past its budget has to say what this reads
+	 * off the links it cuts (#1074). It asks for the walk rather than for the question alone, so that a change to
+	 * the edges this walks changes what the tail carries along with what {@link #write} reads.
+	 */
+	static SQLException connectionFailureLink(Throwable failure) {
+		return firstLinkMatching(failure, WITH_THE_RELEASE, EVERY_LINK, JDBCStorage::saysTheConnectionIsGone);
 	}
 
 	/**
@@ -7071,8 +7084,9 @@
 		 * database that its dialect table does not recognize is raised at once instead: a mysql
 		 * account with a {@code MAX_USER_CONNECTIONS} of its own answers 1226 on SQLState 42000, and
 		 * a driver of no known dialect has no vendor code read at all (issue #1011). The type is no
-		 * rule either: a failure whose chain names the credentials of the backend is rebuilt as a
-		 * plain {@code SQLException} whatever the driver threw. Sorting them here would be that
+		 * rule either: a driver need not raise the standard JDBC type that names what happened, and a
+		 * failure whose chain names the credentials of the backend keeps only that standard type, not
+		 * the class of the driver (#1074). Sorting them here would be that
 		 * classification written out a second time, with the failure of a multi-hour import as the
 		 * cost of getting it wrong (issue #1013).
 		 * <p>
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java
index ce66a7e..135942e 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CachedConnectionTestCase.java
@@ -39,8 +39,19 @@
 import java.sql.DriverManager;
 import java.sql.DriverPropertyInfo;
 import java.sql.PreparedStatement;
+import java.sql.SQLDataException;
 import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.sql.SQLIntegrityConstraintViolationException;
+import java.sql.SQLInvalidAuthorizationSpecException;
+import java.sql.SQLNonTransientConnectionException;
+import java.sql.SQLNonTransientException;
+import java.sql.SQLRecoverableException;
+import java.sql.SQLSyntaxErrorException;
 import java.sql.SQLTimeoutException;
+import java.sql.SQLTransactionRollbackException;
+import java.sql.SQLTransientConnectionException;
+import java.sql.SQLTransientException;
 import java.util.ArrayDeque;
 import java.util.Collections;
 import java.util.Deque;
@@ -942,6 +953,157 @@
 	}
 
 	/**
+	 * A link that says the connection is gone by its type alone still says so once it is rebuilt
+	 * (#1074). The type is what the JDBC contract gives a driver to say it - write() asks it before
+	 * the SQLState - so a copy made as a plain SQLException reads as a failure that says nothing of
+	 * the connection, and only at the deployment whose url has a password in it.
+	 */
+	@Test(timeOut = 60000)
+	public void testALinkThatSaysTheConnectionIsGoneByItsTypeKeepsItThroughRedaction() throws Exception {
+		final String url = "jdbc:postgresql://opendj:S3cretOfTheBackend@127.0.0.1:5432/opendj";
+		for (final SQLException gone : new SQLException[] { new SQLRecoverableException("io error"),
+				new SQLNonTransientConnectionException("closed"), new SQLTransientConnectionException("reset") }) {
+			final SQLException failure = new SQLException("login to " + url + " failed", "S0001", 18456);
+			failure.setNextException(gone);
+
+			final SQLException reported = CachedConnection.reported(failure, url);
+
+			assertNoCredentials(reported);
+			assertTrue(JDBCStorage.isConnectionFailure(reported),
+				"a rebuilt " + gone.getClass().getSimpleName() + " no longer says the connection is gone");
+		}
+	}
+
+	/**
+	 * ... and so does one standing past the budget of the rebuild, whatever it says it by: the link
+	 * that stands for the rest of the chain there is all that the classification of write() gets to
+	 * read of it. The url has no password, and the chain is rebuilt all the same - it is longer than
+	 * the walk looking for credentials, which answers "yes" past it.
+	 */
+	@Test(timeOut = 60000)
+	public void testALinkThatSaysTheConnectionIsGonePastTheBudgetOfARebuildStillSaysSo() throws Exception {
+		final String url = "jdbc:postgresql://127.0.0.1:5432/opendj";
+		for (final SQLException gone : new SQLException[] { new SQLException("connection reset", "08S01", 10054),
+				new SQLRecoverableException("io error") }) {
+			final SQLException failure = plainChain(40);
+			failure.setNextException(gone);
+
+			final SQLException reported = CachedConnection.reported(failure, url);
+
+			assertTrue(reported != failure, "a chain this long is expected to be rebuilt");
+			assertTrue(JDBCStorage.isConnectionFailure(reported),
+				gone + " past the budget of the rebuild no longer says the connection is gone");
+		}
+	}
+
+	/**
+	 * ... and so does one past the budget on any other edge the rebuild cuts: the cause of a link, what
+	 * was suppressed on it - where establish() puts the close that failed (#929) - and the cause of a
+	 * link that is no SQLException, which is rebuilt on a road of its own. Each chain spends the budget
+	 * before the rebuild reaches the edge, so the link that says the connection is gone is behind the
+	 * one that stands for the rest; the last one has it on what was suppressed on a link that is cut,
+	 * which the rest is read for as well.
+	 */
+	@Test(timeOut = 60000)
+	public void testALinkThatSaysTheConnectionIsGoneOnACauseOrASuppressedEdgePastTheBudgetStillSaysSo()
+			throws Exception {
+		final String url = "jdbc:postgresql://127.0.0.1:5432/opendj";
+		final SQLException byCause = plainChain(40);
+		byCause.initCause(new IOException("socket closed", new SQLException("connection reset", "08S01", 10054)));
+		final SQLException bySuppressed = plainChain(40);
+		bySuppressed.addSuppressed(new SQLException("the connection is closed", "08003"));
+		final SQLException byTheCauseOfALinkThatIsNoSQLException = new SQLException("login failed", "S0001", 18456);
+		Throwable wrapper = new SQLException("connection reset", "08S01", 10054);
+		for (int i = 0; i < 40; i++) {
+			wrapper = new IOException("wrapper " + i, wrapper);
+		}
+		byTheCauseOfALinkThatIsNoSQLException.initCause(wrapper);
+		final SQLException byTheSuppressedOfALinkCut = plainChain(40);
+		lastOf(byTheSuppressedOfALinkCut).addSuppressed(new SQLException("the connection is closed", "08003"));
+		final Object[][] cases = {
+			{ "the cause", byCause },
+			{ "the suppressed", bySuppressed },
+			{ "the cause of a link that is no SQLException", byTheCauseOfALinkThatIsNoSQLException },
+			{ "what was suppressed on a link cut", byTheSuppressedOfALinkCut } };
+		for (final Object[] edge : cases) {
+			final SQLException failure = (SQLException) edge[1];
+			assertTrue(JDBCStorage.isConnectionFailure(failure), edge[0] + ": the failure itself says the connection is gone");
+
+			final SQLException reported = CachedConnection.reported(failure, url);
+
+			assertNotSame(reported, failure, edge[0] + ": a chain this long is expected to be rebuilt");
+			assertTrue(JDBCStorage.isConnectionFailure(reported),
+				edge[0] + " cut past the budget no longer says the connection is gone");
+		}
+	}
+
+	/**
+	 * Every standard type of JDBC a link can be of survives its rebuild, not only the three that say
+	 * the connection is gone: each is read somewhere - a SQLTimeoutException is what sends a borrow of
+	 * an import to the debug log rather than the warn one (borrowedOrShared()) - and a rebuild that
+	 * turned one into its supertype would classify the same failure one way where the url of the
+	 * backend has no password and the other where it does. Each type is asked for before its
+	 * supertype, so a check out of order fails here as well.
+	 */
+	@Test(timeOut = 60000)
+	public void testEveryStandardTypeOfALinkIsKeptThroughRedaction() throws Exception {
+		final String url = "jdbc:postgresql://opendj:S3cretOfTheBackend@127.0.0.1:5432/opendj";
+		final String message = "login to " + url + " failed";
+		for (final SQLException original : new SQLException[] {
+				new SQLRecoverableException(message, "08006", 17002),
+				new SQLNonTransientConnectionException(message, "08001", 0),
+				new SQLTransientConnectionException(message, "08001", 0),
+				new SQLTimeoutException(message, "HYT00", 0),
+				new SQLTransactionRollbackException(message, "40001", 1205),
+				new SQLFeatureNotSupportedException(message, "0A000", 0),
+				new SQLIntegrityConstraintViolationException(message, "23000", 2627),
+				new SQLInvalidAuthorizationSpecException(message, "28000", 18456),
+				new SQLSyntaxErrorException(message, "42000", 0),
+				new SQLDataException(message, "22000", 0),
+				new SQLTransientException(message, "S1000", 0),
+				new SQLNonTransientException(message, "S1000", 0),
+				new SQLException(message, "S1000", 0) }) {
+			final SQLException reported = CachedConnection.reported(original, url);
+
+			assertNoCredentials(reported);
+			assertEquals(reported.getClass(), original.getClass(), "the rebuild changed the type of the link");
+			assertEquals(reported.getSQLState(), original.getSQLState(), "the rebuild changed the SQLState of the link");
+			assertEquals(reported.getErrorCode(), original.getErrorCode(), "the rebuild changed the vendor code of the link");
+		}
+	}
+
+	/**
+	 * The link standing for the rest says only what the rest says: a chain that says nothing of the
+	 * connection is not made to say it is gone by being cut. That would replay the write and distrust
+	 * the pool over a database that refused a connection for a reason of its own.
+	 */
+	@Test(timeOut = 60000)
+	public void testALongChainThatSaysNothingOfTheConnectionIsNotMadeToSayItByTheRebuild() throws Exception {
+		final SQLException reported = CachedConnection.reported(plainChain(40), "jdbc:postgresql://127.0.0.1:5432/opendj");
+
+		assertFalse(JDBCStorage.isConnectionFailure(reported),
+			"the rebuild made a failure that says nothing of the connection say it is gone");
+	}
+
+	/** That many plain links of the next exception chain, none of which says the connection is gone. */
+	private static SQLException plainChain(int links) {
+		final SQLException head = new SQLException("error 0", "S0001", 1);
+		for (int i = 1; i < links; i++) {
+			head.setNextException(new SQLException("error " + i, "S0001", 1));
+		}
+		return head;
+	}
+
+	/** The last link of the next exception chain of a failure. */
+	private static SQLException lastOf(SQLException failure) {
+		SQLException last = failure;
+		while (last.getNextException() != null) {
+			last = last.getNextException();
+		}
+		return last;
+	}
+
+	/**
 	 * A credential named by a suppressed link alone is redacted like any other. The close of a
 	 * connection whose set-up failed rides there (establish(), #929) and an interrupt that ended a
 	 * wait for a catalog connect does, and a driver names the url it could not close as readily as
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CatalogConnectionTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CatalogConnectionTestCase.java
index 53404b3..3d63d67 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CatalogConnectionTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/CatalogConnectionTestCase.java
@@ -685,6 +685,69 @@
 		verify(failing).close();
 	}
 
+	/**
+	 * A refusal that says the connection is gone reaches {@code write()} saying so, however deep in its
+	 * chain the driver put the link that says it (#1074). The failure leaves this connect redacted, and
+	 * a chain longer than the walk looking for credentials is rebuilt whether or not anything in it names
+	 * them - this url has no password at all - so what the rebuild leaves past its budget is what {@code
+	 * isConnectionFailure()} reads: the attempt is replayed where the same refusal a few links shorter is.
+	 */
+	@Test
+	public void testARefusalThatSaysTheConnectionIsGoneDeepInItsChainStillSaysSo() throws Exception {
+		final SQLException refusal = deepChain(40, new SQLException("connection reset", "08S01", 10054));
+		probeDriver.refusal = refusal;
+		probeDriver.refusalsLeft.set(Integer.MAX_VALUE);
+		try {
+			storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW, DEFAULT_BOUND, 60);
+			fail("a connect that will not clear was retried instead of being reported");
+		} catch (SQLException expected) {
+			// the road this case is about: the timeout of a refusal waited out carries the same tail
+			// as its cause and would pass the rest of this case just as well
+			assertFalse(expected instanceof SQLTimeoutException,
+				"the refusal was retried to the deadline instead of being reported at once");
+			assertEquals(probeDriver.attempts.get(), 1, "the refusal was retried instead of being reported at once");
+			assertTrue(JDBCStorage.isConnectionFailure(refusal), "the refusal of the driver says the connection is gone");
+			assertTrue(JDBCStorage.isConnectionFailure(expected),
+				"the refusal as it left the connect lost what said the connection is gone: " + expected);
+		}
+	}
+
+	/**
+	 * ... and so does the timeout a refusal worth waiting out ends in, whose cause is that refusal
+	 * redacted the same way: the retry changes no classification.
+	 */
+	@Test
+	public void testATimedOutRefusalThatSaysTheConnectionIsGoneDeepInItsChainStillSaysSo() throws Exception {
+		final SQLException refusal = new SQLException("too many clients already", "53300");
+		refusal.setNextException(deepChain(40, new SQLException("connection reset", "08S01", 10054)));
+		probeDriver.refusal = refusal;
+		probeDriver.refusalsLeft.set(Integer.MAX_VALUE);
+		try {
+			storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW, DEFAULT_BOUND, 1);
+			fail("a connect refused for the whole deadline was not given up on");
+		} catch (SQLTimeoutException expected) {
+			assertTrue(JDBCStorage.isConnectionFailure(refusal), "the refusal of the driver says the connection is gone");
+			assertTrue(JDBCStorage.isConnectionFailure(expected),
+				"the timeout lost what its last refusal said about the connection: " + expected);
+		}
+	}
+
+	/**
+	 * A driver's failure the way mssql-jdbc chains every error of a message it received: that many
+	 * plain links of the next exception chain, and the given one last.
+	 */
+	private static SQLException deepChain(int links, SQLException last) {
+		final SQLException head = new SQLException("error 0 of the login", "S0001", 1);
+		SQLException tail = head;
+		for (int i = 1; i < links; i++) {
+			final SQLException next = new SQLException("error " + i + " of the login", "S0001", 1);
+			tail.setNextException(next);
+			tail = next;
+		}
+		tail.setNextException(last);
+		return head;
+	}
+
 	/** What the dialect of this url declares, at the given number of seconds. */
 	private static void assertBoundedAt(Properties handed, long seconds) {
 		assertNotNull(handed, "no properties were handed to the driver at all");

--
Gitblit v1.10.0