diff --git a/stytch/src/main/kotlin/com/stytch/java/b2b/StytchB2BClient.kt b/stytch/src/main/kotlin/com/stytch/java/b2b/StytchB2BClient.kt index 356e7380..1bd73642 100644 --- a/stytch/src/main/kotlin/com/stytch/java/b2b/StytchB2BClient.kt +++ b/stytch/src/main/kotlin/com/stytch/java/b2b/StytchB2BClient.kt @@ -52,10 +52,15 @@ import com.stytch.java.consumer.api.project.ProjectImpl import com.stytch.java.http.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel public class StytchB2BClient @JvmOverloads - constructor(projectId: String, secret: String, clientConfig: OptionalClientConfig = OptionalClientConfig()) { + constructor( + projectId: String, + secret: String, + clientConfig: OptionalClientConfig = OptionalClientConfig(), + ) : AutoCloseable { private val coroutineScope = CoroutineScope(SupervisorJob()) private val baseUrl = getBaseUrl(projectId, clientConfig) private val httpClient: HttpClient = @@ -100,6 +105,17 @@ public class StytchB2BClient public val sessions: Sessions = SessionsImpl(httpClient, coroutineScope, httpsJwks, jwtOptions, policyCache) public val totps: TOTPs = TOTPsImpl(httpClient, coroutineScope) + override fun close() { + // Cancel the scope first so in-flight coroutines stop enqueuing new OkHttp calls. + // cancelBackgroundRefresh() is redundant once the scope is cancelled (child jobs + // are parented off this scope), but called first for explicit intent. + jwksCache.cancelBackgroundRefresh() + policyCache.cancelBackgroundRefresh() + coroutineScope.cancel() + httpClient.close() + fraudHttpClient.close() + } + /** * Resolve the base URL for the Stytch API environment. */ diff --git a/stytch/src/main/kotlin/com/stytch/java/common/Version.kt b/stytch/src/main/kotlin/com/stytch/java/common/Version.kt index 2f254483..11f7b858 100644 --- a/stytch/src/main/kotlin/com/stytch/java/common/Version.kt +++ b/stytch/src/main/kotlin/com/stytch/java/common/Version.kt @@ -1,3 +1,3 @@ package com.stytch.java.common -internal const val VERSION = "11.0.0" +internal const val VERSION = "11.1.0" diff --git a/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt b/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt index 0af4a3fe..0b5b5c5d 100644 --- a/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt +++ b/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt @@ -47,10 +47,15 @@ import com.stytch.java.consumer.api.webauthn.WebAuthnImpl import com.stytch.java.http.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel public class StytchClient @JvmOverloads - constructor(projectId: String, secret: String, clientConfig: OptionalClientConfig = OptionalClientConfig()) { + constructor( + projectId: String, + secret: String, + clientConfig: OptionalClientConfig = OptionalClientConfig(), + ) : AutoCloseable { private val coroutineScope = CoroutineScope(SupervisorJob()) private val baseUrl = getBaseUrl(projectId, clientConfig) private val httpClient: HttpClient = @@ -92,6 +97,16 @@ public class StytchClient public val users: Users = UsersImpl(httpClient, coroutineScope) public val webauthn: WebAuthn = WebAuthnImpl(httpClient, coroutineScope) + override fun close() { + // Cancel the scope first so in-flight coroutines stop enqueuing new OkHttp calls. + // cancelBackgroundRefresh() is redundant once the scope is cancelled (child jobs + // are parented off this scope), but called first for explicit intent. + jwksCache.cancelBackgroundRefresh() + coroutineScope.cancel() + httpClient.close() + fraudHttpClient.close() + } + /** * Resolve the base URL for the Stytch API environment. */ diff --git a/stytch/src/main/kotlin/com/stytch/java/http/HttpClient.kt b/stytch/src/main/kotlin/com/stytch/java/http/HttpClient.kt index abc21825..1d289333 100644 --- a/stytch/src/main/kotlin/com/stytch/java/http/HttpClient.kt +++ b/stytch/src/main/kotlin/com/stytch/java/http/HttpClient.kt @@ -57,7 +57,7 @@ internal class HttpClient( projectId: String, secret: String, private val client: OkHttpClient = createHttpClient(projectId, secret), -) { +) : AutoCloseable { private val moshi = Moshi.Builder().add(InstantAdapter()).build() internal fun buildUrl( @@ -222,4 +222,10 @@ internal class HttpClient( StytchResult.Error(StytchException.Critical(e)) } } + + override fun close() { + client.dispatcher.cancelAll() + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() + } } diff --git a/stytch/src/test/kotlin/com/stytch/java/http/HttpClientTest.kt b/stytch/src/test/kotlin/com/stytch/java/http/HttpClientTest.kt index 3e5b0932..58f40dd6 100644 --- a/stytch/src/test/kotlin/com/stytch/java/http/HttpClientTest.kt +++ b/stytch/src/test/kotlin/com/stytch/java/http/HttpClientTest.kt @@ -18,6 +18,8 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import okhttp3.Call import okhttp3.Callback +import okhttp3.ConnectionPool +import okhttp3.Dispatcher import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Protocol @@ -27,6 +29,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import okio.IOException import org.junit.Before import org.junit.Test +import java.util.concurrent.ExecutorService @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) internal class HttpClientTest { @@ -174,6 +177,35 @@ internal class HttpClientTest { assert(result is StytchResult.Error) } + @Test + fun `close cancels in-flight calls, shuts down executor, and evicts connection pool`() { + val mockExecutorService: ExecutorService = mockk(relaxUnitFun = true) + val mockDispatcher: Dispatcher = + mockk { + every { cancelAll() } just runs + every { executorService } returns mockExecutorService + } + val mockConnectionPool: ConnectionPool = mockk(relaxUnitFun = true) + val mockClient: OkHttpClient = + mockk { + every { dispatcher } returns mockDispatcher + every { connectionPool } returns mockConnectionPool + } + val client = + HttpClient( + baseUrl = "http://something", + projectId = "project-id", + secret = "secret", + client = mockClient, + ) + + client.close() + + verify(exactly = 1) { mockDispatcher.cancelAll() } + verify(exactly = 1) { mockExecutorService.shutdown() } + verify(exactly = 1) { mockConnectionPool.evictAll() } + } + @Test fun `makeRequest returns StytchResult_Success for successful requests that are mappable`() = runTest { diff --git a/version.gradle.kts b/version.gradle.kts index a9ccca2f..68093838 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -1 +1 @@ -version = "11.0.0" +version = "11.1.0" diff --git a/workbench/src/main/kotlin/com/stytch/workbench/Main.kt b/workbench/src/main/kotlin/com/stytch/workbench/Main.kt index f59fb774..1c0c6fbc 100644 --- a/workbench/src/main/kotlin/com/stytch/workbench/Main.kt +++ b/workbench/src/main/kotlin/com/stytch/workbench/Main.kt @@ -3,17 +3,17 @@ import com.stytch.java.consumer.StytchClient import com.stytch.java.consumer.models.magiclinksemail.LoginOrCreateRequest suspend fun main() { - val stytchClient = - StytchClient( - projectId = "project-test-....", - secret = "secret-test-....", - ) - val result = - stytchClient.magicLinks.email.loginOrCreate( - LoginOrCreateRequest( - email = "email@address.com", - signupExpirationMinutes = 30, - ), - ) - println(result) + StytchClient( + projectId = "project-test-....", + secret = "secret-test-....", + ).use { stytchClient -> + val result = + stytchClient.magicLinks.email.loginOrCreate( + LoginOrCreateRequest( + email = "email@address.com", + signupExpirationMinutes = 30, + ), + ) + println(result) + } }