Prepare exceptions to common sockets

This commit is contained in:
Leonid Stashevsky
2020-01-31 09:59:38 +03:00
committed by Leonid Stashevsky
parent 96b59e9c6e
commit 4e5ab80a1d
22 changed files with 163 additions and 134 deletions

View File

@@ -6,6 +6,7 @@ package io.ktor.client.engine.android
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.network.sockets.*
import io.ktor.util.cio.*
import io.ktor.utils.io.*
import io.ktor.utils.io.jvm.javaio.*
@@ -42,7 +43,7 @@ private fun HttpURLConnection.setupRequestTimeoutAttributes(
}
/**
* Call [HttpURLConnection.connect] catching [SocketTimeoutException] and returning [HttpSocketTimeoutException] instead
* Call [HttpURLConnection.connect] catching [java.net.SocketTimeoutException] and returning [SocketTimeoutException] instead
* of it. If request timeout happens earlier [HttpRequestTimeoutException] will be thrown.
*/
internal suspend fun HttpURLConnection.timeoutAwareConnect(request: HttpRequestData) {
@@ -52,7 +53,7 @@ internal suspend fun HttpURLConnection.timeoutAwareConnect(request: HttpRequestD
// Allow to throw request timeout cancellation exception instead of connect timeout exception if needed.
yield()
throw when (cause) {
is SocketTimeoutException -> HttpConnectTimeoutException(request)
is java.net.SocketTimeoutException -> ConnectTimeoutException(request, cause)
else -> cause
}
}

View File

@@ -38,10 +38,10 @@ internal suspend fun CloseableHttpAsyncClient.sendRequest(
val callback = object : FutureCallback<Unit> {
override fun failed(exception: Exception) {
val mappedCause = when {
exception is ConnectException && exception.isTimeoutException() -> HttpConnectTimeoutException(
requestData
exception is ConnectException && exception.isTimeoutException() -> ConnectTimeoutException(
requestData, exception
)
exception is SocketTimeoutException -> HttpSocketTimeoutException(requestData)
exception is java.net.SocketTimeoutException -> SocketTimeoutException(requestData, exception)
else -> exception
}

View File

@@ -11,10 +11,10 @@ import io.ktor.client.request.*
import io.ktor.client.utils.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.utils.io.*
import kotlinx.atomicfu.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import io.ktor.utils.io.*
import org.apache.http.*
import org.apache.http.HttpHeaders
import org.apache.http.HttpRequest
@@ -25,7 +25,7 @@ import org.apache.http.entity.*
import org.apache.http.nio.*
import org.apache.http.nio.protocol.*
import org.apache.http.protocol.*
import java.nio.ByteBuffer
import java.nio.*
import kotlin.coroutines.*
internal class ApacheRequestProducer(
@@ -186,7 +186,8 @@ internal class ApacheRequestProducer(
private fun ByteBuffer.recycle() {
if (requestData.body is OutgoingContent.WriteChannelContent ||
requestData.body is OutgoingContent.ReadChannelContent) {
requestData.body is OutgoingContent.ReadChannelContent
) {
HttpClientDefaultPool.recycle(this)
}
}

View File

@@ -122,8 +122,8 @@ internal class ApacheResponseConsumerDispatching(
override fun failed(cause: Exception) {
val mappedCause = when {
cause is ConnectException && cause.isTimeoutException() -> HttpConnectTimeoutException(requestData!!)
cause is SocketTimeoutException -> HttpSocketTimeoutException(requestData!!)
cause is ConnectException && cause.isTimeoutException() -> ConnectTimeoutException(requestData!!, cause)
cause is java.net.SocketTimeoutException -> SocketTimeoutException(requestData!!, cause)
else -> cause
}

View File

@@ -121,7 +121,7 @@ internal class Endpoint(
response.resume(responseData)
} catch (cause: Throwable) {
val mappedException = when (cause.rootCause) {
is SocketTimeoutException -> HttpSocketTimeoutException(task.request)
is java.net.SocketTimeoutException -> SocketTimeoutException(task.request, cause)
else -> cause
}
response.resumeWithException(mappedException)
@@ -210,7 +210,7 @@ internal class Endpoint(
*/
private fun getTimeoutException(retryAttempts: Int, timeoutFails: Int, request: HttpRequestData) =
when (timeoutFails) {
retryAttempts -> HttpConnectTimeoutException(request)
retryAttempts -> ConnectTimeoutException(request)
else -> FailToConnectException()
}

View File

@@ -7,8 +7,8 @@ package io.ktor.client.features
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.request.*
import io.ktor.network.sockets.*
import io.ktor.util.*
import io.ktor.utils.io.errors.*
import kotlinx.coroutines.*
import kotlin.native.concurrent.*
@@ -141,22 +141,38 @@ fun HttpRequestBuilder.timeout(block: HttpTimeout.HttpTimeoutCapabilityConfigura
/**
* This exception is thrown in case request timeout exceeded.
*/
class HttpRequestTimeoutException(request: HttpRequestBuilder) :
CancellationException(
"Request timeout has been expired [url=${request.url}, request_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.requestTimeoutMillis ?: "unknown"} ms]"
)
class HttpRequestTimeoutException(
request: HttpRequestBuilder
) : CancellationException(
"Request timeout has been expired [url=${request.url}, request_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.requestTimeoutMillis ?: "unknown"} ms]"
)
/**
* This exception is thrown in case connect timeout exceeded.
*/
expect class HttpConnectTimeoutException(request: HttpRequestData) : IOException
fun ConnectTimeoutException(
request: HttpRequestData, cause: Throwable? = null
): ConnectTimeoutException = ConnectTimeoutException(
"Connect timeout has been expired [url=${request.url}, connect_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.connectTimeoutMillis ?: "unknown"} ms]",
cause
)
/**
* This exception is thrown in case socket timeout (read or write) exceeded.
*/
expect class HttpSocketTimeoutException(request: HttpRequestData) : IOException
fun SocketTimeoutException(
request: HttpRequestData,
cause: Throwable? = null
): SocketTimeoutException = SocketTimeoutException(
"Socket timeout has been expired [url=${request.url}, socket_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.socketTimeoutMillis ?: "unknown"}] ms",
cause
)
/**
* Convert long timeout in milliseconds to int value. To do that we need to consider [HttpTimeout.INFINITE_TIMEOUT_MS]
@@ -170,6 +186,10 @@ fun convertLongTimeoutToIntWithInfiniteAsZero(timeout: Long): Int = when {
else -> timeout.toInt()
}
/**
* Convert long timeout in milliseconds to long value. To do that we need to consider [HttpTimeout.INFINITE_TIMEOUT_MS]
* as zero and convert timeout value to [Int].
*/
@InternalAPI
fun convertLongTimeoutToLongWithInfiniteAsZero(timeout: Long): Long = when (timeout) {
HttpTimeout.INFINITE_TIMEOUT_MS -> 0L

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2014-2020 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.network.sockets
import io.ktor.utils.io.errors.*
/**
* This exception is thrown in case connect timeout exceeded.
*/
expect class ConnectTimeoutException(message: String, cause: Throwable? = null) : IOException
/**
* This exception is thrown in case socket timeout (read or write) exceeded.
*/
expect class SocketTimeoutException(message: String, cause: Throwable? = null) : IOException

View File

@@ -1,30 +0,0 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.client.features
import io.ktor.client.request.*
import io.ktor.utils.io.errors.*
/**
* HTTP connect timeout exception.
*/
@Suppress("ACTUAL_WITHOUT_EXPECT")
actual class HttpConnectTimeoutException actual constructor(request: HttpRequestData) :
IOException(
"Connect timeout has been expired [url=${request.url}, connect_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.connectTimeoutMillis ?: "unknown"} ms]"
)
/**
* HTTP socket timeout exception.
*/
@Suppress("ACTUAL_WITHOUT_EXPECT")
actual class HttpSocketTimeoutException actual constructor(request: HttpRequestData) :
IOException(
"Socket timeout has been expired [url=${request.url}, socket_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.socketTimeoutMillis ?: "unknown"}] ms"
)

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2014-2020 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.network.sockets
import io.ktor.utils.io.errors.*
/**
* This exception is thrown in case connect timeout exceeded.
*/
actual class ConnectTimeoutException actual constructor(
message: String, cause: Throwable?
) : IOException(message, cause)
/**
* This exception is thrown in case socket timeout (read or write) exceeded.
*/
actual class SocketTimeoutException actual constructor(
message: String, cause: Throwable?
) : IOException(message, cause)

View File

@@ -1,33 +1,35 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2020 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.client.features
package io.ktor.network.sockets
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.util.*
import io.ktor.utils.io.*
import kotlinx.coroutines.*
import java.net.*
/**
* This exception is thrown in case connect timeout exceeded.
*/
@Suppress("ACTUAL_WITHOUT_EXPECT")
actual class HttpConnectTimeoutException actual constructor(request: HttpRequestData) :
ConnectException(
"Connect timeout has been expired [url=${request.url}, connect_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.connectTimeoutMillis ?: "unknown"} ms]"
)
@Suppress("ACTUAL_WITHOUT_EXPECT")
actual class HttpSocketTimeoutException actual constructor(request: HttpRequestData) :
SocketTimeoutException(
"Socket timeout has been expired [url=${request.url}, socket_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.socketTimeoutMillis ?: "unknown"}] ms"
)
actual class ConnectTimeoutException actual constructor(
message: String, override val cause: Throwable?
) : ConnectException(message) {
}
/**
* Returns [ByteReadChannel] with [ByteChannel.close] handler that returns [HttpSocketTimeoutException] instead of
* This exception is thrown in case socket timeout (read or write) exceeded.
*/
@Suppress("ACTUAL_WITHOUT_EXPECT")
actual class SocketTimeoutException actual constructor(
message: String, override val cause: Throwable?
) : java.net.SocketTimeoutException(message)
/**
* Returns [ByteReadChannel] with [ByteChannel.close] handler that returns [SocketTimeoutException] instead of
* [SocketTimeoutException].
*/
@InternalAPI
@@ -46,7 +48,7 @@ fun CoroutineScope.mapEngineExceptions(input: ByteReadChannel, request: HttpRequ
}
/**
* Returns [ByteWriteChannel] with [ByteChannel.close] handler that returns [HttpSocketTimeoutException] instead of
* Returns [ByteWriteChannel] with [ByteChannel.close] handler that returns [SocketTimeoutException] instead of
* [SocketTimeoutException].
*/
@InternalAPI
@@ -65,12 +67,12 @@ fun CoroutineScope.mapEngineExceptions(input: ByteWriteChannel, request: HttpReq
}
/**
* Creates [ByteChannel] that maps close exceptions (close the channel with [HttpSocketTimeoutException] if asked to
* Creates [ByteChannel] that maps close exceptions (close the channel with [SocketTimeoutException] if asked to
* close it with [SocketTimeoutException]).
*/
private fun ByteChannelWithMappedExceptions(request: HttpRequestData) = ByteChannel { cause ->
when (cause?.rootCause) {
is SocketTimeoutException -> HttpSocketTimeoutException(request)
is java.net.SocketTimeoutException -> SocketTimeoutException(request, cause)
else -> cause
}
}

View File

@@ -1,30 +0,0 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.client.features
import io.ktor.client.request.*
import io.ktor.utils.io.errors.*
/**
* HTTP connect timeout exception.
*/
@Suppress("ACTUAL_WITHOUT_EXPECT")
actual class HttpConnectTimeoutException actual constructor(request: HttpRequestData) :
IOException(
"Connect timeout has been expired [url=${request.url}, connect_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.connectTimeoutMillis ?: "unknown"} ms]"
)
/**
* HTTP socket timeout exception.
*/
@Suppress("ACTUAL_WITHOUT_EXPECT")
actual class HttpSocketTimeoutException actual constructor(request: HttpRequestData) :
IOException(
"Socket timeout has been expired [url=${request.url}, socket_timeout=${request.getCapabilityOrNull(
HttpTimeout
)?.socketTimeoutMillis ?: "unknown"}] ms"
)

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2014-2020 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.network.sockets
import io.ktor.utils.io.errors.*
/**
* This exception is thrown in case connect timeout exceeded.
*/
actual class ConnectTimeoutException actual constructor(
message: String, cause: Throwable?
) : IOException(message, cause)
/**
* This exception is thrown in case socket timeout (read or write) exceeded.
*/
actual class SocketTimeoutException actual constructor(
message: String, cause: Throwable?
) : IOException(message, cause)

View File

@@ -6,6 +6,7 @@ package io.ktor.client.engine.curl.internal
import io.ktor.client.engine.curl.*
import io.ktor.client.features.*
import io.ktor.network.sockets.*
import kotlinx.cinterop.*
import io.ktor.utils.io.core.*
import libcurl.*
@@ -204,7 +205,7 @@ internal class CurlMultiApiHandler : Closeable {
if (result == CURLE_OPERATION_TIMEDOUT) {
return CurlFail(
responseBuilder.request,
HttpConnectTimeoutException(responseBuilder.request.requestData)
ConnectTimeoutException(responseBuilder.request.requestData)
)
}

View File

@@ -45,7 +45,7 @@ internal class IosClientEngine(override val config: IosClientEngineConfig) : Htt
if (didCompleteWithError != null) {
val mappedException = when (didCompleteWithError.code) {
NSURLErrorTimedOut -> HttpSocketTimeoutException(data)
NSURLErrorTimedOut -> SocketTimeoutException(data)
else -> IosHttpRequestException(didCompleteWithError)
}

View File

@@ -11,15 +11,15 @@ import io.ktor.client.request.*
import io.ktor.client.utils.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.network.sockets.*
import io.ktor.util.*
import io.ktor.util.date.*
import kotlinx.coroutines.*
import io.ktor.utils.io.*
import kotlinx.coroutines.*
import okhttp3.*
import okhttp3.internal.http.HttpMethod
import okio.*
import java.io.*
import java.net.*
import java.util.concurrent.*
import kotlin.coroutines.*
@@ -149,8 +149,8 @@ private fun BufferedSource.toChannel(context: CoroutineContext, requestData: Htt
}
}.channel
private fun mapExceptions(cause: Throwable, request: HttpRequestData) = when (cause) {
is SocketTimeoutException -> HttpSocketTimeoutException(request)
private fun mapExceptions(cause: Throwable, request: HttpRequestData): Throwable = when (cause) {
is java.net.SocketTimeoutException -> SocketTimeoutException(request, cause)
else -> cause
}

View File

@@ -11,7 +11,6 @@ import kotlinx.coroutines.*
import okhttp3.*
import okhttp3.Headers
import java.io.*
import java.net.*
import kotlin.coroutines.*
internal suspend fun OkHttpClient.execute(request: Request, requestData: HttpRequestData): Response =
@@ -25,10 +24,10 @@ internal suspend fun OkHttpClient.execute(request: Request, requestData: HttpReq
}
val mappedException = when (cause) {
is SocketTimeoutException -> if (cause.message?.contains("connect") == true) {
HttpConnectTimeoutException(requestData)
is java.net.SocketTimeoutException -> if (cause.message?.contains("connect") == true) {
ConnectTimeoutException(requestData, cause)
} else {
HttpSocketTimeoutException(requestData)
SocketTimeoutException(requestData, cause)
}
else -> cause
}

View File

@@ -10,4 +10,4 @@ import io.ktor.util.*
* Check that [block] completed with given type of root cause.
*/
@InternalAPI
expect inline fun <reified T : Throwable> assertFailsWithRootCause(block: () -> Unit)
expect inline fun <reified T : Throwable> assertFailsAndContainsCause(block: () -> Unit)

View File

@@ -10,6 +10,7 @@ import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.client.tests.utils.*
import io.ktor.http.*
import io.ktor.network.sockets.*
import io.ktor.utils.io.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.*
@@ -101,7 +102,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
client.head<HttpResponse>("$TEST_URL/with-delay?delay=1000")
}
}
@@ -208,7 +209,7 @@ class HttpTimeoutTest : ClientLoader() {
method = HttpMethod.Get
parameter("delay", 500)
}
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
response.readUTF8Line()
}
}
@@ -227,7 +228,7 @@ class HttpTimeoutTest : ClientLoader() {
timeout { requestTimeoutMillis = 1000 }
}
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
response.readUTF8Line()
}
}
@@ -272,7 +273,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
client.get<ByteArray>("$TEST_URL/with-stream") {
parameter("delay", 200)
}
@@ -287,7 +288,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
client.get<ByteArray>("$TEST_URL/with-stream") {
parameter("delay", 200)
@@ -337,7 +338,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
client.get<String>("$TEST_URL/with-redirect") {
parameter("delay", 500)
parameter("count", 5)
@@ -353,7 +354,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
client.get<String>("$TEST_URL/with-redirect") {
parameter("delay", 500)
parameter("count", 5)
@@ -371,7 +372,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
client.get<String>("$TEST_URL/with-redirect") {
parameter("delay", 250)
parameter("count", 5)
@@ -387,7 +388,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpRequestTimeoutException> {
assertFailsAndContainsCause<HttpRequestTimeoutException> {
client.get<String>("$TEST_URL/with-redirect") {
parameter("delay", 250)
parameter("count", 5)
@@ -405,7 +406,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpConnectTimeoutException> {
assertFailsAndContainsCause<ConnectTimeoutException> {
client.get<String>("http://www.google.com:81")
}
}
@@ -421,7 +422,7 @@ class HttpTimeoutTest : ClientLoader() {
assertFails {
try {
client.get<String>("http://localhost:11")
} catch (_: HttpConnectTimeoutException) {
} catch (_: ConnectTimeoutException) {
/* Ignore. */
}
}
@@ -435,7 +436,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpConnectTimeoutException> {
assertFailsAndContainsCause<ConnectTimeoutException> {
client.get<String>("http://www.google.com:81") {
timeout { connectTimeoutMillis = 1000 }
}
@@ -450,7 +451,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpSocketTimeoutException> {
assertFailsAndContainsCause<SocketTimeoutException> {
client.get<String>("$TEST_URL/with-stream") {
parameter("delay", 5000)
}
@@ -465,7 +466,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpSocketTimeoutException> {
assertFailsAndContainsCause<SocketTimeoutException> {
client.get<String>("$TEST_URL/with-stream") {
parameter("delay", 5000)
@@ -482,7 +483,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpSocketTimeoutException> {
assertFailsAndContainsCause<SocketTimeoutException> {
client.post("$TEST_URL/slow-read") { body = makeString(4 * 1024 * 1024) }
}
}
@@ -495,7 +496,7 @@ class HttpTimeoutTest : ClientLoader() {
}
test { client ->
assertFailsWithRootCause<HttpSocketTimeoutException> {
assertFailsAndContainsCause<SocketTimeoutException> {
client.post("$TEST_URL/slow-read") {
body = makeString(4 * 1024 * 1024)
timeout { socketTimeoutMillis = 500 }

View File

@@ -10,7 +10,7 @@ import io.ktor.util.*
* Check that [block] completed with given type of root cause.
*/
@InternalAPI
actual inline fun <reified T : Throwable> assertFailsWithRootCause(block: () -> Unit) {
actual inline fun <reified T : Throwable> assertFailsAndContainsCause(block: () -> Unit) {
try {
block()
error("Expected ${T::class} exception, but it wasn't thrown")

View File

@@ -11,10 +11,12 @@ import kotlin.test.*
* Check that [block] completed with given type of root cause.
*/
@InternalAPI
actual inline fun <reified T : Throwable> assertFailsWithRootCause(block: () -> Unit) {
actual inline fun <reified T : Throwable> assertFailsAndContainsCause(block: () -> Unit) {
var cause = assertFails(block)
while (cause.cause != null) {
cause = cause.cause!!
while(true) {
if (cause is T) return
cause = cause.cause ?: break
}
assertTrue("Expected root cause is ${T::class}, but got ${cause::class}") { cause is T }

View File

@@ -18,7 +18,7 @@ abstract class TestWithKtor {
protected val serverPort: Int = ServerSocket(0).use { it.localPort }
@get:Rule
open val timeout = CoroutinesTimeout.seconds(3 * 60)
open val timeout = CoroutinesTimeout.seconds(5 * 60)
abstract val server: ApplicationEngine

View File

@@ -11,10 +11,12 @@ import kotlin.test.*
* Check that [block] completed with given type of root cause.
*/
@InternalAPI
actual inline fun <reified T : Throwable> assertFailsWithRootCause(block: () -> Unit) {
actual inline fun <reified T : Throwable> assertFailsAndContainsCause(block: () -> Unit) {
var cause = assertFails(block)
while (cause.cause != null) {
cause = cause.cause!!
if (cause is T) return
}
assertTrue("Expected root cause is ${T::class}, but got ${cause::class}") { cause is T }