diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 27fda9e..5eb58fb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,14 +65,18 @@ jobs:
run: ./scripts/build
- name: Get GitHub OIDC Token
- if: github.repository == 'stainless-sdks/stagehand-java'
+ if: |-
+ github.repository == 'stainless-sdks/stagehand-java' &&
+ !startsWith(github.ref, 'refs/heads/stl/')
id: github-oidc
uses: actions/github-script@v8
with:
script: core.setOutput('github_token', await core.getIDToken());
- name: Build and upload Maven artifacts
- if: github.repository == 'stainless-sdks/stagehand-java'
+ if: |-
+ github.repository == 'stainless-sdks/stagehand-java' &&
+ !startsWith(github.ref, 'refs/heads/stl/')
env:
URL: https://pkg.stainless.com/s
AUTH: ${{ steps.github-oidc.outputs.github_token }}
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 1bc5713..6538ca9 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.7.1"
+ ".": "0.8.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index c61740e..cefb031 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 8
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-a4e672f457dd99336f4b2a113fd7c7c6c9db0941b38d57cff6e3641549a6c4ed.yml
-openapi_spec_hash: eae9c8561e420db8e4d238c1e59617fb
-config_hash: 2a565ad6662259a2e90fa5f1f5095525
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-573d364768ac1902ee5ed8b2485d3b293bda0ea8ff7898aef1a3fd6be79b594a.yml
+openapi_spec_hash: 107ec840f4330885dd2232a05a66fed7
+config_hash: 0209737a4ab2a71afececb0ff7459998
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 168eb3d..30d9be2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog
+## 0.8.0 (2026-03-11)
+
+Full Changelog: [v0.7.1...v0.8.0](https://github.com/browserbase/stagehand-java/compare/v0.7.1...v0.8.0)
+
+### Features
+
+* Add missing cdpHeaders field to v3 server openapi spec ([5a9506d](https://github.com/browserbase/stagehand-java/commit/5a9506d5dfeb1ae172834941aad3bbd9e96f2973))
+
+
+### Bug Fixes
+
+* **client:** incorrect `Retry-After` parsing ([76ea3fc](https://github.com/browserbase/stagehand-java/commit/76ea3fc583bfe9ca7295856db85378b44d765b0f))
+
+
+### Chores
+
+* **ci:** skip uploading artifacts on stainless-internal branches ([4a1399c](https://github.com/browserbase/stagehand-java/commit/4a1399c7a535d9bf8934e9d142878e57a1537b8d))
+* **internal:** bump palantir-java-format ([3bf036a](https://github.com/browserbase/stagehand-java/commit/3bf036aed5ba8fb90fe4bf5bc6b62a49f09f7dcc))
+* **internal:** codegen related update ([f94c8c9](https://github.com/browserbase/stagehand-java/commit/f94c8c9a86af4592fba5c42645ccc1099a8ab1fe))
+
## 0.7.1 (2026-03-04)
Full Changelog: [v0.7.0...v0.7.1](https://github.com/browserbase/stagehand-java/compare/v0.7.0...v0.7.1)
diff --git a/README.md b/README.md
index 726fa64..a60f49c 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-[](https://central.sonatype.com/artifact/com.browserbase.api/stagehand-java/0.7.1)
-[](https://javadoc.io/doc/com.browserbase.api/stagehand-java/0.7.1)
+[](https://central.sonatype.com/artifact/com.browserbase.api/stagehand-java/0.8.0)
+[](https://javadoc.io/doc/com.browserbase.api/stagehand-java/0.8.0)
@@ -85,7 +85,7 @@ Most existing browser automation tools either require you to write low-level cod
### Gradle
```kotlin
-implementation("com.browserbase.api:stagehand-java:0.7.1")
+implementation("com.browserbase.api:stagehand-java:0.8.0")
```
### Maven
@@ -94,7 +94,7 @@ implementation("com.browserbase.api:stagehand-java:0.7.1")
com.browserbase.api
stagehand-java
- 0.7.1
+ 0.8.0
```
diff --git a/build.gradle.kts b/build.gradle.kts
index 79fb854..129fa8a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ repositories {
allprojects {
group = "com.browserbase.api"
- version = "0.7.1" // x-release-please-version
+ version = "0.8.0" // x-release-please-version
}
subprojects {
diff --git a/buildSrc/src/main/kotlin/stagehand.java.gradle.kts b/buildSrc/src/main/kotlin/stagehand.java.gradle.kts
index 70fc33f..8f4f902 100644
--- a/buildSrc/src/main/kotlin/stagehand.java.gradle.kts
+++ b/buildSrc/src/main/kotlin/stagehand.java.gradle.kts
@@ -45,7 +45,7 @@ tasks.withType().configureEach {
val palantir by configurations.creating
dependencies {
- palantir("com.palantir.javaformat:palantir-java-format:2.73.0")
+ palantir("com.palantir.javaformat:palantir-java-format:2.89.0")
}
fun registerPalantir(
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/http/RetryingHttpClient.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/http/RetryingHttpClient.kt
index ab5bd0b..381b36f 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/http/RetryingHttpClient.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/core/http/RetryingHttpClient.kt
@@ -201,7 +201,7 @@ private constructor(
?: headers.values("Retry-After").getOrNull(0)?.let { retryAfter ->
retryAfter.toFloatOrNull()?.times(TimeUnit.SECONDS.toNanos(1))
?: try {
- ChronoUnit.MILLIS.between(
+ ChronoUnit.NANOS.between(
OffsetDateTime.now(clock),
OffsetDateTime.parse(
retryAfter,
@@ -214,13 +214,8 @@ private constructor(
}
}
?.let { retryAfterNanos ->
- // If the API asks us to wait a certain amount of time (and it's a reasonable
- // amount), just
- // do what it says.
- val retryAfter = Duration.ofNanos(retryAfterNanos.toLong())
- if (retryAfter in Duration.ofNanos(0)..Duration.ofMinutes(1)) {
- return retryAfter
- }
+ // If the API asks us to wait a certain amount of time, do what it says.
+ return Duration.ofNanos(retryAfterNanos.toLong())
}
// Apply exponential backoff, but not more than the max.
diff --git a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionStartParams.kt b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionStartParams.kt
index f177a4e..5e09071 100644
--- a/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionStartParams.kt
+++ b/stagehand-java-core/src/main/kotlin/com/browserbase/api/models/sessions/SessionStartParams.kt
@@ -1372,6 +1372,7 @@ private constructor(
private constructor(
private val acceptDownloads: JsonField,
private val args: JsonField>,
+ private val cdpHeaders: JsonField,
private val cdpUrl: JsonField,
private val chromiumSandbox: JsonField,
private val connectTimeoutMs: JsonField,
@@ -1400,6 +1401,9 @@ private constructor(
@JsonProperty("args")
@ExcludeMissing
args: JsonField> = JsonMissing.of(),
+ @JsonProperty("cdpHeaders")
+ @ExcludeMissing
+ cdpHeaders: JsonField = JsonMissing.of(),
@JsonProperty("cdpUrl")
@ExcludeMissing
cdpUrl: JsonField = JsonMissing.of(),
@@ -1450,6 +1454,7 @@ private constructor(
) : this(
acceptDownloads,
args,
+ cdpHeaders,
cdpUrl,
chromiumSandbox,
connectTimeoutMs,
@@ -1483,6 +1488,12 @@ private constructor(
*/
fun args(): Optional> = args.getOptional("args")
+ /**
+ * @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g.
+ * if the server responded with an unexpected value).
+ */
+ fun cdpHeaders(): Optional = cdpHeaders.getOptional("cdpHeaders")
+
/**
* @throws StagehandInvalidDataException if the JSON field has an unexpected type (e.g.
* if the server responded with an unexpected value).
@@ -1608,6 +1619,16 @@ private constructor(
*/
@JsonProperty("args") @ExcludeMissing fun _args(): JsonField> = args
+ /**
+ * Returns the raw JSON value of [cdpHeaders].
+ *
+ * Unlike [cdpHeaders], this method doesn't throw if the JSON field has an unexpected
+ * type.
+ */
+ @JsonProperty("cdpHeaders")
+ @ExcludeMissing
+ fun _cdpHeaders(): JsonField = cdpHeaders
+
/**
* Returns the raw JSON value of [cdpUrl].
*
@@ -1783,6 +1804,7 @@ private constructor(
private var acceptDownloads: JsonField = JsonMissing.of()
private var args: JsonField>? = null
+ private var cdpHeaders: JsonField = JsonMissing.of()
private var cdpUrl: JsonField = JsonMissing.of()
private var chromiumSandbox: JsonField = JsonMissing.of()
private var connectTimeoutMs: JsonField = JsonMissing.of()
@@ -1806,6 +1828,7 @@ private constructor(
internal fun from(launchOptions: LaunchOptions) = apply {
acceptDownloads = launchOptions.acceptDownloads
args = launchOptions.args.map { it.toMutableList() }
+ cdpHeaders = launchOptions.cdpHeaders
cdpUrl = launchOptions.cdpUrl
chromiumSandbox = launchOptions.chromiumSandbox
connectTimeoutMs = launchOptions.connectTimeoutMs
@@ -1865,6 +1888,19 @@ private constructor(
}
}
+ fun cdpHeaders(cdpHeaders: CdpHeaders) = cdpHeaders(JsonField.of(cdpHeaders))
+
+ /**
+ * Sets [Builder.cdpHeaders] to an arbitrary JSON value.
+ *
+ * You should usually call [Builder.cdpHeaders] with a well-typed [CdpHeaders] value
+ * instead. This method is primarily for setting the field to an undocumented or not
+ * yet supported value.
+ */
+ fun cdpHeaders(cdpHeaders: JsonField) = apply {
+ this.cdpHeaders = cdpHeaders
+ }
+
fun cdpUrl(cdpUrl: String) = cdpUrl(JsonField.of(cdpUrl))
/**
@@ -2120,6 +2156,7 @@ private constructor(
LaunchOptions(
acceptDownloads,
(args ?: JsonMissing.of()).map { it.toImmutable() },
+ cdpHeaders,
cdpUrl,
chromiumSandbox,
connectTimeoutMs,
@@ -2150,6 +2187,7 @@ private constructor(
acceptDownloads()
args()
+ cdpHeaders().ifPresent { it.validate() }
cdpUrl()
chromiumSandbox()
connectTimeoutMs()
@@ -2188,6 +2226,7 @@ private constructor(
internal fun validity(): Int =
(if (acceptDownloads.asKnown().isPresent) 1 else 0) +
(args.asKnown().getOrNull()?.size ?: 0) +
+ (cdpHeaders.asKnown().getOrNull()?.validity() ?: 0) +
(if (cdpUrl.asKnown().isPresent) 1 else 0) +
(if (chromiumSandbox.asKnown().isPresent) 1 else 0) +
(if (connectTimeoutMs.asKnown().isPresent) 1 else 0) +
@@ -2206,6 +2245,110 @@ private constructor(
(if (userDataDir.asKnown().isPresent) 1 else 0) +
(viewport.asKnown().getOrNull()?.validity() ?: 0)
+ class CdpHeaders
+ @JsonCreator
+ private constructor(
+ @com.fasterxml.jackson.annotation.JsonValue
+ private val additionalProperties: Map
+ ) {
+
+ @JsonAnyGetter
+ @ExcludeMissing
+ fun _additionalProperties(): Map = additionalProperties
+
+ fun toBuilder() = Builder().from(this)
+
+ companion object {
+
+ /** Returns a mutable builder for constructing an instance of [CdpHeaders]. */
+ @JvmStatic fun builder() = Builder()
+ }
+
+ /** A builder for [CdpHeaders]. */
+ class Builder internal constructor() {
+
+ private var additionalProperties: MutableMap = mutableMapOf()
+
+ @JvmSynthetic
+ internal fun from(cdpHeaders: CdpHeaders) = apply {
+ additionalProperties = cdpHeaders.additionalProperties.toMutableMap()
+ }
+
+ fun additionalProperties(additionalProperties: Map) = apply {
+ this.additionalProperties.clear()
+ putAllAdditionalProperties(additionalProperties)
+ }
+
+ fun putAdditionalProperty(key: String, value: JsonValue) = apply {
+ additionalProperties.put(key, value)
+ }
+
+ fun putAllAdditionalProperties(additionalProperties: Map) =
+ apply {
+ this.additionalProperties.putAll(additionalProperties)
+ }
+
+ fun removeAdditionalProperty(key: String) = apply {
+ additionalProperties.remove(key)
+ }
+
+ fun removeAllAdditionalProperties(keys: Set) = apply {
+ keys.forEach(::removeAdditionalProperty)
+ }
+
+ /**
+ * Returns an immutable instance of [CdpHeaders].
+ *
+ * Further updates to this [Builder] will not mutate the returned instance.
+ */
+ fun build(): CdpHeaders = CdpHeaders(additionalProperties.toImmutable())
+ }
+
+ private var validated: Boolean = false
+
+ fun validate(): CdpHeaders = apply {
+ if (validated) {
+ return@apply
+ }
+
+ validated = true
+ }
+
+ fun isValid(): Boolean =
+ try {
+ validate()
+ true
+ } catch (e: StagehandInvalidDataException) {
+ false
+ }
+
+ /**
+ * Returns a score indicating how many valid values are contained in this object
+ * recursively.
+ *
+ * Used for best match union deserialization.
+ */
+ @JvmSynthetic
+ internal fun validity(): Int =
+ additionalProperties.count { (_, value) ->
+ !value.isNull() && !value.isMissing()
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) {
+ return true
+ }
+
+ return other is CdpHeaders && additionalProperties == other.additionalProperties
+ }
+
+ private val hashCode: Int by lazy { Objects.hash(additionalProperties) }
+
+ override fun hashCode(): Int = hashCode
+
+ override fun toString() = "CdpHeaders{additionalProperties=$additionalProperties}"
+ }
+
@JsonDeserialize(using = IgnoreDefaultArgs.Deserializer::class)
@JsonSerialize(using = IgnoreDefaultArgs.Serializer::class)
class IgnoreDefaultArgs
@@ -2872,6 +3015,7 @@ private constructor(
return other is LaunchOptions &&
acceptDownloads == other.acceptDownloads &&
args == other.args &&
+ cdpHeaders == other.cdpHeaders &&
cdpUrl == other.cdpUrl &&
chromiumSandbox == other.chromiumSandbox &&
connectTimeoutMs == other.connectTimeoutMs &&
@@ -2896,6 +3040,7 @@ private constructor(
Objects.hash(
acceptDownloads,
args,
+ cdpHeaders,
cdpUrl,
chromiumSandbox,
connectTimeoutMs,
@@ -2920,7 +3065,7 @@ private constructor(
override fun hashCode(): Int = hashCode
override fun toString() =
- "LaunchOptions{acceptDownloads=$acceptDownloads, args=$args, cdpUrl=$cdpUrl, chromiumSandbox=$chromiumSandbox, connectTimeoutMs=$connectTimeoutMs, deviceScaleFactor=$deviceScaleFactor, devtools=$devtools, downloadsPath=$downloadsPath, executablePath=$executablePath, hasTouch=$hasTouch, headless=$headless, ignoreDefaultArgs=$ignoreDefaultArgs, ignoreHttpsErrors=$ignoreHttpsErrors, locale=$locale, port=$port, preserveUserDataDir=$preserveUserDataDir, proxy=$proxy, userDataDir=$userDataDir, viewport=$viewport, additionalProperties=$additionalProperties}"
+ "LaunchOptions{acceptDownloads=$acceptDownloads, args=$args, cdpHeaders=$cdpHeaders, cdpUrl=$cdpUrl, chromiumSandbox=$chromiumSandbox, connectTimeoutMs=$connectTimeoutMs, deviceScaleFactor=$deviceScaleFactor, devtools=$devtools, downloadsPath=$downloadsPath, executablePath=$executablePath, hasTouch=$hasTouch, headless=$headless, ignoreDefaultArgs=$ignoreDefaultArgs, ignoreHttpsErrors=$ignoreHttpsErrors, locale=$locale, port=$port, preserveUserDataDir=$preserveUserDataDir, proxy=$proxy, userDataDir=$userDataDir, viewport=$viewport, additionalProperties=$additionalProperties}"
}
/** Browser type to use */
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/http/RetryingHttpClientTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/http/RetryingHttpClientTest.kt
index ce14c91..46d5ade 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/http/RetryingHttpClientTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/core/http/RetryingHttpClientTest.kt
@@ -20,7 +20,11 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo
import com.github.tomakehurst.wiremock.junit5.WireMockTest
import com.github.tomakehurst.wiremock.stubbing.Scenario
import java.io.InputStream
+import java.time.Clock
import java.time.Duration
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
import java.util.concurrent.CompletableFuture
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
@@ -36,6 +40,21 @@ internal class RetryingHttpClientTest {
private lateinit var baseUrl: String
private lateinit var httpClient: HttpClient
+ private class RecordingSleeper : Sleeper {
+ val durations = mutableListOf()
+
+ override fun sleep(duration: Duration) {
+ durations.add(duration)
+ }
+
+ override fun sleepAsync(duration: Duration): CompletableFuture {
+ durations.add(duration)
+ return CompletableFuture.completedFuture(null)
+ }
+
+ override fun close() {}
+ }
+
@BeforeEach
fun beforeEach(wmRuntimeInfo: WireMockRuntimeInfo) {
baseUrl = wmRuntimeInfo.httpBaseUrl
@@ -86,7 +105,8 @@ internal class RetryingHttpClientTest {
@ValueSource(booleans = [false, true])
fun execute(async: Boolean) {
stubFor(post(urlPathEqualTo("/something")).willReturn(ok()))
- val retryingClient = retryingHttpClientBuilder().build()
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).build()
val response =
retryingClient.execute(
@@ -100,6 +120,7 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(1, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).isEmpty()
assertNoResponseLeaks()
}
@@ -111,8 +132,12 @@ internal class RetryingHttpClientTest {
.withHeader("X-Some-Header", matching("stainless-java-retry-.+"))
.willReturn(ok())
)
+ val sleeper = RecordingSleeper()
val retryingClient =
- retryingHttpClientBuilder().maxRetries(2).idempotencyHeader("X-Some-Header").build()
+ retryingHttpClientBuilder(sleeper)
+ .maxRetries(2)
+ .idempotencyHeader("X-Some-Header")
+ .build()
val response =
retryingClient.execute(
@@ -126,20 +151,20 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(1, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).isEmpty()
assertNoResponseLeaks()
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
fun execute_withRetryAfterHeader(async: Boolean) {
+ val retryAfterDate = "Wed, 21 Oct 2015 07:28:00 GMT"
stubFor(
post(urlPathEqualTo("/something"))
// First we fail with a retry after header given as a date
.inScenario("foo")
.whenScenarioStateIs(Scenario.STARTED)
- .willReturn(
- serviceUnavailable().withHeader("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")
- )
+ .willReturn(serviceUnavailable().withHeader("Retry-After", retryAfterDate))
.willSetStateTo("RETRY_AFTER_DATE")
)
stubFor(
@@ -158,7 +183,13 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(2).build()
+ // Fix the clock to 5 seconds before the Retry-After date so the date-based backoff is
+ // deterministic.
+ val retryAfterDateTime =
+ OffsetDateTime.parse(retryAfterDate, DateTimeFormatter.RFC_1123_DATE_TIME)
+ val clock = Clock.fixed(retryAfterDateTime.minusSeconds(5).toInstant(), ZoneOffset.UTC)
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(2).build()
val response =
retryingClient.execute(
@@ -186,19 +217,20 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("2")),
)
+ assertThat(sleeper.durations)
+ .containsExactly(Duration.ofSeconds(5), Duration.ofMillis(1234))
assertNoResponseLeaks()
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
fun execute_withOverwrittenRetryCountHeader(async: Boolean) {
+ val retryAfterDate = "Wed, 21 Oct 2015 07:28:00 GMT"
stubFor(
post(urlPathEqualTo("/something"))
.inScenario("foo") // first we fail with a retry after header given as a date
.whenScenarioStateIs(Scenario.STARTED)
- .willReturn(
- serviceUnavailable().withHeader("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")
- )
+ .willReturn(serviceUnavailable().withHeader("Retry-After", retryAfterDate))
.willSetStateTo("RETRY_AFTER_DATE")
)
stubFor(
@@ -208,7 +240,11 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(2).build()
+ val retryAfterDateTime =
+ OffsetDateTime.parse(retryAfterDate, DateTimeFormatter.RFC_1123_DATE_TIME)
+ val clock = Clock.fixed(retryAfterDateTime.minusSeconds(5).toInstant(), ZoneOffset.UTC)
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper, clock).maxRetries(2).build()
val response =
retryingClient.execute(
@@ -227,6 +263,7 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("42")),
)
+ assertThat(sleeper.durations).containsExactly(Duration.ofSeconds(5))
assertNoResponseLeaks()
}
@@ -247,7 +284,8 @@ internal class RetryingHttpClientTest {
.willReturn(ok())
.willSetStateTo("COMPLETED")
)
- val retryingClient = retryingHttpClientBuilder().maxRetries(1).build()
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
val response =
retryingClient.execute(
@@ -261,6 +299,7 @@ internal class RetryingHttpClientTest {
assertThat(response.statusCode()).isEqualTo(200)
verify(2, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).containsExactly(Duration.ofMillis(10))
assertNoResponseLeaks()
}
@@ -301,21 +340,12 @@ internal class RetryingHttpClientTest {
override fun close() = httpClient.close()
}
+ val sleeper = RecordingSleeper()
val retryingClient =
RetryingHttpClient.builder()
.httpClient(failingHttpClient)
.maxRetries(2)
- .sleeper(
- object : Sleeper {
-
- override fun sleep(duration: Duration) {}
-
- override fun sleepAsync(duration: Duration): CompletableFuture =
- CompletableFuture.completedFuture(null)
-
- override fun close() {}
- }
- )
+ .sleeper(sleeper)
.build()
val response =
@@ -339,25 +369,153 @@ internal class RetryingHttpClientTest {
postRequestedFor(urlPathEqualTo("/something"))
.withHeader("x-stainless-retry-count", equalTo("0")),
)
+ // Exponential backoff with jitter: 0.5s * jitter where jitter is in [0.75, 1.0].
+ assertThat(sleeper.durations).hasSize(1)
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
assertNoResponseLeaks()
}
- private fun retryingHttpClientBuilder() =
- RetryingHttpClient.builder()
- .httpClient(httpClient)
- // Use a no-op `Sleeper` to make the test fast.
- .sleeper(
- object : Sleeper {
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withExponentialBackoff(async: Boolean) {
+ stubFor(post(urlPathEqualTo("/something")).willReturn(serviceUnavailable()))
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(3).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
- override fun sleep(duration: Duration) {}
+ // All retries exhausted; the last 503 response is returned.
+ assertThat(response.statusCode()).isEqualTo(503)
+ verify(4, postRequestedFor(urlPathEqualTo("/something")))
+ // Exponential backoff with jitter: backoff = min(0.5 * 2^(retries-1), 8) * jitter where
+ // jitter is in [0.75, 1.0].
+ assertThat(sleeper.durations).hasSize(3)
+ // retries=1: 0.5s * [0.75, 1.0]
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
+ // retries=2: 1.0s * [0.75, 1.0]
+ assertThat(sleeper.durations[1]).isBetween(Duration.ofMillis(750), Duration.ofMillis(1000))
+ // retries=3: 2.0s * [0.75, 1.0]
+ assertThat(sleeper.durations[2]).isBetween(Duration.ofMillis(1500), Duration.ofMillis(2000))
+ assertNoResponseLeaks()
+ }
- override fun sleepAsync(duration: Duration): CompletableFuture =
- CompletableFuture.completedFuture(null)
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withExponentialBackoffCap(async: Boolean) {
+ stubFor(post(urlPathEqualTo("/something")).willReturn(serviceUnavailable()))
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(6).build()
- override fun close() {}
- }
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
)
+ assertThat(response.statusCode()).isEqualTo(503)
+ verify(7, postRequestedFor(urlPathEqualTo("/something")))
+ assertThat(sleeper.durations).hasSize(6)
+ // retries=5: min(0.5 * 2^4, 8) = 8.0s * [0.75, 1.0]
+ assertThat(sleeper.durations[4]).isBetween(Duration.ofMillis(6000), Duration.ofMillis(8000))
+ // retries=6: min(0.5 * 2^5, 8) = min(16, 8) = 8.0s * [0.75, 1.0] (capped)
+ assertThat(sleeper.durations[5]).isBetween(Duration.ofMillis(6000), Duration.ofMillis(8000))
+ assertNoResponseLeaks()
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withRetryAfterMsPriorityOverRetryAfter(async: Boolean) {
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(
+ serviceUnavailable()
+ .withHeader("Retry-After-Ms", "50")
+ .withHeader("Retry-After", "2")
+ )
+ .willSetStateTo("RETRY")
+ )
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs("RETRY")
+ .willReturn(ok())
+ .willSetStateTo("COMPLETED")
+ )
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
+
+ assertThat(response.statusCode()).isEqualTo(200)
+ // Retry-After-Ms (50ms) takes priority over Retry-After (2s).
+ assertThat(sleeper.durations).containsExactly(Duration.ofMillis(50))
+ assertNoResponseLeaks()
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = [false, true])
+ fun execute_withRetryAfterUnparseable(async: Boolean) {
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(serviceUnavailable().withHeader("Retry-After", "not-a-date-or-number"))
+ .willSetStateTo("RETRY")
+ )
+ stubFor(
+ post(urlPathEqualTo("/something"))
+ .inScenario("foo")
+ .whenScenarioStateIs("RETRY")
+ .willReturn(ok())
+ .willSetStateTo("COMPLETED")
+ )
+ val sleeper = RecordingSleeper()
+ val retryingClient = retryingHttpClientBuilder(sleeper).maxRetries(1).build()
+
+ val response =
+ retryingClient.execute(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl(baseUrl)
+ .addPathSegment("something")
+ .build(),
+ async,
+ )
+
+ assertThat(response.statusCode()).isEqualTo(200)
+ // Unparseable Retry-After falls through to exponential backoff.
+ assertThat(sleeper.durations).hasSize(1)
+ assertThat(sleeper.durations[0]).isBetween(Duration.ofMillis(375), Duration.ofMillis(500))
+ assertNoResponseLeaks()
+ }
+
+ private fun retryingHttpClientBuilder(
+ sleeper: RecordingSleeper,
+ clock: Clock = Clock.systemUTC(),
+ ) = RetryingHttpClient.builder().httpClient(httpClient).sleeper(sleeper).clock(clock)
+
private fun HttpClient.execute(request: HttpRequest, async: Boolean): HttpResponse =
if (async) executeAsync(request).get() else execute(request)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionStartParamsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionStartParamsTest.kt
index e22e941..5b35aaa 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionStartParamsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/models/sessions/SessionStartParamsTest.kt
@@ -22,6 +22,11 @@ internal class SessionStartParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -168,6 +173,11 @@ internal class SessionStartParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -331,6 +341,11 @@ internal class SessionStartParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -478,6 +493,11 @@ internal class SessionStartParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ErrorHandlingTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ErrorHandlingTest.kt
index a28b993..a123b5c 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ErrorHandlingTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ErrorHandlingTest.kt
@@ -84,6 +84,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -261,6 +270,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -438,6 +456,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -615,6 +642,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -792,6 +828,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -969,6 +1014,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1146,6 +1200,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1323,6 +1386,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1500,6 +1572,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1677,6 +1758,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -1854,6 +1944,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2031,6 +2130,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2208,6 +2316,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2385,6 +2502,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2562,6 +2688,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2739,6 +2874,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
@@ -2914,6 +3058,15 @@ internal class ErrorHandlingTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty(
+ "foo",
+ JsonValue.from("string"),
+ )
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ServiceParamsTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ServiceParamsTest.kt
index f3a40b7..33f3131 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ServiceParamsTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/ServiceParamsTest.kt
@@ -58,6 +58,11 @@ internal class ServiceParamsTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders.builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/async/SessionServiceAsyncTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/async/SessionServiceAsyncTest.kt
index 2a0e601..ce43fba 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/async/SessionServiceAsyncTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/async/SessionServiceAsyncTest.kt
@@ -493,6 +493,12 @@ internal class SessionServiceAsyncTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)
diff --git a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/blocking/SessionServiceTest.kt b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/blocking/SessionServiceTest.kt
index dedab31..3392149 100644
--- a/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/blocking/SessionServiceTest.kt
+++ b/stagehand-java-core/src/test/kotlin/com/browserbase/api/services/blocking/SessionServiceTest.kt
@@ -486,6 +486,12 @@ internal class SessionServiceTest {
SessionStartParams.Browser.LaunchOptions.builder()
.acceptDownloads(true)
.addArg("string")
+ .cdpHeaders(
+ SessionStartParams.Browser.LaunchOptions.CdpHeaders
+ .builder()
+ .putAdditionalProperty("foo", JsonValue.from("string"))
+ .build()
+ )
.cdpUrl("cdpUrl")
.chromiumSandbox(true)
.connectTimeoutMs(0.0)