Skip to content

Commit ca1f60f

Browse files
adamwclaude
andauthored
Retry flaky Vert.x WebSocket server tests (#5410)
## Problem The Vert.x WebSocket server tests (`CatsVertxServerTest`, `ZioVertxServerTest`, `VertxServerTest`) intermittently fail CI with a 3-minute `TimeoutException` — e.g. recently on scala-steward dependency PRs (#5404), unrelated to the bumped dependency. ## Root cause (upstream Vert.x, not tapir) A race in `HttpServerRequest.toWebSocket()`: Vert.x writes+flushes the HTTP 101 in one event-loop task and completes the returned `Future` (where we register our frame handler) in a later task. A client frame arriving in that ~1-3 ms window is read and then **silently dropped** — `WebSocketImplBase.receiveFrame` discards TEXT/BINARY frames when no handler is registered, and the server WebSocket's inbound queue starts unpaused. The echo never returns, so the test times out. This is **not fixable via Vert.x's public per-request API**: `toWebSocket()` hands back an already-flowing socket, and the intermediate handshake object isn't exposed (its `pause()` throws `UnsupportedOperationException`). The only race-free API is the server-level `HttpServer.webSocketHandler`, which bypasses tapir's `Router`. The bug is present in every released Vert.x and on current `master`; it will be reported upstream (the fix there is to start the server WebSocket paused / buffer inbound frames until a handler is registered). ## This change Interim, test-only: retry failed Vert.x server tests using the same `withFixture` retry already used by `JdkHttpServerTest` and `ZioHttpServerTest`. `retries = 3` (lower than those suites' `5`, because a WS flake hangs ~3 min per attempt rather than failing fast). Each attempt re-runs with a fresh timeout budget. No production code changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 504091b commit ca1f60f

6 files changed

Lines changed: 42 additions & 35 deletions

File tree

server/jdkhttp-server/src/test/scala/sttp/tapir/server/jdkhttp/JdkHttpServerTest.scala

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,17 @@
11
package sttp.tapir.server.jdkhttp
22

33
import cats.effect.{IO, Resource}
4-
import org.scalatest.{EitherValues, Exceptional, FutureOutcome}
4+
import org.scalatest.EitherValues
55
import sttp.tapir.server.jdkhttp.internal.idMonad
66
import sttp.tapir.server.tests._
77
import sttp.tapir.tests.{Test, TestSuite}
88

9-
import scala.concurrent.Future
10-
119
class JdkHttpServerTest extends TestSuite with EitherValues {
1210
// these tests often fail on CI with:
1311
// "Cause: java.io.IOException: HTTP/1.1 header parser received no bytes"
1412
// "Cause: java.io.EOFException: EOF reached while reading"
1513
// for an unknown reason; adding retries to avoid flaky tests
16-
val retries = 5
17-
18-
override def withFixture(test: NoArgAsyncTest): FutureOutcome = withFixture(test, retries)
19-
20-
def withFixture(test: NoArgAsyncTest, count: Int): FutureOutcome = {
21-
val outcome = super.withFixture(test)
22-
new FutureOutcome(outcome.toFuture.flatMap {
23-
case Exceptional(e) =>
24-
println(s"Test ${test.name} failed, retrying.")
25-
e.printStackTrace()
26-
(if (count == 1) super.withFixture(test) else withFixture(test, count - 1)).toFuture
27-
case other => Future.successful(other)
28-
})
29-
}
14+
override def retries = 5
3015

3116
override def tests: Resource[IO, List[Test]] =
3217
backendResource.flatMap { backend =>

server/vertx-server/cats/src/test/scala/sttp/tapir/server/vertx/cats/CatsVertxServerTest.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import scala.concurrent.duration._
1313

1414
class CatsVertxServerTest extends TestSuite {
1515

16+
// The Vert.x WebSocket tests hit a rare upstream flake: a client frame sent in the brief window after the 101
17+
// handshake but before Vert.x completes toWebSocket() (so before we can register a frame handler) is silently
18+
// dropped, so the echo never returns and the test times out. Not fixable via Vert.x's public per-request API
19+
// (toWebSocket() returns an already-flowing socket); to be reported upstream. Retry failed tests to avoid flaky CI.
20+
override def retries = 3
21+
1622
def vertxResource: Resource[IO, Vertx] =
1723
Resource.make(IO.delay(Vertx.vertx()))(vertx =>
1824
// vertx.close sometimes never completes; bound the wait so suite cleanup can't hang the (non-forked) test JVM

server/vertx-server/src/test/scala/sttp/tapir/server/vertx/VertxServerTest.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import scala.concurrent.Promise
1414
import io.vertx.core.buffer.Buffer
1515

1616
class VertxServerTest extends TestSuite {
17+
18+
// The Vert.x WebSocket tests hit a rare upstream flake: a client frame sent in the brief window after the 101
19+
// handshake but before Vert.x completes toWebSocket() (so before we can register a frame handler) is silently
20+
// dropped, so the echo never returns and the test times out. Not fixable via Vert.x's public per-request API
21+
// (toWebSocket() returns an already-flowing socket); to be reported upstream. Retry failed tests to avoid flaky CI.
22+
override def retries = 3
23+
1724
def vertxResource: Resource[IO, Vertx] =
1825
Resource.make(IO.delay(Vertx.vertx()))(vertx => IO.delay(vertx.close()).void)
1926

server/vertx-server/zio/src/test/scala/sttp/tapir/server/vertx/zio/ZioVertxServerTest.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ import sttp.tapir.ztapir.RIOMonadError
1616
import zio.stream.ZSink
1717

1818
class ZioVertxServerTest extends TestSuite with OptionValues {
19+
20+
// The Vert.x WebSocket tests hit a rare upstream flake: a client frame sent in the brief window after the 101
21+
// handshake but before Vert.x completes toWebSocket() (so before we can register a frame handler) is silently
22+
// dropped, so the echo never returns and the test times out. Not fixable via Vert.x's public per-request API
23+
// (toWebSocket() returns an already-flowing socket); to be reported upstream. Retry failed tests to avoid flaky CI.
24+
override def retries = 3
25+
1926
def vertxResource: Resource[IO, Vertx] =
2027
Resource.make(IO.delay(Vertx.vertx()))(vertx => IO.delay(vertx.close()).void)
2128

server/zio-http-server/src/test/scala/sttp/tapir/server/ziohttp/ZioHttpServerTest.scala

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import cats.implicits.toTraverseOps
66
import io.netty.channel.ChannelFactory
77
import io.netty.channel.ServerChannel
88
import org.scalatest.Assertion
9-
import org.scalatest.Exceptional
10-
import org.scalatest.FutureOutcome
119
import org.scalatest.matchers.should.Matchers._
1210
import sttp.capabilities.zio.ZioStreams
1311
import sttp.client4._
@@ -46,7 +44,6 @@ import zio.stream.ZStream
4644

4745
import java.nio.charset.StandardCharsets
4846
import java.time
49-
import scala.concurrent.Future
5047
import scala.concurrent.duration.DurationInt
5148
import zio.stream.ZSink
5249
import zio.http.netty.NettyConfig
@@ -58,20 +55,7 @@ class ZioHttpServerTest extends TestSuite {
5855

5956
// zio-http tests often fail with "Cause: java.io.IOException: parsing HTTP/1.1 status line, receiving [DEFAULT], parser state [STATUS_LINE]"
6057
// until this is fixed, adding retries to avoid flaky tests
61-
val retries = 5
62-
63-
override def withFixture(test: NoArgAsyncTest): FutureOutcome = withFixture(test, retries)
64-
65-
def withFixture(test: NoArgAsyncTest, count: Int): FutureOutcome = {
66-
val outcome = super.withFixture(test)
67-
new FutureOutcome(outcome.toFuture.flatMap {
68-
case Exceptional(e) =>
69-
println(s"Test ${test.name} failed, retrying.")
70-
e.printStackTrace()
71-
(if (count == 1) super.withFixture(test) else withFixture(test, count - 1)).toFuture
72-
case other => Future.successful(other)
73-
})
74-
}
58+
override def retries = 5
7559

7660
override def tests: Resource[IO, List[Test]] = backendResource.flatMap { backend =>
7761
implicit val r: Runtime[Any] = Runtime.default

tests/src/main/scalajvm/sttp/tapir/tests/TestSuite.scala

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,33 @@ import cats.effect.std.Dispatcher
44
import cats.effect.unsafe.implicits.global
55
import cats.effect.{IO, Resource}
66
import org.scalactic.source.Position
7-
import org.scalatest.BeforeAndAfterAll
87
import org.scalatest.funsuite.AsyncFunSuite
8+
import org.scalatest.{BeforeAndAfterAll, Exceptional, FutureOutcome}
99

10+
import scala.concurrent.Future
1011
import scala.concurrent.duration._
1112

1213
trait TestSuite extends AsyncFunSuite with BeforeAndAfterAll {
1314
def tests: Resource[IO, List[Test]]
1415
def testNameFilter: Option[String] = None // define to run a single test (temporarily for debugging)
1516

17+
/** The number of times a failing test is retried before it is reported as failed. Override with a positive value in
18+
* suites whose backend is subject to a known, unavoidable flake (e.g. an upstream bug) to keep CI green; keep the
19+
* override well-commented with the reason. `0` (the default) means each test runs exactly once, as usual.
20+
*/
21+
def retries: Int = 0
22+
23+
override def withFixture(test: NoArgAsyncTest): FutureOutcome = withRetries(test, retries)
24+
25+
private def withRetries(test: NoArgAsyncTest, remaining: Int): FutureOutcome =
26+
new FutureOutcome(super.withFixture(test).toFuture.flatMap {
27+
case Exceptional(e) if remaining > 0 =>
28+
println(s"Test '${test.name}' failed, retrying ($remaining ${if (remaining == 1) "retry" else "retries"} left).")
29+
e.printStackTrace()
30+
withRetries(test, remaining - 1).toFuture
31+
case other => Future.successful(other)
32+
})
33+
1634
protected val (dispatcher, shutdownDispatcher) = Dispatcher.parallel[IO].allocated.unsafeRunSync()
1735

1836
// we need to register the tests when the class is constructed, as otherwise scalatest skips it

0 commit comments

Comments
 (0)