Skip to content
Merged
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
54 changes: 53 additions & 1 deletion src/main/java/org/metricshub/winrm/WinRMClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ public static final class Builder {
private int consoleCodePage;
private SSLContext sslContext;
private Duration timeout = DEFAULT_TIMEOUT;
private int retries;
private Duration retryDelay = Duration.ZERO;

private Builder(final String hostname) {
Utils.checkNonBlank(hostname, "hostname");
Expand Down Expand Up @@ -441,6 +443,54 @@ public Builder timeout(final Duration timeout) {
return this;
}

/**
* Retry transient connection failures. Default: no retry — any failure is reported
* immediately.
* <p>
* The policy is deliberately narrow, to preserve <b>at-most-once execution</b> for
* non-idempotent commands: a WSMan round trip is retried only when it failed to establish
* and authenticate the connection — TCP connect, DNS resolution, the TLS handshake, or a
* transport error during the authentication handshake — because there the request provably
* never reached the server. A request that was actually sent (a command that may have
* started, a query that may be executing) is never retried; neither are credential
* rejections ({@link org.metricshub.winrm.exceptions.WinRMAuthenticationException}) or
* WSMan faults ({@link org.metricshub.winrm.exceptions.WinRMFaultException}).
* <p>
* The policy is connection-scoped: it applies to every operation on the client, including
* reconnections in the middle of one (WinRM connections are re-established transparently
* when the server drops an idle one). Retries stay inside each operation's wall-clock
* deadline — when the timeout elapses mid-pause, the operation fails with
* {@link org.metricshub.winrm.exceptions.WinRMTimeoutException} exactly as it would without
* a retry policy. For the streaming terminals (whose timeout is an inactivity timeout with
* no overall deadline), each connection attempt is bounded by that timeout, so a retried
* reconnection can extend the tolerated silence accordingly.
*
* <pre>{@code
* WinRMClient client = WinRMClient.builder("server01")
* .credentials("ACME\\admin", password)
* .retries(2, Duration.ofSeconds(5)) // up to 2 retries, pausing 5 s before each
* .build();
* }</pre>
*
* @param retries how many times a failed connection attempt is retried (0 disables retrying)
* @param delay the pause before each retry ({@link Duration#ZERO} retries immediately)
* @return this builder
* @throws IllegalArgumentException when {@code retries} is negative, or {@code delay} is
* null or negative
*/
public Builder retries(final int retries, final Duration delay) {
if (retries < 0) {
throw new IllegalArgumentException("retries must not be negative.");
}
Utils.checkNonNull(delay, "delay");
if (delay.isNegative()) {
throw new IllegalArgumentException("delay must not be negative.");
}
this.retries = retries;
this.retryDelay = delay;
return this;
}

/**
* Set the console code page of the remote command shell. Default: 65001 (UTF-8), which makes
* command output UTF-8 whatever the remote locale — the right choice for reading output and
Expand Down Expand Up @@ -499,7 +549,9 @@ public WinRMClient build() {
authentications,
sslContext,
trustAllCertificates,
consoleCodePage
consoleCodePage,
retries,
toMillis(retryDelay)
);
return new WinRMClient(executor, endpoint.getHostname(), endpoint.getNamespace(), timeout);
} catch (final WinRMException e) {
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/org/metricshub/winrm/light/HttpTransport.java
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,29 @@ private boolean isStalePeerClosed() {
}
}

/**
* Establish (or validate) the connection now instead of lazily on the next {@link #post}. Lets
* the caller separate "could not reach the endpoint" — where nothing has been sent and a retry
* is provably safe — from a failure of a request that may already be executing.
*
* @throws IOException when the connection cannot be established
*/
void connect() throws IOException {
ensureConnected();
}

/**
* How many milliseconds remain of the active deadline-bounded poll ({@link #pollTimeout(int)}),
* or {@link Long#MAX_VALUE} when no poll deadline is active. Lets a caller decide whether a
* retry pause still fits inside the poll's hard bound.
*/
long remainingPollBudgetMillis() {
if (deadlineEpochMillis == 0 || deadlinePerLeg) {
return Long.MAX_VALUE;
}
return deadlineEpochMillis - Utils.getCurrentTimeMillis();
}

private void ensureConnected() throws IOException {
if (isConnected()) {
return;
Expand Down
59 changes: 58 additions & 1 deletion src/main/java/org/metricshub/winrm/light/LightWinRMService.java
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,64 @@ public static LightWinRMService createInstance(
final SSLContext sslContext,
final boolean trustAllCertificates,
final int consoleCodePage
) throws WinRMException {
return createInstance(
winRMEndpoint,
timeout,
ticketCache,
authentications,
sslContext,
trustAllCertificates,
consoleCodePage,
0,
0L
);
}

/**
* Create a light WinRM executor with an opt-in retry policy for transient connection failures.
* A round trip is retried only when it failed to establish and authenticate the connection —
* TCP connect, DNS resolution, TLS handshake, or a transport error during the authentication
* handshake — i.e. when its request provably never reached the server; a request that may have
* started executing is never retried, preserving at-most-once execution semantics.
*
* @param winRMEndpoint endpoint with credentials (mandatory)
* @param timeout timeout in milliseconds (must be &gt; 0)
* @param ticketCache Kerberos ticket cache path (used by the Kerberos scheme; {@code null} logs
* in with the password)
* @param authentications requested authentication schemes, tried in order (NTLM and/or Kerberos);
* {@code null}/empty means NTLM only
* @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname
* verification stays on); {@code null} uses the default configuration
* @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every
* server certificate and skip hostname verification — insecure, testing only
* @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001,
* which makes command output UTF-8 whatever the remote locale
* @param connectRetries how many times one round trip may re-attempt to connect and authenticate
* (must be &gt;= 0); 0 keeps the historical fail-fast behavior
* @param retryDelay the pause in milliseconds before each retry (must be &gt;= 0)
* @return a new {@code LightWinRMService}
* @throws WinRMException on invalid arguments or an unsupported authentication request
*/
public static LightWinRMService createInstance(
final WinRMEndpoint winRMEndpoint,
final long timeout,
final java.nio.file.Path ticketCache,
final List<AuthenticationEnum> authentications,
final SSLContext sslContext,
final boolean trustAllCertificates,
final int consoleCodePage,
final int connectRetries,
final long retryDelay
) throws WinRMException {
Utils.checkNonNull(winRMEndpoint, "winRMEndpoint");
Utils.checkArgumentNotZeroOrNegative(timeout, "timeout");
if (connectRetries < 0) {
throw new IllegalArgumentException("connectRetries must not be negative.");
}
if (retryDelay < 0) {
throw new IllegalArgumentException("retryDelay must not be negative.");
}

// HTTPS wraps the transport in TLS and exchanges plaintext SOAP; HTTP uses NTLM message sealing.
// TLS validates by default (platform trust store + hostname verification); see LightTls. A
Expand Down Expand Up @@ -179,7 +234,9 @@ public static LightWinRMService createInstance(
verifyHostname,
authScheme,
winRMEndpoint.getRawUsername(),
consoleCodePage
consoleCodePage,
connectRetries,
retryDelay
);
return new LightWinRMService(winRMEndpoint, client);
}
Expand Down
67 changes: 65 additions & 2 deletions src/main/java/org/metricshub/winrm/light/WsmanClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
Expand Down Expand Up @@ -84,6 +85,13 @@ final class WsmanClient implements AutoCloseable {
private final AuthScheme auth;
private final HttpTransport transport;

// Opt-in retry policy for transient connection failures (issue #158): how many times one round
// trip may re-attempt to establish and authenticate the connection, and the pause before each
// attempt. Applies ONLY to failures where the round trip's request provably never reached the
// server (see send()); 0 retries — the default — keeps the historical fail-fast behavior.
private final int connectRetries;
private final long retryDelayMs;

private String pendingAuthorization;
private String shellId;

Expand Down Expand Up @@ -150,10 +158,14 @@ private static void checkNotCancelled() throws InterruptedException {
final boolean verifyHostname,
final AuthScheme auth,
final String rawUsername,
final int consoleCodePage
final int consoleCodePage,
final int connectRetries,
final long retryDelayMs
) {
this.timeoutMs = timeoutMs;
this.consoleCodePage = consoleCodePage;
this.connectRetries = connectRetries;
this.retryDelayMs = retryDelayMs;
// A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext.
this.url = (sslSocketFactory != null ? "https" : "http") + "://" + host + ":" + port + "/wsman";
this.rawUsername = rawUsername;
Expand Down Expand Up @@ -1055,9 +1067,44 @@ private Decoded send(final String soap) throws Exception {
final byte[] body = soap.getBytes(StandardCharsets.UTF_8);
// Stays null while the loop retries the next authentication scheme on a fresh connection.
Decoded decoded = null;
int retriesLeft = connectRetries;
while (decoded == null) {
if (!auth.isAuthenticated()) {
pendingAuthorization = auth.authenticate(transport);
try {
// Connect explicitly (rather than letting post() do it lazily) so every failure to
// REACH the endpoint — TCP connect, DNS resolution, TLS handshake — surfaces here,
// alongside the authentication round trips: the one phase where this round trip's
// request provably never reached the server, which is the only situation where a
// retry cannot duplicate a side effect (issue #158).
transport.connect();
pendingAuthorization = auth.authenticate(transport);
} catch (final Exception e) {
// Transient connection failure: apply the opt-in retry policy — but only to a
// transport I/O failure (possibly wrapped, e.g. by an ordered authentication
// fallback), and unless the client is closing (best-effort cleanup must not
// linger), the retries are exhausted, or a deadline-bounded poll cannot absorb
// the pause within its hard bound. A cancelled worker (its caller was already
// told the operation timed out) stops in the sleep.
if (closed
||
retriesLeft <= 0
||
!isConnectionFailure(e)
||
transport.remainingPollBudgetMillis() <= retryDelayMs) {
throw e;
}
retriesLeft--;
auth.reset();
Thread.sleep(retryDelayMs);
// close() cannot interrupt a worker sleeping here: recheck after the pause, or
// the retry would revive the hard-closed transport and send the (possibly
// non-idempotent) operation after the client was closed.
if (closed) {
throw e;
}
continue;
Comment on lines +1099 to +1106

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recheck closure after each retry delay

When another thread calls close() while an operation is sleeping here, close() sets closed and returns, but it does not interrupt this worker. Once the delay ends, continue re-enters send() without passing through request()'s closure check, so the worker can reconnect, authenticate, and send the original SOAP request—including a non-idempotent command—after the client has closed. Check closed again before every retry attempt so closing during a pause cannot revive the transport or execute delayed side effects.

Useful? React with 👍 / 👎.

}
}
// The handshake's Authorization accompanies the first real request; later requests on the
// already-authenticated connection carry no Authorization header.
Expand Down Expand Up @@ -1100,6 +1147,22 @@ private Decoded send(final String soap) throws Exception {
return decoded;
}

/**
* Whether the failure is a transport I/O failure — directly, or through its cause chain: an
* ordered authentication fallback reports "all schemes failed" with the last scheme's failure
* as the cause, which must still be recognized when that failure was a transient transport
* error. Anything without an {@link IOException} in the chain (a protocol violation, a
* rejected credential, an interrupt) is not a connection failure and must not be retried.
*/
private static boolean isConnectionFailure(final Exception exception) {
for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
if (cause instanceof IOException) {
return true;
}
}
return false;
}

// --- XML helpers --------------------------------------------------------

static Document parse(final byte[] xml) throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,49 @@ public static WindowsRemoteExecutor createInstance(
consoleCodePage
);
}

/**
* Create a {@link WindowsRemoteExecutor} with an opt-in retry policy for transient connection
* failures. A round trip is retried only when it failed to establish and authenticate the
* connection — i.e. when its request provably never reached the server — so at-most-once
* execution semantics are preserved.
*
* @param winRMEndpoint endpoint with credentials (mandatory)
* @param timeout timeout in milliseconds (must be &gt; 0)
* @param ticketCache Kerberos ticket cache path (may be {@code null})
* @param authentications requested authentication schemes (may be {@code null})
* @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname
* verification stays on); {@code null} uses the default configuration
* @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every
* server certificate and skip hostname verification — insecure, testing only
* @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001
* @param connectRetries how many times one round trip may re-attempt to connect and authenticate
* (must be &gt;= 0); 0 keeps the historical fail-fast behavior
* @param retryDelay the pause in milliseconds before each retry (must be &gt;= 0)
* @return an executor backed by {@link LightWinRMService}
* @throws WinRMException for any problem creating the executor
*/
public static WindowsRemoteExecutor createInstance(
final WinRMEndpoint winRMEndpoint,
final long timeout,
final Path ticketCache,
final List<AuthenticationEnum> authentications,
final SSLContext sslContext,
final boolean trustAllCertificates,
final int consoleCodePage,
final int connectRetries,
final long retryDelay
) throws WinRMException {
return LightWinRMService.createInstance(
winRMEndpoint,
timeout,
ticketCache,
authentications,
sslContext,
trustAllCertificates,
consoleCodePage,
connectRetries,
retryDelay
);
}
}
42 changes: 42 additions & 0 deletions src/site/markdown/timeouts-and-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,48 @@ long — but as soon as the server stays silent for a whole timeout, the operati
For commands, `RemoteProcess.waitFor(Duration)` provides an overall deadline on top when one is
needed.

## Retrying transient connection failures

By default there is **no retry**: a transient network failure — a dropped connection, a DNS
hiccup, a momentarily unreachable host — fails the operation immediately. The opt-in
`retries(...)` builder setting rides out such failures, which long-running monitoring and
automation workloads usually want:

```java
WinRMClient client = WinRMClient.builder("server.example.com")
.credentials("DOMAIN\\Administrator", password)
.retries(2, Duration.ofSeconds(5)) // up to 2 retries, pausing 5 s before each
.build();
```

The policy is deliberately narrow, to preserve **at-most-once execution** for non-idempotent
commands: a WSMan round trip is retried only when it failed to **establish and authenticate the
connection** — TCP connect, DNS resolution, the TLS handshake, or a transport error during the
authentication handshake — because there the request provably never reached the server. WQL
queries get no broader surface even though they are idempotent: re-issuing a WS-Enumeration
`Pull` whose response was lost could skip or duplicate rows.

The policy is connection-scoped: it applies to every round trip of every operation on the
client, including reconnections in the middle of one (WinRM connections are re-established
transparently when the server drops an idle one). What is **never** retried:

* a request that reached the wire — a command that may have started, a query that may be
executing;
* credential rejections
([`WinRMAuthenticationException`](apidocs/org/metricshub/winrm/exceptions/WinRMAuthenticationException.html));
* WSMan faults
([`WinRMFaultException`](apidocs/org/metricshub/winrm/exceptions/WinRMFaultException.html)).

Retries stay inside each operation's **wall-clock deadline**: when the timeout elapses mid-pause,
the operation fails with
[`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html)
exactly as it would without a retry policy. For the streaming terminals (whose timeout is an
inactivity timeout with no overall deadline), each connection attempt is bounded by that timeout,
so a retried reconnection can extend the tolerated silence accordingly.

Users migrating from winrm4j: `retries(1, Duration.ofSeconds(5))` is the closest equivalent of
its default `failureRetryPolicy` (one retry, 5-second pause).

## The exception surface

The fluent API is **unchecked**: every failure is a
Expand Down
Loading
Loading