diff --git a/driver-core/src/main/com/mongodb/MongoSocksProxyException.java b/driver-core/src/main/com/mongodb/MongoSocksProxyException.java
new file mode 100644
index 0000000000..aafb7fad7c
--- /dev/null
+++ b/driver-core/src/main/com/mongodb/MongoSocksProxyException.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2008-present MongoDB, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mongodb;
+
+import com.mongodb.lang.Nullable;
+
+import static com.mongodb.assertions.Assertions.notNull;
+
+/**
+ * Thrown when an error occurs while establishing a connection to a SOCKS5 proxy.
+ *
+ *
The {@link #getHandshakePhase()} identifies which phase of the SOCKS5 handshake failed.
+ * {@link #getProxyReplyCode()} returns the RFC 1928 reply code sent by the proxy when a
+ * non-success CONNECT reply was successfully parsed; it returns {@code null} otherwise
+ * (including for {@link HandshakePhase#CONNECT_RELAY} failures caused by an I/O error or
+ * an unrecognized reply field).
+ *
+ *
RFC 1928 reply codes: 1=general failure, 2=connection not allowed by ruleset,
+ * 3=network unreachable, 4=host unreachable, 5=connection refused, 6=TTL expired,
+ * 7=command not supported, 8=address type not supported.
+ *
+ *
Constructor parameter ordering follows the parent class first (message, address,
+ * optional cause), then SOCKS-specific arguments (handshakePhase, optional proxyReplyCode).
+ *
+ * @since 5.8
+ */
+public class MongoSocksProxyException extends MongoSocketOpenException {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * The phase of the SOCKS5 handshake at which the failure occurred.
+ *
+ * @since 5.8
+ */
+ public enum HandshakePhase {
+ /**
+ * TCP connection to the proxy host itself failed before any SOCKS5 exchange.
+ * The proxy may be temporarily unreachable.
+ */
+ PROXY_TCP_CONNECT,
+
+ /**
+ * The SOCKS5 method-selection exchange failed. Causes include: incompatible
+ * proxy version, no common authentication method, an unrecognized method, or
+ * an I/O failure (EOF, timeout, broken pipe) while sending the method-selection
+ * request or reading its reply.
+ */
+ NEGOTIATION,
+
+ /**
+ * Username/password sub-negotiation with the proxy failed. Causes include:
+ * the proxy rejecting the credentials (typically wrong username/password),
+ * or an I/O failure (EOF, timeout, broken pipe) while sending credentials
+ * or reading the auth result.
+ */
+ AUTHENTICATION,
+
+ /**
+ * A failure occurred while sending the CONNECT request to the proxy or
+ * reading/parsing its reply. Causes include: a parsed non-success RFC 1928
+ * reply (in which case {@link MongoSocksProxyException#getProxyReplyCode()}
+ * carries the code), an unrecognized reply field or address type, or an
+ * I/O failure (EOF, timeout, broken pipe) on the CONNECT exchange.
+ */
+ CONNECT_RELAY
+ }
+
+ private final HandshakePhase handshakePhase;
+
+ @Nullable
+ private final Integer proxyReplyCode;
+
+ /**
+ * Construct an instance with no RFC 1928 reply code and no cause. Suitable for any phase
+ * whose failure does not carry a parsed reply code: {@link HandshakePhase#PROXY_TCP_CONNECT},
+ * {@link HandshakePhase#NEGOTIATION}, {@link HandshakePhase#AUTHENTICATION}, and the
+ * {@link HandshakePhase#CONNECT_RELAY} sub-cases driven by an I/O failure or an unrecognized
+ * reply field.
+ *
+ * @param message the message
+ * @param serverAddress the server address
+ * @param handshakePhase the phase at which the failure occurred
+ */
+ public MongoSocksProxyException(final String message, final ServerAddress serverAddress, final HandshakePhase handshakePhase) {
+ this(message, serverAddress, notNull("handshakePhase", handshakePhase), null);
+ }
+
+ /**
+ * Construct an instance with no RFC 1928 reply code. Suitable for any phase whose failure
+ * does not carry a parsed reply code: {@link HandshakePhase#PROXY_TCP_CONNECT},
+ * {@link HandshakePhase#NEGOTIATION}, {@link HandshakePhase#AUTHENTICATION}, and the
+ * {@link HandshakePhase#CONNECT_RELAY} sub-cases driven by an I/O failure or an unrecognized
+ * reply field.
+ *
+ * @param message the message
+ * @param address the server address
+ * @param cause the cause
+ * @param handshakePhase the phase at which the failure occurred
+ */
+ public MongoSocksProxyException(final String message, final ServerAddress address,
+ final Throwable cause, final HandshakePhase handshakePhase) {
+ this(message, address, cause, notNull("handshakePhase", handshakePhase), null);
+ }
+
+ /**
+ * Construct an instance with an optional RFC 1928 reply code. A non-{@code null}
+ * {@code proxyReplyCode} should only accompany {@link HandshakePhase#CONNECT_RELAY} and
+ * indicates a successfully parsed non-success reply from the proxy. Use {@code null} in
+ * all other cases — including {@link HandshakePhase#CONNECT_RELAY} failures caused by an
+ * I/O error or an unrecognized reply field.
+ *
+ * @param message the message
+ * @param address the server address
+ * @param handshakePhase the phase at which the failure occurred
+ * @param proxyReplyCode the RFC 1928 reply code, or {@code null}
+ */
+ public MongoSocksProxyException(final String message, final ServerAddress address, final HandshakePhase handshakePhase,
+ @Nullable final Integer proxyReplyCode) {
+ super(message, address);
+ this.handshakePhase = notNull("handshakePhase", handshakePhase);
+ this.proxyReplyCode = proxyReplyCode;
+ }
+
+ /**
+ * Construct an instance with an optional RFC 1928 reply code. A non-{@code null}
+ * {@code proxyReplyCode} should only accompany {@link HandshakePhase#CONNECT_RELAY} and
+ * indicates a successfully parsed non-success reply from the proxy. Use {@code null} in
+ * all other cases — including {@link HandshakePhase#CONNECT_RELAY} failures caused by an
+ * I/O error or an unrecognized reply field.
+ *
+ * @param message the message
+ * @param address the server address
+ * @param cause the cause
+ * @param handshakePhase the phase at which the failure occurred
+ * @param proxyReplyCode the RFC 1928 reply code, or {@code null}
+ */
+ public MongoSocksProxyException(final String message, final ServerAddress address,
+ final Throwable cause, final HandshakePhase handshakePhase,
+ @Nullable final Integer proxyReplyCode) {
+ super(message, address, cause);
+ this.handshakePhase = notNull("handshakePhase", handshakePhase);
+ this.proxyReplyCode = proxyReplyCode;
+ }
+
+ /**
+ * Returns the phase of the SOCKS5 handshake at which the failure occurred.
+ *
+ * @return the handshake phase, never {@code null}
+ */
+ public HandshakePhase getHandshakePhase() {
+ return handshakePhase;
+ }
+
+ /**
+ * Returns the RFC 1928 reply code sent by the SOCKS5 proxy when a non-success CONNECT
+ * reply was successfully parsed, or {@code null} otherwise.
+ *
+ * @return the RFC 1928 proxy reply code, or {@code null}
+ */
+ @Nullable
+ public Integer getProxyReplyCode() {
+ return proxyReplyCode;
+ }
+}
diff --git a/driver-core/src/main/com/mongodb/internal/connection/BackpressureErrorLabeler.java b/driver-core/src/main/com/mongodb/internal/connection/BackpressureErrorLabeler.java
index b062620455..50372e532c 100644
--- a/driver-core/src/main/com/mongodb/internal/connection/BackpressureErrorLabeler.java
+++ b/driver-core/src/main/com/mongodb/internal/connection/BackpressureErrorLabeler.java
@@ -18,6 +18,7 @@
import com.mongodb.MongoException;
import com.mongodb.MongoSocketException;
+import com.mongodb.MongoSocksProxyException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
@@ -76,19 +77,36 @@ static void applyLabelsIfEligible(final Throwable t) {
return;
}
MongoSocketException socketException = (MongoSocketException) t;
+ if (isSocksFailure(socketException)) {
+ return;
+ }
if (isDnsLookupFailure(socketException)) {
return;
}
if (isTlsConfigurationError(socketException)) {
return;
}
- // TODO-BACKPRESSURE Nabil - Add SOCKS5 check once JAVA-6194 is introduced
- // async proxy error surfaces can be handled together — likely via a dedicated internal
- // exception thrown from the proxy code path.
socketException.addLabel(MongoException.SYSTEM_OVERLOADED_ERROR_LABEL);
socketException.addLabel(MongoException.RETRYABLE_ERROR_LABEL);
}
+ private static boolean isSocksFailure(final MongoSocketException t) {
+ if (!(t instanceof MongoSocksProxyException)) {
+ return false;
+ }
+ MongoSocksProxyException socksException = (MongoSocksProxyException) t;
+ if (socksException.getHandshakePhase() != MongoSocksProxyException.HandshakePhase.CONNECT_RELAY) {
+ return true;
+ }
+ Integer replyCode = socksException.getProxyReplyCode();
+ if (replyCode == null) {
+ return true;
+ }
+ return replyCode != SocksSocket.ServerReply.NET_UNREACHABLE.getReplyNumber()
+ && replyCode != SocksSocket.ServerReply.HOST_UNREACHABLE.getReplyNumber()
+ && replyCode != SocksSocket.ServerReply.CONN_REFUSED.getReplyNumber();
+ }
+
private static boolean isDnsLookupFailure(final MongoSocketException t) {
Throwable cause = t.getCause();
while (cause != null) {
diff --git a/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java b/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java
index a1c3ed0d91..dc3956f82d 100644
--- a/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java
+++ b/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java
@@ -16,9 +16,11 @@
package com.mongodb.internal.connection;
+import com.mongodb.MongoInterruptedException;
import com.mongodb.MongoSocketException;
import com.mongodb.MongoSocketOpenException;
import com.mongodb.MongoSocketReadException;
+import com.mongodb.MongoSocksProxyException;
import com.mongodb.ServerAddress;
import com.mongodb.connection.AsyncCompletionHandler;
import com.mongodb.connection.ProxySettings;
@@ -38,6 +40,7 @@
import java.net.SocketTimeoutException;
import java.util.Iterator;
import java.util.List;
+import java.util.Optional;
import static com.mongodb.assertions.Assertions.assertTrue;
import static com.mongodb.assertions.Assertions.notNull;
@@ -79,10 +82,16 @@ public void open(final OperationContext operationContext) {
socket = initializeSocket(operationContext);
outputStream = socket.getOutputStream();
inputStream = socket.getInputStream();
+ } catch (MongoSocksProxyException e) {
+ close();
+ throw e;
} catch (IOException e) {
close();
- throw translateInterruptedException(e, "Interrupted while connecting")
- .orElseThrow(() -> new MongoSocketOpenException("Exception opening socket", getAddress(), e));
+ Optional interrupted = translateInterruptedException(e, "Interrupted while connecting");
+ if (interrupted.isPresent()) {
+ throw interrupted.get();
+ }
+ throw new MongoSocketOpenException("Exception opening socket", getAddress(), e);
}
}
@@ -119,15 +128,32 @@ private SSLSocket initializeSslSocketOverSocksProxy(final OperationContext opera
final int serverPort = address.getPort();
SocksSocket socksProxy = new SocksSocket(settings.getProxySettings());
- configureSocket(socksProxy, operationContext, settings);
- InetSocketAddress inetSocketAddress = toSocketAddress(serverHost, serverPort);
- socksProxy.connect(inetSocketAddress, operationContext.getTimeoutContext().getConnectTimeoutMs());
-
- SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socksProxy, serverHost, serverPort, true);
- //Even though Socks proxy connection is already established, TLS handshake has not been performed yet.
- //So it is possible to set SSL parameters before handshake is done.
- configureSslSocket(sslSocket, sslSettings, inetSocketAddress);
- return sslSocket;
+ // Track the outermost socket layer to close on failure. Initially this is socksProxy;
+ // once we wrap it into an SSLSocket, that becomes the outermost layer and closing it
+ // tears down the underlying socksProxy as well.
+ Socket toClose = socksProxy;
+ try {
+ configureSocket(socksProxy, operationContext, settings);
+ InetSocketAddress inetSocketAddress = toSocketAddress(serverHost, serverPort);
+ try {
+ socksProxy.connect(inetSocketAddress, operationContext.getTimeoutContext().getConnectTimeoutMs());
+ } catch (IOException e) {
+ throw wrapAsProxyTcpConnect(e);
+ }
+ SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socksProxy, serverHost, serverPort, true);
+ toClose = sslSocket;
+ //Even though Socks proxy connection is already established, TLS handshake has not been performed yet.
+ //So it is possible to set SSL parameters before handshake is done.
+ configureSslSocket(sslSocket, sslSettings, inetSocketAddress);
+ return sslSocket;
+ } catch (IOException | RuntimeException e) {
+ try {
+ toClose.close();
+ } catch (IOException closeException) {
+ e.addSuppressed(closeException);
+ }
+ throw e;
+ }
}
@@ -139,19 +165,41 @@ private static InetSocketAddress toSocketAddress(final String serverHost, final
return InetSocketAddress.createUnresolved(serverHost, serverPort);
}
+ private MongoSocksProxyException wrapAsProxyTcpConnect(final IOException cause) {
+ ProxySettings proxySettings = settings.getProxySettings();
+ return new MongoSocksProxyException(
+ "Exception connecting to SOCKS5 proxy (" + proxySettings.getHost() + ":" + proxySettings.getPort() + ")",
+ getAddress(), cause,
+ MongoSocksProxyException.HandshakePhase.PROXY_TCP_CONNECT);
+ }
+
private Socket initializeSocketOverSocksProxy(final OperationContext operationContext) throws IOException {
Socket createdSocket = socketFactory.createSocket();
- configureSocket(createdSocket, operationContext, settings);
- /*
- Wrap the configured socket with SocksSocket to add extra functionality.
- Reason for separate steps: We can't directly extend Java 11 methods within 'SocksSocket'
- to configure itself.
- */
- SocksSocket socksProxy = new SocksSocket(createdSocket, settings.getProxySettings());
-
- socksProxy.connect(toSocketAddress(address.getHost(), address.getPort()),
- operationContext.getTimeoutContext().getConnectTimeoutMs());
- return socksProxy;
+ try {
+ configureSocket(createdSocket, operationContext, settings);
+ /*
+ Wrap the configured socket with SocksSocket to add extra functionality.
+ Reason for separate steps: We can't directly extend Java 11 methods within 'SocksSocket'
+ to configure itself.
+ */
+ SocksSocket socksProxy = new SocksSocket(createdSocket, settings.getProxySettings());
+ try {
+ socksProxy.connect(toSocketAddress(address.getHost(), address.getPort()),
+ operationContext.getTimeoutContext().getConnectTimeoutMs());
+ } catch (IOException e) {
+ throw wrapAsProxyTcpConnect(e);
+ }
+ return socksProxy;
+ } catch (IOException | RuntimeException e) {
+ // SocksSocket.connect() closes itself on failure, but createdSocket may not yet
+ // be owned by a SocksSocket (e.g. configureSocket threw). Close defensively;
+ try {
+ createdSocket.close();
+ } catch (IOException closeException) {
+ e.addSuppressed(closeException);
+ }
+ throw e;
+ }
}
@Override
diff --git a/driver-core/src/main/com/mongodb/internal/connection/SocksSocket.java b/driver-core/src/main/com/mongodb/internal/connection/SocksSocket.java
index 2619a3c2c1..89166f9bce 100644
--- a/driver-core/src/main/com/mongodb/internal/connection/SocksSocket.java
+++ b/driver-core/src/main/com/mongodb/internal/connection/SocksSocket.java
@@ -15,6 +15,9 @@
*/
package com.mongodb.internal.connection;
+import com.mongodb.MongoSocksProxyException;
+import com.mongodb.MongoSocksProxyException.HandshakePhase;
+import com.mongodb.ServerAddress;
import com.mongodb.connection.ProxySettings;
import com.mongodb.internal.time.Timeout;
import com.mongodb.lang.Nullable;
@@ -95,24 +98,56 @@ public void connect(final SocketAddress endpoint, final int connectTimeoutMs) th
timeout.checkedRun(MILLISECONDS,
() -> socketConnect(proxyAddress, 0),
(ms) -> socketConnect(proxyAddress, Math.toIntExact(ms)),
- () -> throwSocketConnectionTimeout());
-
- SocksAuthenticationMethod authenticationMethod = performNegotiation(timeout);
- authenticate(authenticationMethod, timeout);
- sendConnect(timeout);
- } catch (SocketException socketException) {
- /*
- * The 'close()' call here has two purposes:
- *
- * 1. Enforces self-closing under RFC 1928 if METHOD is X'FF'.
- * 2. Handles all other errors during connection, distinct from external closures.
- */
+ SocksSocket::throwSocketConnectionTimeout);
+
+ // Each call below is wrapped so any IOException raised inside that phase is converted to
+ // a MongoSocksProxyException with the actual phase. Otherwise IOExceptions (EOF, timeout,
+ // unknown reply codes) escape unwrapped and are mislabeled as PROXY_TCP_CONNECT upstream.
+ // A MongoSocksProxyException thrown directly by a phase method is a RuntimeException and
+ // propagates past the IOException catch to the outer block.
+ SocksAuthenticationMethod authenticationMethod;
+ try {
+ authenticationMethod = performNegotiation(timeout);
+ } catch (IOException e) {
+ throw new MongoSocksProxyException("SOCKS5 negotiation failed: " + e.getMessage(),
+ targetServerAddress(), e, HandshakePhase.NEGOTIATION);
+ }
+
+ try {
+ authenticate(authenticationMethod, timeout);
+ } catch (IOException e) {
+ throw new MongoSocksProxyException("SOCKS5 authentication failed: " + e.getMessage(),
+ targetServerAddress(), e, HandshakePhase.AUTHENTICATION);
+ }
+
+ try {
+ sendConnect(timeout);
+ } catch (IOException e) {
+ throw new MongoSocksProxyException("SOCKS5 CONNECT relay failed: " + e.getMessage(),
+ targetServerAddress(), e, HandshakePhase.CONNECT_RELAY);
+ }
+ } catch (MongoSocksProxyException e) {
+ // Reached for any SOCKS5 protocol failure (negotiation / authentication / CONNECT-relay,
+ // including RFC 1928 X'FF' "no acceptable method" self-close). The proxy TCP socket is
+ // already connected at this point. MongoSocksProxyException is a RuntimeException and is
+ // not caught below, so close the socket here to avoid leaking the FD.
try {
close();
} catch (Exception closeException) {
- socketException.addSuppressed(closeException);
+ e.addSuppressed(closeException);
}
- throw socketException;
+ throw e;
+ } catch (IOException ioException) {
+ // Reached only when the initial proxy TCP connect fails
+ // before any SOCKS5 handshake byte goes on the wire. Inner-phase IOExceptions are
+ // converted to MongoSocksProxyException by the per-phase wrappers and caught above.
+ // Close the partially-initialised proxy socket so we don't leak a FD.
+ try {
+ close();
+ } catch (Exception closeException) {
+ ioException.addSuppressed(closeException);
+ }
+ throw ioException;
}
}
@@ -223,7 +258,9 @@ private void checkServerReply(final Timeout timeout) throws IOException {
}
return;
}
- throw new ConnectException(reply.getMessage());
+ throw new MongoSocksProxyException(
+ "SOCKS5 CONNECT reply: " + reply.message + " (code " + reply.replyNumber + ")",
+ targetServerAddress(), HandshakePhase.CONNECT_RELAY, reply.replyNumber);
}
private void authenticate(final SocksAuthenticationMethod authenticationMethod, final Timeout timeout) throws IOException {
@@ -249,7 +286,9 @@ private void authenticate(final SocksAuthenticationMethod authenticationMethod,
byte authStatus = authResult[1];
if (authStatus != AUTHENTICATION_SUCCEEDED_STATUS) {
- throw new ConnectException("Authentication failed. Proxy server returned status: " + authStatus);
+ throw new MongoSocksProxyException(
+ "Authentication failed. Proxy server returned status: " + authStatus,
+ targetServerAddress(), HandshakePhase.AUTHENTICATION);
}
}
}
@@ -273,13 +312,16 @@ private SocksAuthenticationMethod performNegotiation(final Timeout timeout) thro
byte[] handshakeReply = readSocksReply(2, timeout);
if (handshakeReply[0] != SOCKS_VERSION) {
- throw new ConnectException("Remote server doesn't support socks version 5"
- + " Received version: " + handshakeReply[0]);
+ throw new MongoSocksProxyException("Remote server doesn't support socks version 5"
+ + " Received version: " + handshakeReply[0],
+ targetServerAddress(), HandshakePhase.NEGOTIATION);
}
byte authMethodNumber = handshakeReply[1];
if (authMethodNumber == (byte) 0xFF) {
- throw new ConnectException("None of the authentication methods listed are acceptable. Attempted methods: "
- + Arrays.toString(authenticationMethods));
+ throw new MongoSocksProxyException(
+ "None of the authentication methods listed are acceptable. Attempted methods: "
+ + Arrays.toString(authenticationMethods),
+ targetServerAddress(), HandshakePhase.NEGOTIATION);
}
if (authMethodNumber == SocksAuthenticationMethod.NO_AUTH.getMethodNumber()) {
return SocksAuthenticationMethod.NO_AUTH;
@@ -287,7 +329,12 @@ private SocksAuthenticationMethod performNegotiation(final Timeout timeout) thro
return SocksAuthenticationMethod.USERNAME_PASSWORD;
}
- throw new ConnectException("Proxy returned unsupported authentication method: " + authMethodNumber);
+ throw new MongoSocksProxyException("Proxy returned unsupported authentication method: " + authMethodNumber,
+ targetServerAddress(), HandshakePhase.NEGOTIATION);
+ }
+
+ private ServerAddress targetServerAddress() {
+ return new ServerAddress(remoteAddress.getHostString(), remoteAddress.getPort());
}
private SocksAuthenticationMethod[] getSocksAuthenticationMethods() {
@@ -435,6 +482,10 @@ static ServerReply of(final byte byteStatus) throws ConnectException {
public String getMessage() {
return message;
}
+
+ public int getReplyNumber() {
+ return replyNumber;
+ }
}
@Override
diff --git a/driver-core/src/test/unit/com/mongodb/internal/connection/BackpressureErrorLabelerTest.java b/driver-core/src/test/unit/com/mongodb/internal/connection/BackpressureErrorLabelerTest.java
index 37b67430d2..7851ba0ec4 100644
--- a/driver-core/src/test/unit/com/mongodb/internal/connection/BackpressureErrorLabelerTest.java
+++ b/driver-core/src/test/unit/com/mongodb/internal/connection/BackpressureErrorLabelerTest.java
@@ -22,6 +22,7 @@
import com.mongodb.MongoSocketException;
import com.mongodb.MongoSocketOpenException;
import com.mongodb.MongoSocketReadTimeoutException;
+import com.mongodb.MongoSocksProxyException;
import com.mongodb.ServerAddress;
import net.bytebuddy.ByteBuddy;
import org.junit.jupiter.api.Named;
@@ -83,6 +84,71 @@ void dnsFailureShouldNotBeLabeled(final MongoSocketException e) {
assertLacksBackpressureLabels(e);
}
+ static Stream> socks5ProxyExceptionsShouldNotBeLabeled() {
+ return Stream.of(
+ // PROXY_TCP_CONNECT happens before any byte is exchanged with mongod — the proxy
+ // itself is unreachable. Not a mongod overload signal.
+ named(new MongoSocksProxyException("tcp connect to proxy failed", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.PROXY_TCP_CONNECT)),
+ // NEGOTIATION + AUTHENTICATION are proxy-side protocol / credential errors.
+ named(new MongoSocksProxyException("negotiation failed", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.NEGOTIATION)),
+ named(new MongoSocksProxyException("auth failed", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.AUTHENTICATION)),
+ // CONNECT_RELAY with null replyCode = I/O failure or unrecognized reply field;
+ // no definitive mongod-side signal.
+ named(new MongoSocksProxyException("connect relay io failure", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, null)),
+ // CONNECT_RELAY with proxy-side / ambiguous reply codes — not mongod-attributable:
+ // 0x01 GENERAL_FAILURE (too generic to attribute)
+ // 0x02 NOT_ALLOWED (proxy ACL)
+ // 0x06 TTL_EXPIRED (transient routing, ambiguous)
+ // 0x07 COMMAND_NOT_SUPPORTED (proxy capability)
+ // 0x08 ADDRESS_TYPE_NOT_SUPPORTED (proxy capability)
+ named(new MongoSocksProxyException("general failure", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 1)),
+ named(new MongoSocksProxyException("not allowed", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 2)),
+ named(new MongoSocksProxyException("ttl expired", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 6)),
+ named(new MongoSocksProxyException("command not supported", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 7)),
+ named(new MongoSocksProxyException("address type not supported", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 8))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void socks5ProxyExceptionsShouldNotBeLabeled(final MongoSocketException e) {
+ BackpressureErrorLabeler.applyLabelsIfEligible(e);
+ assertLacksBackpressureLabels(e);
+ }
+
+ static Stream> socks5ProxyExceptionsShouldBeLabeled() {
+ // CONNECT_RELAY with reply codes 3 / 4 / 5 means the proxy tried to reach mongod on our
+ // behalf and got a transport-level failure that mirrors a direct-connection socket-open
+ // outcome.
+ return Stream.of(
+ // 0x03 NET_UNREACHABLE — proxy → mongod network path is down (≈ NoRouteToHostException)
+ named(new MongoSocksProxyException("network unreachable", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 3)),
+ // 0x04 HOST_UNREACHABLE — proxy can't reach mongod host (≈ NoRouteToHostException)
+ named(new MongoSocksProxyException("host unreachable", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 4)),
+ // 0x05 CONN_REFUSED — mongod actively refused (≈ ConnectException)
+ named(new MongoSocksProxyException("connection refused", ADDRESS,
+ MongoSocksProxyException.HandshakePhase.CONNECT_RELAY, 5))
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ void socks5ProxyExceptionsShouldBeLabeled(final MongoSocketException e) {
+ BackpressureErrorLabeler.applyLabelsIfEligible(e);
+ assertHasBackpressureLabels(e);
+ }
+
static Stream> localTlsConfigErrorShouldNotBeLabeled() {
return Stream.of(
named(new CertificateException("bad cert")),
diff --git a/driver-core/src/test/unit/com/mongodb/internal/connection/SocksSocketTest.java b/driver-core/src/test/unit/com/mongodb/internal/connection/SocksSocketTest.java
new file mode 100644
index 0000000000..f57bbf942c
--- /dev/null
+++ b/driver-core/src/test/unit/com/mongodb/internal/connection/SocksSocketTest.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2008-present MongoDB, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mongodb.internal.connection;
+
+import com.mongodb.MongoSocksProxyException;
+import com.mongodb.MongoSocksProxyException.HandshakePhase;
+import com.mongodb.connection.ProxySettings;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Verifies that SocksSocket tags each SOCKS5 protocol failure with the correct HandshakePhase
+ * and, for CONNECT_RELAY failures, the correct RFC 1928 reply code.
+ * Uses a local mini-server; no real SOCKS5 proxy required.
+ */
+class SocksSocketTest {
+
+ private static final InetSocketAddress TARGET =
+ InetSocketAddress.createUnresolved("mongo.example.com", 27017);
+
+ private Exception connectWithMiniServer(final byte[] serverBytes, final boolean withCredentials)
+ throws Exception {
+ return connectWithMiniServer(serverBytes, withCredentials, false);
+ }
+
+ private Exception connectWithMiniServer(final byte[] serverBytes, final boolean withCredentials,
+ final boolean eofAfterWrite)
+ throws Exception {
+ try (ServerSocket server = new ServerSocket(0)) {
+ int port = server.getLocalPort();
+
+ Thread t = new Thread(() -> {
+ try (Socket client = server.accept()) {
+ OutputStream out = client.getOutputStream();
+ out.write(serverBytes);
+ out.flush();
+ if (eofAfterWrite) {
+ // Half-close: send TCP FIN so the client sees EOF (in.read() == -1) on its
+ // next read.
+ client.shutdownOutput();
+ }
+ // Drain anything the client writes (negotiation/auth/CONNECT bytes) until the
+ // client closes its end. This blocks the server thread so it does not tear
+ // down the socket while the client is still reading the canned bytes.
+ // Bounded by the client's natural close in the SocksSocket finally block.
+ // Plain read-loop (no transferTo/nullOutputStream) for Java 8 source compatibility.
+ InputStream in = client.getInputStream();
+ byte[] discard = new byte[1024];
+ //noinspection StatementWithEmptyBody
+ while (in.read(discard) != -1) {
+ // discard
+ }
+ } catch (Exception ignored) {
+ }
+ });
+ t.setDaemon(true);
+ t.start();
+
+ try (SocksSocket socksSocket = new SocksSocket(buildProxySettings("127.0.0.1", port, withCredentials))) {
+ try {
+ socksSocket.connect(TARGET, 5000);
+ return null;
+ } catch (MongoSocksProxyException | IOException e) {
+ return e;
+ }
+ } finally {
+ try {
+ t.join(5000);
+ } catch (InterruptedException ie) {
+ // Don't mask the primary exception (if any) with the join interruption;
+ // just preserve the thread's interrupt status and continue.
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ }
+
+ private static ProxySettings buildProxySettings(final String host, final int port, final boolean withCredentials) {
+ ProxySettings.Builder b = ProxySettings.builder().host(host).port(port);
+ if (withCredentials) {
+ b.username("user").password("pass");
+ }
+ return b.build();
+ }
+
+ private static MongoSocksProxyException assertProxy(final Exception ex) {
+ return assertInstanceOf(MongoSocksProxyException.class, ex,
+ "Expected MongoSocksProxyException but got: " + (ex == null ? "null" : ex.getClass().getName()));
+ }
+
+ // -----------------------------------------------------------------------
+ // CONNECT_RELAY — RFC 1928 server reply codes
+ // -----------------------------------------------------------------------
+
+ @Test
+ void hostUnreachablePhaseConnectRelayCode4() throws Exception {
+ byte[] bytes = {
+ 0x05, 0x00, // negotiation OK, no auth
+ 0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0 // HOST_UNREACHABLE
+ };
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false));
+ Assertions.assertNotNull(ex);
+ assertEquals(HandshakePhase.CONNECT_RELAY, ex.getHandshakePhase());
+ assertEquals(SocksSocket.ServerReply.HOST_UNREACHABLE.getReplyNumber(), ex.getProxyReplyCode());
+ }
+
+ @Test
+ void connRefusedPhaseConnectRelayCode5() throws Exception {
+ byte[] bytes = {
+ 0x05, 0x00,
+ 0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0 // CONN_REFUSED
+ };
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false));
+ Assertions.assertNotNull(ex);
+ assertEquals(HandshakePhase.CONNECT_RELAY, ex.getHandshakePhase());
+ assertEquals(SocksSocket.ServerReply.CONN_REFUSED.getReplyNumber(), ex.getProxyReplyCode());
+ }
+
+ @Test
+ void notAllowedPhaseConnectRelayCode2() throws Exception {
+ byte[] bytes = {
+ 0x05, 0x00,
+ 0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0 // NOT_ALLOWED
+ };
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false));
+ Assertions.assertNotNull(ex);
+ assertEquals(HandshakePhase.CONNECT_RELAY, ex.getHandshakePhase());
+ assertEquals(SocksSocket.ServerReply.NOT_ALLOWED.getReplyNumber(), ex.getProxyReplyCode());
+ }
+
+ // -----------------------------------------------------------------------
+ // AUTHENTICATION
+ // -----------------------------------------------------------------------
+
+ @Test
+ void authRejectedPhaseAuthenticationNoReplyCode() throws Exception {
+ byte[] bytes = {
+ 0x05, 0x02, // negotiation OK, needs username/password
+ 0x01, 0x01 // auth rejected
+ };
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, true));
+ Assertions.assertNotNull(ex);
+ assertEquals(HandshakePhase.AUTHENTICATION, ex.getHandshakePhase());
+ assertNull(ex.getProxyReplyCode());
+ }
+
+ // -----------------------------------------------------------------------
+ // NEGOTIATION
+ // -----------------------------------------------------------------------
+
+ @Test
+ void noAcceptableMethodPhaseNegotiationNoReplyCode() throws Exception {
+ byte[] bytes = {0x05, (byte) 0xFF};
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false));
+ Assertions.assertNotNull(ex);
+ assertEquals(HandshakePhase.NEGOTIATION, ex.getHandshakePhase());
+ assertNull(ex.getProxyReplyCode());
+ }
+
+ @Test
+ void wrongSocksVersionPhaseNegotiationNoReplyCode() throws Exception {
+ byte[] bytes = {0x04, 0x00};
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false));
+ Assertions.assertNotNull(ex);
+ assertEquals(HandshakePhase.NEGOTIATION, ex.getHandshakePhase());
+ assertNull(ex.getProxyReplyCode());
+ }
+
+ // -----------------------------------------------------------------------
+ // IOException-during-handshake → tagged with the proper phase, not PROXY_TCP_CONNECT
+ // -----------------------------------------------------------------------
+
+ @Test
+ void ioFailureDuringNegotiationTaggedAsNegotiation() throws Exception {
+ // Mini-server half-closes immediately after writing zero bytes of method-selection reply.
+ // Client's readSocksReply sees EOF (in.read() == -1) and throws ConnectException("Malformed
+ // reply..."). That IOException must be wrapped as MongoSocksProxyException with
+ // phase=NEGOTIATION, not PROXY_TCP_CONNECT.
+ byte[] noReply = new byte[0];
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(noReply, false, true));
+ Assertions.assertNotNull(ex);
+ Assertions.assertTrue(ex.getMessage().contains("Malformed reply from SOCKS proxy server"));
+ assertEquals(HandshakePhase.NEGOTIATION, ex.getHandshakePhase());
+ assertNull(ex.getProxyReplyCode());
+ }
+
+ @Test
+ void unknownReplyCodeDuringConnectRelayTaggedAsConnectRelay() throws Exception {
+ // Reply code 0x09 is not a known RFC 1928 code.
+ byte[] bytes = {
+ 0x05, 0x00, // negotiation OK
+ 0x05, 0x09, 0x00, 0x01, 0, 0, 0, 0, 0, 0 // unknown reply code 0x09
+ };
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, false));
+ Assertions.assertNotNull(ex);
+ assertEquals(HandshakePhase.CONNECT_RELAY, ex.getHandshakePhase());
+ assertNull(ex.getProxyReplyCode());
+ }
+
+ @Test
+ void ioFailureDuringAuthenticationTaggedAsAuthentication() throws Exception {
+ // Negotiation succeeds picking USERNAME_PASSWORD; mini-server then half-closes immediately,
+ // so the client reads the 2 negotiation bytes successfully and then sees EOF on the
+ // subsequent auth-result read. readSocksReply throws ConnectException("Malformed reply...")
+ // from inside authenticate(). The wrapper must tag the IOException as AUTHENTICATION.
+ byte[] bytes = {0x05, 0x02}; // negotiation OK, picked username/password; then EOF
+ MongoSocksProxyException ex = assertProxy(connectWithMiniServer(bytes, true, true));
+ Assertions.assertNotNull(ex);
+ Assertions.assertTrue(ex.getMessage().contains("Malformed reply from SOCKS proxy server"));
+ assertEquals(HandshakePhase.AUTHENTICATION, ex.getHandshakePhase());
+ assertNull(ex.getProxyReplyCode());
+ }
+
+ // -----------------------------------------------------------------------
+ // PROXY_TCP_CONNECT — inferred at SocketStream boundary, not tagged here
+ // -----------------------------------------------------------------------
+
+ @Test
+ void tcpConnectFailureNotMongoSocksProxyException() throws IOException {
+ // Bind an ephemeral port then release it, so we have a port that is reliably closed
+ // for the duration of this test. Using a hard-coded low port (e.g. 1) is unreliable
+ // because some systems have services listening there.
+ int closedPort;
+ try (ServerSocket probe = new ServerSocket(0)) {
+ closedPort = probe.getLocalPort();
+ }
+ try (SocksSocket s = new SocksSocket(buildProxySettings("127.0.0.1", closedPort, false))) {
+ // Expecting a plain IOException TCP connect failures
+ // are NOT tagged as MongoSocksProxyException at the SocksSocket layer.
+ assertThrows(IOException.class, () -> s.connect(TARGET, 5000));
+ }
+ }
+
+ @SuppressWarnings({"ThrowableNotThrown", "DataFlowIssue"})
+ @Test
+ void constructorRejectsNullHandshakePhase() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new MongoSocksProxyException("m", new com.mongodb.ServerAddress(), null));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new MongoSocksProxyException("m", new com.mongodb.ServerAddress(),
+ new RuntimeException("c"), null));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new MongoSocksProxyException("m", new com.mongodb.ServerAddress(), null, 5));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new MongoSocksProxyException("m", new com.mongodb.ServerAddress(),
+ new RuntimeException("c"), null, 5));
+ }
+}
diff --git a/driver-scala/src/main/scala/org/mongodb/scala/package.scala b/driver-scala/src/main/scala/org/mongodb/scala/package.scala
index 6443157936..27f206d5f0 100644
--- a/driver-scala/src/main/scala/org/mongodb/scala/package.scala
+++ b/driver-scala/src/main/scala/org/mongodb/scala/package.scala
@@ -389,6 +389,13 @@ package object scala extends ClientSessionImplicits with ObservableImplicits wit
*/
type MongoSocketWriteException = com.mongodb.MongoSocketWriteException
+ /**
+ * This exception is thrown when an error occurs while establishing a connection to a SOCKS5 proxy.
+ *
+ * @since 5.8
+ */
+ type MongoSocksProxyException = com.mongodb.MongoSocksProxyException
+
/**
* An exception indicating that the driver has timed out waiting for either a server or a connection to become available.
*/
diff --git a/driver-sync/src/test/functional/com/mongodb/client/Socks5ProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/Socks5ProseTest.java
index 20e3a35534..d09617aef3 100644
--- a/driver-sync/src/test/functional/com/mongodb/client/Socks5ProseTest.java
+++ b/driver-sync/src/test/functional/com/mongodb/client/Socks5ProseTest.java
@@ -17,7 +17,7 @@
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
-import com.mongodb.MongoSocketOpenException;
+import com.mongodb.MongoSocksProxyException;
import com.mongodb.MongoTimeoutException;
import com.mongodb.connection.ClusterDescription;
import com.mongodb.connection.ServerDescription;
@@ -151,7 +151,7 @@ private static void assertSocksAuthenticationIssue(final ClusterListener cluster
.filter(Objects::nonNull)
.collect(Collectors.toList());
assumeFalse(errors.isEmpty());
- errors.forEach(throwable -> Assertions.assertEquals(MongoSocketOpenException.class, throwable.getClass()));
+ errors.forEach(throwable -> Assertions.assertInstanceOf(MongoSocksProxyException.class, throwable));
}
private static void runHelloCommand(final MongoClient mongoClient) {