Skip to content

Commit 4da98ab

Browse files
committed
ws client: handle io errors (connection closed), tweak the return type of PodsApi#execStream, minor refactoring
1 parent f46000e commit 4da98ab

File tree

5 files changed

+74
-81
lines changed

5 files changed

+74
-81
lines changed

kubernetes-client/src/com/goyeau/kubernetes/client/KubernetesClient.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@ import com.goyeau.kubernetes.client.util.SslContexts
99
import com.goyeau.kubernetes.client.util.cache.{AuthorizationParse, ExecToken}
1010
import io.circe.{Decoder, Encoder}
1111
import org.http4s.client.Client
12-
import org.http4s.client.middleware.{RequestLogger, ResponseLogger}
1312
import org.http4s.headers.Authorization
1413
import org.http4s.jdkhttpclient.{JdkHttpClient, JdkWSClient}
15-
import org.http4s.client.websocket.{WSClient, WSClientHighLevel, WSConnection, WSConnectionHighLevel, WSRequest}
14+
import org.http4s.client.websocket.WSClient
1615
import org.typelevel.log4cats.Logger
1716

1817
import java.net.http.HttpClient

kubernetes-client/src/com/goyeau/kubernetes/client/api/PodsApi.scala

Lines changed: 68 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.goyeau.kubernetes.client.api
22

33
import cats.effect.{Async, Resource}
4+
import cats.effect.syntax.all.*
45
import cats.syntax.all.*
56
import com.goyeau.kubernetes.client.KubeConfig
67
import com.goyeau.kubernetes.client.api.ExecRouting.*
@@ -142,24 +143,19 @@ private[client] class NamespacedPodsApi[F[_]](
142143
F.ref(List.empty[StdErr]),
143144
F.ref(none[ErrorOrStatus])
144145
).tupled.flatMap { case (stdErr, errorOrStatus) =>
145-
execRequest(podName, Seq("sh", "-c", s"cat ${sourceFile.toString}"), container).flatMap { request =>
146-
wsClient.connectHighLevel(request).use { connection =>
147-
connection.receiveStream
148-
.through(processWebSocketData)
149-
.evalMapFilter[F, Chunk[Byte]] {
150-
case Left(StdOut(data)) => Chunk.array(data).some.pure[F]
151-
case Left(e: StdErr) => stdErr.update(e :: _).as(None)
152-
case Right(statusOrError) => errorOrStatus.update(_.orElse(statusOrError.some)).as(None)
153-
}
154-
.unchunks
155-
.through(Files[F].writeAll(destinationFile))
156-
.compile
157-
.drain
158-
.flatMap { _ =>
159-
(stdErr.get.map(_.reverse), errorOrStatus.get).tupled
160-
}
146+
execStream(podName, container, Seq("sh", "-c", s"cat ${sourceFile.toString}"))
147+
.evalMapFilter[F, Chunk[Byte]] {
148+
case Left(StdOut(data)) => Chunk.array(data).some.pure[F]
149+
case Left(e: StdErr) => stdErr.update(e :: _).as(None)
150+
case Right(statusOrError) => errorOrStatus.update(_.orElse(statusOrError.some)).as(None)
151+
}
152+
.unchunks
153+
.through(Files[F].writeAll(destinationFile))
154+
.compile
155+
.drain
156+
.flatMap { _ =>
157+
(stdErr.get.map(_.reverse), errorOrStatus.get).tupled
161158
}
162-
}
163159
}
164160

165161
@deprecated("Use upload() which uses fs2.io.file.Path", "0.8.2")
@@ -179,16 +175,7 @@ private[client] class NamespacedPodsApi[F[_]](
179175
): F[(List[StdErr], Option[ErrorOrStatus])] = {
180176
val mkDirResult = destinationFile.parent match {
181177
case Some(dir) =>
182-
execRequest(
183-
podName,
184-
Seq("sh", "-c", s"mkdir -p $dir"),
185-
container
186-
).flatMap { mkDirRequest =>
187-
wsClient
188-
.connectHighLevel(mkDirRequest)
189-
.map(conn => F.delay(conn.receiveStream.through(processWebSocketData)))
190-
.use(_.flatMap(foldErrorStream))
191-
}
178+
foldErrorStream(execStream(podName, container, Seq("sh", "-c", s"mkdir -p $dir")))
192179
case None =>
193180
(List.empty -> None).pure
194181
}
@@ -201,37 +188,34 @@ private[client] class NamespacedPodsApi[F[_]](
201188
)
202189

203190
val uploadFileResult =
204-
uploadRequest.flatMap { uploadRequest =>
205-
wsClient.connectHighLevel(uploadRequest).use { connection =>
206-
val source = Files[F].readAll(sourceFile, 4096, Flags.Read)
207-
val sendData = source
208-
.mapChunks(chunk => Chunk(WSFrame.Binary(ByteVector(chunk.toChain.prepend(StdInId).toVector))))
209-
.through(connection.sendPipe)
210-
val retryAttempts = 5
211-
val sendWithRetry = Stream
212-
.retry(sendData.compile.drain, delay = 500.millis, nextDelay = _ * 2, maxAttempts = retryAttempts)
213-
.onError { case e =>
214-
Stream.eval(Logger[F].error(e)(s"Failed send file data after $retryAttempts attempts"))
215-
}
216-
217-
val result = for {
218-
signal <- Stream.eval(SignallingRef[F, Boolean](false))
219-
dataStream = sendWithRetry *> Stream.eval(signal.set(true))
220-
221-
output = connection.receiveStream
222-
.through(
223-
processWebSocketData
224-
)
225-
.interruptWhen(signal)
226-
.concurrently(dataStream)
227-
228-
errors = foldErrorStream(
229-
output
230-
).map { case (errors, _) => errors }
231-
} yield errors
232-
233-
result.compile.lastOrError.flatten
234-
}
191+
uploadRequest.toResource.flatMap(wsClient.connectHighLevel).use { connection =>
192+
val source = Files[F].readAll(sourceFile, 4096, Flags.Read)
193+
val sendData = source
194+
.mapChunks(chunk => Chunk(WSFrame.Binary(ByteVector(chunk.toChain.prepend(StdInId).toVector))))
195+
.through(connection.sendPipe)
196+
val retryAttempts = 5
197+
val sendWithRetry = Stream
198+
.retry(sendData.compile.drain, delay = 500.millis, nextDelay = _ * 2, maxAttempts = retryAttempts)
199+
.onError { case e =>
200+
Stream.eval(Logger[F].error(e)(s"Failed send file data after $retryAttempts attempts"))
201+
}
202+
203+
val result = for {
204+
signal <- Stream.eval(SignallingRef[F, Boolean](false))
205+
dataStream = sendWithRetry *> Stream.eval(signal.set(true))
206+
207+
output = connection.receiveStream
208+
.through(skipConnectionClosedErrors)
209+
.through(processWebSocketData)
210+
.interruptWhen(signal)
211+
.concurrently(dataStream)
212+
213+
errors = foldErrorStream(
214+
output
215+
).map { case (errors, _) => errors }
216+
} yield errors
217+
218+
result.compile.lastOrError.flatten
235219
}
236220

237221
for {
@@ -249,12 +233,15 @@ private[client] class NamespacedPodsApi[F[_]](
249233
stdout: Boolean = true,
250234
stderr: Boolean = true,
251235
tty: Boolean = false
252-
): Resource[F, F[Stream[F, Either[ExecStream, ErrorOrStatus]]]] =
253-
Resource.eval(execRequest(podName, command, container, stdin, stdout, stderr, tty)).flatMap { request =>
254-
wsClient.connectHighLevel(request).map { connection =>
255-
F.delay(connection.receiveStream.through(processWebSocketData))
236+
): Stream[F, Either[ExecStream, ErrorOrStatus]] =
237+
Stream
238+
.eval(execRequest(podName, command, container, stdin, stdout, stderr, tty))
239+
.flatMap(request => Stream.resource(wsClient.connectHighLevel(request)))
240+
.flatMap { connection =>
241+
connection.receiveStream
242+
.through(skipConnectionClosedErrors)
243+
.through(processWebSocketData)
256244
}
257-
}
258245

259246
def exec(
260247
podName: String,
@@ -265,11 +252,26 @@ private[client] class NamespacedPodsApi[F[_]](
265252
stderr: Boolean = true,
266253
tty: Boolean = false
267254
): F[(List[ExecStream], Option[ErrorOrStatus])] =
268-
execStream(podName, container, command, stdin, stdout, stderr, tty).use(_.flatMap(foldStream))
255+
foldStream(execStream(podName, container, command, stdin, stdout, stderr, tty))
256+
257+
private def skipConnectionClosedErrors: Pipe[F, WSDataFrame, WSDataFrame] =
258+
_.map(_.some)
259+
.recover {
260+
// Need to handle (and ignore) this exception
261+
//
262+
// Because of the "conflict" between the http4s WS client and
263+
// the underlying JDK WS client (which are both high-level clients)
264+
// an extra "Close" frame gets sent to the server, potentially
265+
// after the TCP connection is closed, which causes this exception.
266+
//
267+
// This will be solved in a later version of the http4s (core or jdk).
268+
case e: java.io.IOException if e.getMessage == "closed output" => none
269+
}
270+
.unNone
269271

270272
private def foldStream(
271273
stdoutStream: Stream[F, Either[ExecStream, ErrorOrStatus]]
272-
) =
274+
): F[(List[ExecStream], Option[ErrorOrStatus])] =
273275
stdoutStream.compile.fold((List.empty[ExecStream], none[ErrorOrStatus])) { case ((accEvents, accStatus), data) =>
274276
data match {
275277
case Left(event) =>
@@ -281,7 +283,7 @@ private[client] class NamespacedPodsApi[F[_]](
281283

282284
private def foldErrorStream(
283285
stdoutStream: Stream[F, Either[ExecStream, ErrorOrStatus]]
284-
) =
286+
): F[(List[StdErr], Option[ErrorOrStatus])] =
285287
stdoutStream.compile.fold((List.empty[StdErr], none[ErrorOrStatus])) { case ((accEvents, accStatus), data) =>
286288
data match {
287289
case Left(event) =>

kubernetes-client/src/com/goyeau/kubernetes/client/api/RawApi.scala

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import com.goyeau.kubernetes.client.KubeConfig
66
import com.goyeau.kubernetes.client.operation.*
77
import org.http4s.client.Client
88
import org.http4s.headers.Authorization
9-
import org.http4s.jdkhttpclient.{WSClient, WSConnectionHighLevel, WSRequest}
9+
import org.http4s.client.websocket.{WSClient, WSConnectionHighLevel, WSRequest}
1010
import org.http4s.{Request, Response}
1111

1212
private[client] class RawApi[F[_]](
@@ -19,22 +19,17 @@ private[client] class RawApi[F[_]](
1919
def runRequest(
2020
request: Request[F]
2121
): Resource[F, Response[F]] =
22-
Request[F](
23-
method = request.method,
24-
uri = config.server.resolve(request.uri),
25-
httpVersion = request.httpVersion,
26-
headers = request.headers,
27-
body = request.body,
28-
attributes = request.attributes
29-
).withOptionalAuthorization(authorization)
22+
request
23+
.withUri(config.server.resolve(request.uri))
24+
.withOptionalAuthorization(authorization)
3025
.toResource
3126
.flatMap(httpClient.run)
3227

3328
def connectWS(
3429
request: WSRequest
3530
): Resource[F, WSConnectionHighLevel[F]] =
3631
request
37-
.copy(uri = config.server.resolve(request.uri))
32+
.withUri(config.server.resolve(request.uri))
3833
.withOptionalAuthorization(authorization)
3934
.toResource
4035
.flatMap { request =>

kubernetes-client/test/src/com/goyeau/kubernetes/client/api/RawApiTest.scala

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ import org.typelevel.log4cats.Logger
1818
import org.typelevel.log4cats.slf4j.Slf4jLogger
1919

2020
import java.nio.file.Files as JFiles
21-
import scala.util.Random
2221
import org.http4s.implicits.*
23-
import org.http4s.jdkhttpclient.WSConnectionHighLevel
2422

2523
class RawApiTest extends FunSuite with MinikubeClientProvider[IO] with ContextProvider {
2624

kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/WatchableTests.scala

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import com.goyeau.kubernetes.client.Utils.retry
77
import com.goyeau.kubernetes.client.api.CustomResourceDefinitionsApiTest
88
import com.goyeau.kubernetes.client.{EventType, KubernetesClient, WatchEvent}
99
import fs2.concurrent.SignallingRef
10-
import fs2.{Pipe, Stream}
1110
import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta
1211
import munit.FunSuite
1312
import org.http4s.Status

0 commit comments

Comments
 (0)