Skip to content

Commit

Permalink
[plugin-saucelabs] Add ability to wait for free device (#5722)
Browse files Browse the repository at this point in the history
  • Loading branch information
valfirst authored Feb 14, 2025
1 parent 8d394c8 commit 537e377
Show file tree
Hide file tree
Showing 10 changed files with 326 additions and 29 deletions.
8 changes: 8 additions & 0 deletions docs/modules/plugins/pages/plugin-sauce-labs.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ a|
|
|https://wiki.saucelabs.com/display/DOCS/Data+Center+Endpoints[Sauce Labs data center] to use

|`saucelabs.free-device-wait-timeout`
|{durations-format-link} format
|`PT0S`
|When the https://docs.saucelabs.com/dev/error-messages/#youve-exceeded-your-sauce-labs-concurrency-limit[Sauce Labs concurrency limit is exceeded],
the error message will be "Could not start a new session. Response code 400. Message: CCYAbuse - too many jobs".
This means all devices are busy. This property sets the maximum wait time for a free device before throwing an exception.
If set to zero, waiting is disabled.

|`saucelabs.sauce-connect.enabled`
a|`true` +
`false`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019-2023 the original author or authors.
* Copyright 2019-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -18,34 +18,32 @@

import java.net.URL;
import java.time.Duration;
import java.util.List;
import java.util.Optional;

import org.openqa.selenium.Capabilities;
import org.openqa.selenium.SessionNotCreatedException;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.remote.http.ClientConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.vividus.selenium.manager.GenericWebDriverManager;

import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.ios.IOSDriver;

public class RemoteWebDriverFactory implements IRemoteWebDriverFactory
{
private static final Logger LOGGER = LoggerFactory.getLogger(RemoteWebDriverFactory.class);
private static final String DEFAULT_HTTP_VERSION = "HTTP_1_1";

private final boolean retrySessionCreationOnHttpConnectTimeout;
private final Duration readTimeout;

private final RemoteWebDriverUrlProvider remoteWebDriverUrlProvider;
private final List<SessionCreationRetryHandler> sessionCreationRetryHandlers;

public RemoteWebDriverFactory(boolean retrySessionCreationOnHttpConnectTimeout, Duration readTimeout,
RemoteWebDriverUrlProvider remoteWebDriverUrlProvider)
public RemoteWebDriverFactory(Duration readTimeout, RemoteWebDriverUrlProvider remoteWebDriverUrlProvider,
List<SessionCreationRetryHandler> sessionCreationRetryHandlers)
{
this.retrySessionCreationOnHttpConnectTimeout = retrySessionCreationOnHttpConnectTimeout;
this.readTimeout = readTimeout;
this.remoteWebDriverUrlProvider = remoteWebDriverUrlProvider;
this.sessionCreationRetryHandlers = sessionCreationRetryHandlers;
}

@Override
Expand All @@ -61,21 +59,15 @@ public RemoteWebDriver getRemoteWebDriver(Capabilities capabilities)
}
catch (SessionNotCreatedException e)
{
if (retrySessionCreationOnHttpConnectTimeout && isHttpConnectTimeout(e))
{
LOGGER.warn("Failed to create a new session due to HTTP connect timout", e);
LOGGER.warn("Retrying to create a new session");
return createRemoteWebDriver(capabilities, clientConfig);
}
throw e;
return sessionCreationRetryHandlers.stream()
.map(handler -> handler.handleError(e, () -> createRemoteWebDriver(capabilities, clientConfig)))
.filter(Optional::isPresent)
.map(Optional::get)
.findFirst()
.orElseThrow(() -> e);
}
}

private static boolean isHttpConnectTimeout(SessionNotCreatedException e)
{
return e.getMessage().contains("Message: java.net.http.HttpConnectTimeoutException: HTTP connect timed out");
}

private RemoteWebDriver createRemoteWebDriver(Capabilities capabilities, ClientConfig clientConfig)
{
if (GenericWebDriverManager.isIOS(capabilities) || GenericWebDriverManager.isTvOS(capabilities))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2019-2025 the original author or authors.
*
* 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
*
* https://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 org.vividus.selenium;

import java.util.Optional;
import java.util.function.Supplier;

import org.openqa.selenium.SessionNotCreatedException;
import org.openqa.selenium.remote.RemoteWebDriver;

public interface SessionCreationRetryHandler
{
/**
* Checks whether the error from {@link SessionNotCreatedException} is recoverable. If it is, tries to create a
* new {@link RemoteWebDriver} using the provided factory.
*
* @param sessionNotCreatedException The original exception thrown when a session is not created at the first
* attempt.
* @param remoteWebDriverFactory The factory to create a new {@link RemoteWebDriver}.
* @return An {@link Optional} containing the new {@link RemoteWebDriver} if the error is recoverable, otherwise
* an empty {@link Optional}.
*/
Optional<RemoteWebDriver> handleError(SessionNotCreatedException sessionNotCreatedException,
Supplier<RemoteWebDriver> remoteWebDriverFactory);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright 2019-2025 the original author or authors.
*
* 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
*
* https://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 org.vividus.selenium;

import java.util.Optional;
import java.util.function.Supplier;

import org.openqa.selenium.SessionNotCreatedException;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SessionCreationRetryOnHttpConnectTimeoutHandler implements SessionCreationRetryHandler
{
private static final Logger LOGGER = LoggerFactory.getLogger(SessionCreationRetryOnHttpConnectTimeoutHandler.class);

private final boolean retrySessionCreationOnHttpConnectTimeout;

public SessionCreationRetryOnHttpConnectTimeoutHandler(boolean retrySessionCreationOnHttpConnectTimeout)
{
this.retrySessionCreationOnHttpConnectTimeout = retrySessionCreationOnHttpConnectTimeout;
}

@Override
public Optional<RemoteWebDriver> handleError(SessionNotCreatedException sessionNotCreatedException,
Supplier<RemoteWebDriver> remoteWebDriverFactory)
{
if (retrySessionCreationOnHttpConnectTimeout && isHttpConnectTimeout(sessionNotCreatedException))
{
LOGGER.warn("Failed to create a new session due to HTTP connect timout", sessionNotCreatedException);
LOGGER.warn("Retrying to create a new session");
return Optional.ofNullable(remoteWebDriverFactory.get());
}
return Optional.empty();
}

private static boolean isHttpConnectTimeout(SessionNotCreatedException e)
{
return e.getMessage().contains("Message: java.net.http.HttpConnectTimeoutException: HTTP connect timed out");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
</bean>

<bean id="remoteWebDriverFactory" class="org.vividus.selenium.RemoteWebDriverFactory">
<constructor-arg index="0" value="${selenium.grid.http.read-timeout}" />
</bean>

<bean class="org.vividus.selenium.SessionCreationRetryOnHttpConnectTimeoutHandler">
<constructor-arg index="0" value="${selenium.grid.retry-session-creation-on-http-connect-timeout}" />
<constructor-arg index="1" value="${selenium.grid.http.read-timeout}" />
</bean>

<bean id="webDriverProvider" class="org.vividus.selenium.WebDriverProvider"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019-2024 the original author or authors.
* Copyright 2019-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -84,7 +84,8 @@ class RemoteWebDriverFactoryTests

@Mock private RemoteWebDriverUrlProvider provider;

private final TestLogger logger = TestLoggerFactory.getTestLogger(RemoteWebDriverFactory.class);
private final TestLogger logger = TestLoggerFactory.getTestLogger(
SessionCreationRetryOnHttpConnectTimeoutHandler.class);

@BeforeEach
void init() throws URISyntaxException
Expand Down Expand Up @@ -130,7 +131,7 @@ void shouldCreateMobileDriver(Object platform, Class<?> webDriveClass)
);
}))
{
var actualDriver = new RemoteWebDriverFactory(false, READ_TIMEOUT, provider).getRemoteWebDriver(
var actualDriver = new RemoteWebDriverFactory(READ_TIMEOUT, provider, List.of()).getRemoteWebDriver(
capabilities);
assertEquals(driver.constructed(), List.of(actualDriver));
assertThat(logger.getLoggingEvents(), is(empty()));
Expand All @@ -148,7 +149,7 @@ void shouldCreateRemoteDriver()
RemoteWebDriver remoteWebDriver = mock();
when(remoteWebDriverBuilder.build()).thenReturn(remoteWebDriver);
driverMock.when(RemoteWebDriver::builder).thenReturn(remoteWebDriverBuilder);
var actualDriver = new RemoteWebDriverFactory(false, READ_TIMEOUT, provider).getRemoteWebDriver(
var actualDriver = new RemoteWebDriverFactory(READ_TIMEOUT, provider, List.of()).getRemoteWebDriver(
capabilities);
assertEquals(remoteWebDriver, actualDriver);
assertThat(logger.getLoggingEvents(), is(empty()));
Expand Down Expand Up @@ -190,7 +191,9 @@ void shouldRetrySessionCreationOnHttpConnectTimeoutSuccessfully()
}
}))
{
var actualDriver = new RemoteWebDriverFactory(true, READ_TIMEOUT, provider).getRemoteWebDriver(
List<SessionCreationRetryHandler> retryHandlers = List.of(
new SessionCreationRetryOnHttpConnectTimeoutHandler(true));
var actualDriver = new RemoteWebDriverFactory(READ_TIMEOUT, provider, retryHandlers).getRemoteWebDriver(
desiredCapabilities);
assertEquals(sessionId, actualDriver.getSessionId().toString());
assertThat(logger.getLoggingEvents(), is(List.of(
Expand Down Expand Up @@ -221,8 +224,9 @@ void shouldRetrySessionCreationOnHttpConnectTimeoutUnsuccessfully(SessionNotCrea
Either.left(exception))
))
{
var remoteWebDriverFactory = new RemoteWebDriverFactory(retrySessionCreationOnHttpConnectTimeout,
READ_TIMEOUT, provider);
List<SessionCreationRetryHandler> retryHandlers = List.of(
new SessionCreationRetryOnHttpConnectTimeoutHandler(retrySessionCreationOnHttpConnectTimeout));
var remoteWebDriverFactory = new RemoteWebDriverFactory(READ_TIMEOUT, provider, retryHandlers);
var actualException = assertThrows(SessionNotCreatedException.class,
() -> remoteWebDriverFactory.getRemoteWebDriver(desiredCapabilities));
assertEquals(exception, actualException);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright 2019-2025 the original author or authors.
*
* 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
*
* https://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 org.vividus.saucelabs;

import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier;

import org.openqa.selenium.SessionNotCreatedException;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.vividus.selenium.SessionCreationRetryHandler;
import org.vividus.util.wait.DurationBasedWaiter;

public class SessionCreationRetryOnConcurrencyLimitHandler implements SessionCreationRetryHandler
{
private final Duration freeDeviceWaitTimeout;
private final Duration pollingTimeout;

public SessionCreationRetryOnConcurrencyLimitHandler(Duration freeDeviceWaitTimeout, Duration pollingTimeout)
{
this.freeDeviceWaitTimeout = freeDeviceWaitTimeout;
this.pollingTimeout = pollingTimeout;
}

@Override
public Optional<RemoteWebDriver> handleError(SessionNotCreatedException sessionNotCreatedException,
Supplier<RemoteWebDriver> remoteWebDriverFactory)
{
if (!freeDeviceWaitTimeout.isZero() && isConcurrencyLimitExceeded(sessionNotCreatedException))
{
RemoteWebDriver remoteWebDriver = new DurationBasedWaiter(freeDeviceWaitTimeout, pollingTimeout).wait(
() ->
{
try
{
return remoteWebDriverFactory.get();
}
catch (SessionNotCreatedException e)
{
if (isConcurrencyLimitExceeded(e))
{
return null;
}
throw e;
}
}, Objects::nonNull);
return Optional.ofNullable(remoteWebDriver);
}
return Optional.empty();
}

private static boolean isConcurrencyLimitExceeded(SessionNotCreatedException sessionNotCreatedException)
{
return sessionNotCreatedException.getMessage().contains("CCYAbuse - too many jobs");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ saucelabs.sauce-connect.enabled=false
saucelabs.sauce-connect.use-latest-version=true
saucelabs.sauce-connect.command-line-arguments=
saucelabs.sauce-connect.skip-host-glob-patterns=
saucelabs.free-device-wait-timeout=PT0S

selenium.grid.capabilities.sauce\:options.idleTimeout=${selenium.grid.idle-timeout}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
</constructor-arg>
</bean>

<bean class="org.vividus.saucelabs.SessionCreationRetryOnConcurrencyLimitHandler">
<constructor-arg index="0" value="${saucelabs.free-device-wait-timeout}" />
<constructor-arg index="1" value="PT30S" />
</bean>

<bean class="org.vividus.selenium.sauce.SauceLabsCapabilitiesConfigurer">
<constructor-arg index="0" value="${saucelabs.sauce-connect.use-latest-version}" />
<property name="tunnellingEnabled" value="${saucelabs.sauce-connect.enabled}"/>
Expand Down
Loading

0 comments on commit 537e377

Please sign in to comment.