diff --git a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java index 4e40ba5e50..f26fd5a9da 100644 --- a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java +++ b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java @@ -38,6 +38,7 @@ import com.hivemq.edge.adapters.opcua.client.Failure; import com.hivemq.edge.adapters.opcua.client.ParsedConfig; import com.hivemq.edge.adapters.opcua.client.Success; +import com.hivemq.edge.adapters.opcua.config.ConnectionOptions; import com.hivemq.edge.adapters.opcua.config.OpcUaSpecificAdapterConfig; import com.hivemq.edge.adapters.opcua.config.tag.OpcuaTag; import com.hivemq.edge.adapters.opcua.listeners.OpcUaServiceFaultListener; @@ -56,11 +57,13 @@ import java.util.List; import java.util.Map; +import java.util.Random; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; @@ -80,21 +83,22 @@ public class OpcUaProtocolAdapter implements WritingProtocolAdapter { private final @NotNull DataPointFactory dataPointFactory; private final @NotNull ProtocolAdapterMetricsService protocolAdapterMetricsService; private final @NotNull OpcUaSpecificAdapterConfig config; - private volatile @Nullable ScheduledExecutorService retryScheduler = null; private final @NotNull AtomicReference> retryFuture = new AtomicReference<>(); - private volatile @Nullable ScheduledExecutorService healthCheckScheduler = null; private final @NotNull AtomicReference> healthCheckFuture = new AtomicReference<>(); private final @NotNull OpcUaServiceFaultListener opcUaServiceFaultListener; + // Retry attempt tracking for exponential backoff private final @NotNull AtomicLong reconnectAttempts = new AtomicLong(0); private final @NotNull AtomicLong lastReconnectTimestamp = new AtomicLong(0); + private final @NotNull AtomicInteger consecutiveRetryAttempts = new AtomicInteger(0); // Lock to prevent concurrent reconnections private final @NotNull ReentrantLock reconnectLock = new ReentrantLock(); - + private volatile @Nullable ScheduledExecutorService retryScheduler = null; + private volatile @Nullable ScheduledExecutorService healthCheckScheduler = null; // Stored for reconnection - set during start() - private volatile ParsedConfig parsedConfig; - private volatile ModuleServices moduleServices; + private volatile @Nullable ParsedConfig parsedConfig; + private volatile @Nullable ModuleServices moduleServices; // Flag to prevent scheduling after stop private volatile boolean stopped = false; @@ -111,14 +115,39 @@ public OpcUaProtocolAdapter( this.protocolAdapterMetricsService = input.getProtocolAdapterMetricsHelper(); this.config = input.getConfig(); this.opcUaClientConnection = new AtomicReference<>(); - this.opcUaServiceFaultListener = new OpcUaServiceFaultListener( - protocolAdapterMetricsService, + this.opcUaServiceFaultListener = new OpcUaServiceFaultListener(protocolAdapterMetricsService, input.moduleServices().eventService(), adapterId, this::reconnect, config.getConnectionOptions().autoReconnect()); } + /** + * Calculates backoff delay based on the number of consecutive retry attempts. + * Parses the comma-separated retryIntervalMs string and returns the appropriate delay. + * If attemptCount exceeds the number of configured delays, returns the last configured delay. + * + * @param retryIntervalMs comma-separated string of backoff delays in milliseconds + * @param attemptCount the number of consecutive retry attempts (1-indexed) + * @return the backoff delay in milliseconds + * @throws NumberFormatException when the format is incorrect + */ + public static long calculateBackoffDelayMs(final @NotNull String retryIntervalMs, final int attemptCount) { + final String[] delayStrings = retryIntervalMs.split(","); + final long[] backoffDelays = new long[delayStrings.length]; + + for (int i = 0; i < delayStrings.length; i++) { + // NumberFormatException is thrown. + backoffDelays[i] = Long.parseLong(delayStrings[i].trim()); + } + + // Array is 0-indexed, attemptCount is 1-indexed, so we need attemptCount - 1 + final int index = Math.min(Math.max(0, attemptCount - 1), backoffDelays.length - 1); + final double backoffDelay = + backoffDelays[index] * (1 + new Random().nextDouble(ConnectionOptions.DEFAULT_RETRY_JITTER)); + return Double.valueOf(backoffDelay).longValue(); + } + @Override public @NotNull String getId() { return adapterId; @@ -143,8 +172,7 @@ public synchronized void start( final var result = ParsedConfig.fromConfig(config); if (result instanceof Failure(final String failure)) { log.error("Failed to parse configuration for OPC UA client: {}", failure); - output.failStart(new IllegalStateException(failure), - "Failed to parse configuration for OPC UA client"); + output.failStart(new IllegalStateException(failure), "Failed to parse configuration for OPC UA client"); return; } else if (result instanceof Success(final ParsedConfig successfullyParsedConfig)) { newlyParsedConfig = successfullyParsedConfig; @@ -158,15 +186,16 @@ public synchronized void start( } final OpcUaClientConnection conn; - if (opcUaClientConnection.compareAndSet(null, conn = new OpcUaClientConnection(adapterId, - tagList, - protocolAdapterState, - input.moduleServices().protocolAdapterTagStreamingService(), - dataPointFactory, - input.moduleServices().eventService(), - protocolAdapterMetricsService, - config, - opcUaServiceFaultListener))) { + if (opcUaClientConnection.compareAndSet(null, + conn = new OpcUaClientConnection(adapterId, + tagList, + protocolAdapterState, + input.moduleServices().protocolAdapterTagStreamingService(), + dataPointFactory, + input.moduleServices().eventService(), + protocolAdapterMetricsService, + config, + opcUaServiceFaultListener))) { protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); // Attempt initial connection asynchronously @@ -178,10 +207,8 @@ public synchronized void start( output.startedSuccessfully(); } else { log.error("Cannot start OPC UA protocol adapter '{}' - adapter is already started", adapterId); - output.failStart( - new IllegalStateException("Adapter already started"), - "Cannot start already started adapter. Please stop the adapter first." - ); + output.failStart(new IllegalStateException("Adapter already started"), + "Cannot start already started adapter. Please stop the adapter first."); } } @@ -242,10 +269,20 @@ private void reconnect() { final long currentTime = System.currentTimeMillis(); final long lastReconnectTime = lastReconnectTimestamp.get(); - if (reconnectAttempts.get() > 0 && - currentTime - lastReconnectTime < config.getConnectionOptions().retryIntervalMs()) { - log.debug("Reconnection for adapter '{}' attempted too soon after last reconnect - skipping", adapterId); - return; + if (reconnectAttempts.get() > 0) { + long backoffDelayMs; + try { + backoffDelayMs = calculateBackoffDelayMs(config.getConnectionOptions().retryIntervalMs(), + (int) reconnectAttempts.get()); + } catch (final Exception e) { + backoffDelayMs = calculateBackoffDelayMs(ConnectionOptions.DEFAULT_RETRY_INTERVALS, + (int) reconnectAttempts.get()); + } + if (currentTime - lastReconnectTime < backoffDelayMs) { + log.debug("Reconnection for adapter '{}' attempted too soon after last reconnect - skipping", + adapterId); + return; + } } reconnectAttempts.incrementAndGet(); lastReconnectTimestamp.set(currentTime); @@ -258,6 +295,9 @@ private void reconnect() { return; } + // Reset retry counter for fresh reconnection attempt with exponential backoff + consecutiveRetryAttempts.set(0); + // Cancel any pending retries and health checks cancelRetry(); cancelHealthCheck(); @@ -292,7 +332,8 @@ private void reconnect() { }; attemptConnection(newConn, parsedConfig, input); } else { - log.warn("OPC UA adapter '{}' reconnect failed - another connection was created concurrently", adapterId); + log.warn("OPC UA adapter '{}' reconnect failed - another connection was created concurrently", + adapterId); } } finally { reconnectLock.unlock(); @@ -343,7 +384,8 @@ private void scheduleHealthCheck() { } log.debug("Scheduled connection health check every {} milliseconds for adapter '{}'", - healthCheckIntervalMs, adapterId); + healthCheckIntervalMs, + adapterId); } /** @@ -583,27 +625,29 @@ private void attemptConnection( scheduleHealthCheck(); log.info("OPC UA adapter '{}' connected successfully", adapterId); } else { - // Connection failed - clean up and schedule retry + // Connection failed - clean up and schedule retry with exponential backoff this.opcUaClientConnection.set(null); protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.ERROR); - final long retryIntervalMs = config.getConnectionOptions().retryIntervalMs(); if (throwable != null) { - log.warn("OPC UA adapter '{}' connection failed, will retry in {} milliseconds", - adapterId, retryIntervalMs, throwable); + log.warn("OPC UA adapter '{}' connection failed, scheduling retry with exponential backoff", + adapterId, + throwable); } else { - log.warn("OPC UA adapter '{}' connection returned false, will retry in {} milliseconds", - adapterId, retryIntervalMs); + log.warn("OPC UA adapter '{}' connection returned false, scheduling retry with exponential backoff", + adapterId); } - // Schedule retry attempt + cancelHealthCheck(); + // Schedule retry attempt with exponential backoff scheduleRetry(input); } }); } /** - * Schedules a retry attempt after configured retry interval. + * Schedules a retry attempt using exponential backoff strategy. + * First retry is after 1 second, subsequent retries use exponential backoff (base 2) up to 5 minutes max. */ private void scheduleRetry(final @NotNull ProtocolAdapterStartInput input) { @@ -613,7 +657,24 @@ private void scheduleRetry(final @NotNull ProtocolAdapterStartInput input) { return; } - final long retryIntervalMs = config.getConnectionOptions().retryIntervalMs(); + // Increment retry attempt counter and calculate backoff delay + final int attemptCount = consecutiveRetryAttempts.incrementAndGet(); + long backoffDelayMs; + try { + backoffDelayMs = calculateBackoffDelayMs(config.getConnectionOptions().retryIntervalMs(), attemptCount); + } catch (final Exception e) { + log.warn("Failed to calculate backoff delay for adapter '{}' from retryIntervalMs {}", + adapterId, + config.getConnectionOptions().retryIntervalMs(), + e); + backoffDelayMs = calculateBackoffDelayMs(ConnectionOptions.DEFAULT_RETRY_INTERVALS, attemptCount); + } + + log.info("Scheduling retry attempt #{} for OPC UA adapter '{}' with backoff delay of {} ms", + attemptCount, + adapterId, + backoffDelayMs); + final ScheduledFuture future = retryScheduler.schedule(() -> { // Check if adapter was stopped before retry executes if (stopped || this.parsedConfig == null || this.moduleServices == null) { @@ -621,7 +682,7 @@ private void scheduleRetry(final @NotNull ProtocolAdapterStartInput input) { return; } - log.info("Retrying connection for OPC UA adapter '{}'", adapterId); + log.info("Executing retry attempt #{} for OPC UA adapter '{}'", attemptCount, adapterId); // Create new connection object for retry final OpcUaClientConnection newConn = new OpcUaClientConnection(adapterId, @@ -641,7 +702,7 @@ private void scheduleRetry(final @NotNull ProtocolAdapterStartInput input) { } else { log.debug("OPC UA adapter '{}' retry skipped - connection already exists", adapterId); } - }, retryIntervalMs, TimeUnit.MILLISECONDS); + }, backoffDelayMs, TimeUnit.MILLISECONDS); // Store future so it can be cancelled if needed final ScheduledFuture oldFuture = retryFuture.getAndSet(future); diff --git a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/config/ConnectionOptions.java b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/config/ConnectionOptions.java index daacf9246b..f5c9763ad1 100644 --- a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/config/ConnectionOptions.java +++ b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/config/ConnectionOptions.java @@ -70,12 +70,10 @@ public record ConnectionOptions( Long healthCheckIntervalMs, @JsonProperty("retryIntervalMs") - @ModuleConfigField(title = "Connection Retry Interval (milliseconds)", - description = "Interval between connection retry attempts in milliseconds", - numberMin = 5 * 1000, - numberMax = 300 * 1000, - defaultValue = ""+DEFAULT_RETRY_INTERVAL) - Long retryIntervalMs, + @ModuleConfigField(title = "Connection Retry Intervals (milliseconds)", + description = "Comma-separated list of backoff delays in milliseconds for connection retry attempts. The adapter will use these delays sequentially for each retry attempt, repeating the last value if attempts exceed the list length.", + defaultValue = DEFAULT_RETRY_INTERVALS) + String retryIntervalMs, @JsonProperty("autoReconnect") @ModuleConfigField(title = "Automatic Reconnection", @@ -97,7 +95,10 @@ public record ConnectionOptions( public static final long DEFAULT_REQUEST_TIMEOUT = 30 * 1000; public static final long DEFAULT_CONNECTION_TIMEOUT = 30 * 1000; public static final long DEFAULT_HEALTHCHECK_INTERVAL = 30 * 1000; - public static final long DEFAULT_RETRY_INTERVAL = 30 * 1000; + // Exponential backoff delays: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 300s (capped at 5 minutes) + public static final String DEFAULT_RETRY_INTERVALS = "1000,2000,4000,8000,16000,32000,64000,128000,256000,300000"; + // It adds a little jitter to avoid traffic jam. + public static final double DEFAULT_RETRY_JITTER = 0.1; public ConnectionOptions { // Timeout configurations with sensible defaults @@ -107,7 +108,7 @@ public record ConnectionOptions( keepAliveFailuresAllowed = requireNonNullElse(keepAliveFailuresAllowed, DEFAULT_KEEP_ALIVE_FAILURES_ALLOWED); connectionTimeoutMs = requireNonNullElse(connectionTimeoutMs, DEFAULT_CONNECTION_TIMEOUT); healthCheckIntervalMs = requireNonNullElse(healthCheckIntervalMs, DEFAULT_HEALTHCHECK_INTERVAL); - retryIntervalMs = requireNonNullElse(retryIntervalMs, DEFAULT_RETRY_INTERVAL); + retryIntervalMs = requireNonNullElse(retryIntervalMs, DEFAULT_RETRY_INTERVALS); autoReconnect = requireNonNullElse(autoReconnect, DEFAULT_AUTO_RECONNECT); reconnectOnServiceFault = requireNonNullElse(reconnectOnServiceFault, DEFAULT_RECONNECT_ON_SERVICE_FAULT); } @@ -115,6 +116,6 @@ public record ConnectionOptions( public static ConnectionOptions defaultConnectionOptions() { return new ConnectionOptions(DEFAULT_SESSION_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, DEFAULT_KEEP_ALIVE_INTERVAL, DEFAULT_KEEP_ALIVE_FAILURES_ALLOWED, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_HEALTHCHECK_INTERVAL, - DEFAULT_RETRY_INTERVAL, DEFAULT_AUTO_RECONNECT, DEFAULT_RECONNECT_ON_SERVICE_FAULT); + DEFAULT_RETRY_INTERVALS, DEFAULT_AUTO_RECONNECT, DEFAULT_RECONNECT_ON_SERVICE_FAULT); } } diff --git a/modules/hivemq-edge-module-opcua/src/test/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapterTest.java b/modules/hivemq-edge-module-opcua/src/test/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapterTest.java index b0b5e55406..08f4edd3f4 100644 --- a/modules/hivemq-edge-module-opcua/src/test/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapterTest.java +++ b/modules/hivemq-edge-module-opcua/src/test/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapterTest.java @@ -42,6 +42,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import util.EmbeddedOpcUaServerExtension; import java.time.Duration; @@ -49,6 +52,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -66,6 +70,10 @@ public class OpcUaProtocolAdapterTest { private @NotNull ProtocolAdapterState protocolAdapterState; private @NotNull FakeEventService eventService; + static long getUpperBound(final long value) { + return (long) (value * (1 + ConnectionOptions.DEFAULT_RETRY_JITTER)); + } + @BeforeEach void setUp() { protocolAdapterState = new ProtocolAdapterStateImpl(mock(), "test-adapter-id", "opcua"); @@ -220,7 +228,7 @@ void whenMultipleRetriesOccur_thenReconnectWorks() throws Exception { // health check interval 10000L, // retry interval - 2000L, + "2000", true, true)); @@ -318,4 +326,148 @@ void whenMultipleRetriesOccur_thenReconnectWorks() throws Exception { return input; } + + /** + * Tests the exponential backoff delay calculation using the comma-separated retry intervals. + * Verifies the backoff sequence: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 300s (capped). + */ + @ParameterizedTest + @CsvSource({ + "1, 1000", // First retry: 1 second + "2, 2000", // Second retry: 2 seconds + "3, 4000", // Third retry: 4 seconds + "4, 8000", // Fourth retry: 8 seconds + "5, 16000", // Fifth retry: 16 seconds + "6, 32000", // Sixth retry: 32 seconds + "7, 64000", // Seventh retry: 64 seconds + "8, 128000", // Eighth retry: 128 seconds + "9, 256000", // Ninth retry: 256 seconds + "10, 300000", // Tenth retry: 300 seconds (max) + "11, 300000", // Eleventh retry: still 300 seconds (capped) + "20, 300000", // Large attempt count: still 300 seconds (capped) + "100, 300000", // Very large attempt count: still 300 seconds (capped) + }) + void testCalculateBackoffDelayMs_exponentialGrowthAndCapping(final int attemptCount, final long expectedDelayMs) { + final long actualDelay = + OpcUaProtocolAdapter.calculateBackoffDelayMs(ConnectionOptions.DEFAULT_RETRY_INTERVALS, attemptCount); + assertThat(actualDelay).as("Backoff delay for attempt #%d should be %d ms", attemptCount, expectedDelayMs) + .isGreaterThanOrEqualTo(expectedDelayMs) + .isLessThan(getUpperBound(expectedDelayMs)); + } + + /** + * Tests that the backoff strategy correctly handles the maximum delay cap. + * Any attempt count >= 10 should return the maximum delay of 300 seconds. + */ + @Test + void testCalculateBackoffDelayMs_capsAtMaximumDelay() { + for (int attemptCount = 10; attemptCount <= 1000; attemptCount += 10) { + final long actualDelay = + OpcUaProtocolAdapter.calculateBackoffDelayMs(ConnectionOptions.DEFAULT_RETRY_INTERVALS, + attemptCount); + assertThat(actualDelay).as("Backoff delay for attempt #%d should be capped at 300 seconds", attemptCount) + .isGreaterThanOrEqualTo(300_000L) + .isLessThan(getUpperBound(300_000L)); + } + } + + /** + * Tests that the exponential backoff follows base-2 growth pattern. + * Each delay should be double the previous one (until capped). + */ + @Test + void testCalculateBackoffDelayMs_followsExponentialPattern() { + long previousDelay = 0; + for (int attemptCount = 1; attemptCount <= 9; attemptCount++) { + final long currentDelay = + OpcUaProtocolAdapter.calculateBackoffDelayMs(ConnectionOptions.DEFAULT_RETRY_INTERVALS, + attemptCount); + if (attemptCount > 1) { + assertThat(currentDelay).as("Delay for attempt #%d should be double the previous delay", attemptCount) + .isGreaterThan(previousDelay); + } + previousDelay = currentDelay; + } + + // Verify that the 10th attempt doesn't follow the exponential pattern (it's capped) + final long tenthAttemptDelay = + OpcUaProtocolAdapter.calculateBackoffDelayMs(ConnectionOptions.DEFAULT_RETRY_INTERVALS, 10); + assertThat(tenthAttemptDelay).as("10th attempt should be capped, not double the 9th") + .isLessThan(previousDelay * 2) + .isGreaterThanOrEqualTo(300_000L) + .isLessThan(getUpperBound(300_000L)); + } + + /** + * Tests that malformed retry intervals throw NumberFormatException. + * Various invalid formats should be rejected with appropriate exceptions. + */ + @ParameterizedTest + @ValueSource(strings = { + "abc,def,ghi", // Non-numeric values + "1000,abc,3000", // Mix of valid and invalid + "1000,2000, ", // Trailing comma with empty value + ",1000,2000", // Leading comma with empty value + "1000,,2000", // Double comma with empty value + "1000.5,2000.5", // Floating point values + "not-a-number", // Single invalid value + "" // Empty + }) + void testCalculateBackoffDelayMs_malformedIntervals(final @NotNull String malformedIntervals) { + assertThatThrownBy(() -> OpcUaProtocolAdapter.calculateBackoffDelayMs(malformedIntervals, 1)).isInstanceOf( + NumberFormatException.class).hasMessageContaining("For input string:"); + } + + /** + * Tests that valid custom retry intervals work correctly. + * Verifies that custom configurations are parsed and applied properly. + */ + @Test + void testCalculateBackoffDelayMs_customValidIntervals() { + final String customIntervals = "5000,10000,15000"; + + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(customIntervals, 1)).isGreaterThanOrEqualTo(5_000L) + .isLessThan(getUpperBound(5_000L)); + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(customIntervals, 2)).isGreaterThanOrEqualTo(10_000L) + .isLessThan(getUpperBound(10_000L)); + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(customIntervals, 3)).isGreaterThanOrEqualTo(15_000L) + .isLessThan(getUpperBound(15_000L)); + // Should repeat last value when exceeding array length + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(customIntervals, 4)).isGreaterThanOrEqualTo(15_000L) + .isLessThan(getUpperBound(15_000L)); + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(customIntervals, 10)).isGreaterThanOrEqualTo(15_000L) + .isLessThan(getUpperBound(15_000L)); + } + + /** + * Tests that single interval value works correctly. + * A configuration with only one value should use that value for all attempts. + */ + @Test + void testCalculateBackoffDelayMs_singleInterval() { + final String singleInterval = "30000"; + + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(singleInterval, 1)).isGreaterThanOrEqualTo(30_000L) + .isLessThan(getUpperBound(30_000L)); + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(singleInterval, 2)).isGreaterThanOrEqualTo(30_000L) + .isLessThan(getUpperBound(30_000L)); + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(singleInterval, 10)).isGreaterThanOrEqualTo(30_000L) + .isLessThan(getUpperBound(30_000L)); + } + + /** + * Tests that intervals with whitespace are handled correctly. + * Leading and trailing whitespace should be trimmed from each value. + */ + @Test + void testCalculateBackoffDelayMs_intervalsWithWhitespace() { + final String intervalsWithWhitespace = " 1000 , 2000 , 4000 "; + + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(intervalsWithWhitespace, 1)).isGreaterThanOrEqualTo( + 1_000L).isLessThan(getUpperBound(1_000L)); + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(intervalsWithWhitespace, 2)).isGreaterThanOrEqualTo( + 2_000L).isLessThan(getUpperBound(2_000L)); + assertThat(OpcUaProtocolAdapter.calculateBackoffDelayMs(intervalsWithWhitespace, 3)).isGreaterThanOrEqualTo( + 4_000L).isLessThan(getUpperBound(4_000L)); + } }