From 3f4cbf5b236b6042c3bad0bdaf96813ca984cd6c Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Wed, 25 Mar 2026 20:34:08 +0100 Subject: [PATCH 1/3] fix: close OkHttp response bodies to prevent connection leaks Use Response.use {} to ensure OkHttp response bodies are always closed, preventing "connection was leaked" warnings. Affected call sites: - FlagApplierClientImpl (flags:apply) - EventSenderUploaderImpl (events:publish) - RemoteFlagResolver (flags:resolve) Made-with: Cursor --- .../spotify/confidence/EventSenderUploader.kt | 27 ++++++++++--------- .../spotify/confidence/RemoteFlagResolver.kt | 24 ++++++----------- .../client/FlagApplierClientImpl.kt | 16 +++++------ 3 files changed, 30 insertions(+), 37 deletions(-) diff --git a/Confidence/src/main/java/com/spotify/confidence/EventSenderUploader.kt b/Confidence/src/main/java/com/spotify/confidence/EventSenderUploader.kt index 9bdb24a1..29f653b5 100644 --- a/Confidence/src/main/java/com/spotify/confidence/EventSenderUploader.kt +++ b/Confidence/src/main/java/com/spotify/confidence/EventSenderUploader.kt @@ -65,19 +65,20 @@ internal class EventSenderUploaderImpl( .post(networkJson.encodeToString(events).toRequestBody()) .build() - val response = httpClient.newCall(httpRequest).await() - if (!response.isSuccessful) { - debugLogger?.logError(message = "Failed to upload events. http code ${response.code}") - } - when (response.code) { - // clean up in case of success - 200 -> true - // we shouldn't cleanup for rate limiting - // TODO("return retry-after") - 429 -> false - // if batch couldn't be processed, we should clean it up - in 400..499 -> true - else -> false + httpClient.newCall(httpRequest).await().use { response -> + if (!response.isSuccessful) { + debugLogger?.logError(message = "Failed to upload events. http code ${response.code}") + } + when (response.code) { + // clean up in case of success + 200 -> true + // we shouldn't cleanup for rate limiting + // TODO("return retry-after") + 429 -> false + // if batch couldn't be processed, we should clean it up + in 400..499 -> true + else -> false + } } } diff --git a/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt b/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt index f9f9e404..55183efc 100644 --- a/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt +++ b/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt @@ -57,8 +57,7 @@ internal class RemoteFlagResolver( val startTime = System.nanoTime() var status = Telemetry.RequestStatus.SUCCESS try { - val result = httpClient.newCall(httpRequest).await() - result.toResolveFlags() + httpClient.newCall(httpRequest).await().use { it.toResolveFlags() } } catch (e: SocketTimeoutException) { status = Telemetry.RequestStatus.TIMEOUT throw e @@ -93,21 +92,14 @@ internal class RemoteFlagResolver( if (!isSuccessful) { debugLogger?.logError("Failed to resolve flags. Http code: $code") } - body?.let { body -> - val bodyString = body.string() - - // building the json class responsible for serializing the object - val networkJson = Json { - serializersModule = SerializersModule { - ignoreUnknownKeys = true - } + val bodyString = body?.string() + ?: throw ConfidenceError.ParseError("Response body is null", listOf()) + val networkJson = Json { + serializersModule = SerializersModule { + ignoreUnknownKeys = true } - try { - return ResolveResponse.Resolved(networkJson.decodeFromString(bodyString)) - } finally { - body.close() - } - } ?: throw ConfidenceError.ParseError("Response body is null", listOf()) + } + return ResolveResponse.Resolved(networkJson.decodeFromString(bodyString)) } } diff --git a/Confidence/src/main/java/com/spotify/confidence/client/FlagApplierClientImpl.kt b/Confidence/src/main/java/com/spotify/confidence/client/FlagApplierClientImpl.kt index 0af66ae3..e67d6cd6 100644 --- a/Confidence/src/main/java/com/spotify/confidence/client/FlagApplierClientImpl.kt +++ b/Confidence/src/main/java/com/spotify/confidence/client/FlagApplierClientImpl.kt @@ -94,16 +94,16 @@ internal class FlagApplierClientImpl : FlagApplierClient { extraHeaders[Telemetry.HEADER_NAME] = headerValue } - val result = applyInteractor.invoke(request, extraHeaders).runCatching { - if (isSuccessful) { - Result.Success(Unit) - } else { - Result.Failure() + return try { + applyInteractor.invoke(request, extraHeaders).use { response -> + if (response.isSuccessful) { + Result.Success(Unit) + } else { + Result.Failure() + } } - }.getOrElse { + } catch (_: Exception) { Result.Failure() } - - return result } } From 90fe2c365faccf79052eb6bbee99ae3f7abf74b2 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Wed, 25 Mar 2026 23:11:51 +0100 Subject: [PATCH 2/3] fix: throw early on non-successful resolve responses Previously, toResolveFlags() would log the error but still attempt to deserialize the response body on HTTP errors, leading to confusing parse exceptions. Now it throws immediately with the HTTP status code. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/com/spotify/confidence/RemoteFlagResolver.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt b/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt index 55183efc..f2baf993 100644 --- a/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt +++ b/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt @@ -91,6 +91,7 @@ internal class RemoteFlagResolver( private fun Response.toResolveFlags(): ResolveResponse { if (!isSuccessful) { debugLogger?.logError("Failed to resolve flags. Http code: $code") + throw ConfidenceError.ParseError("Failed to resolve flags. Http code: $code", listOf()) } val bodyString = body?.string() ?: throw ConfidenceError.ParseError("Response body is null", listOf()) From 8b9841cdcdf7cbf60e45cf21aecbd11a0ca5a69b Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 26 Mar 2026 08:50:37 +0100 Subject: [PATCH 3/3] fix: use HttpError instead of ParseError for non-successful HTTP responses Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/com/spotify/confidence/Confidence.kt | 3 +++ .../src/main/java/com/spotify/confidence/ConfidenceError.kt | 5 +++++ .../main/java/com/spotify/confidence/RemoteFlagResolver.kt | 2 +- .../confidence/openfeature/ConfidenceFeatureProvider.kt | 3 +++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt index c55d91b0..431be4c4 100644 --- a/Confidence/src/main/java/com/spotify/confidence/Confidence.kt +++ b/Confidence/src/main/java/com/spotify/confidence/Confidence.kt @@ -1,6 +1,7 @@ package com.spotify.confidence import android.content.Context +import com.spotify.confidence.ConfidenceError.HttpError import com.spotify.confidence.ConfidenceError.ParseError import com.spotify.confidence.apply.FlagApplierWithRetries import com.spotify.confidence.cache.DiskStorage @@ -287,6 +288,8 @@ class Confidence internal constructor( } } catch (e: ParseError) { throw ParseError(e.message) + } catch (e: HttpError) { + throw e } } diff --git a/Confidence/src/main/java/com/spotify/confidence/ConfidenceError.kt b/Confidence/src/main/java/com/spotify/confidence/ConfidenceError.kt index 9f75b507..f77b8f8a 100644 --- a/Confidence/src/main/java/com/spotify/confidence/ConfidenceError.kt +++ b/Confidence/src/main/java/com/spotify/confidence/ConfidenceError.kt @@ -22,5 +22,10 @@ class ConfidenceError { val flag: String ) : Error(message) + data class HttpError( + val httpCode: Int, + override val message: String + ) : Error(message) + class InvalidContextInMessage : Error("Field 'context' is not allowed in event's data") } diff --git a/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt b/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt index f2baf993..b4e2d9dd 100644 --- a/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt +++ b/Confidence/src/main/java/com/spotify/confidence/RemoteFlagResolver.kt @@ -91,7 +91,7 @@ internal class RemoteFlagResolver( private fun Response.toResolveFlags(): ResolveResponse { if (!isSuccessful) { debugLogger?.logError("Failed to resolve flags. Http code: $code") - throw ConfidenceError.ParseError("Failed to resolve flags. Http code: $code", listOf()) + throw ConfidenceError.HttpError(code, "Failed to resolve flags. Http code: $code") } val bodyString = body?.string() ?: throw ConfidenceError.ParseError("Response body is null", listOf()) diff --git a/Provider/src/main/java/com/spotify/confidence/openfeature/ConfidenceFeatureProvider.kt b/Provider/src/main/java/com/spotify/confidence/openfeature/ConfidenceFeatureProvider.kt index d7c48bac..73d2438f 100644 --- a/Provider/src/main/java/com/spotify/confidence/openfeature/ConfidenceFeatureProvider.kt +++ b/Provider/src/main/java/com/spotify/confidence/openfeature/ConfidenceFeatureProvider.kt @@ -5,6 +5,7 @@ package com.spotify.confidence.openfeature import com.spotify.confidence.Confidence import com.spotify.confidence.ConfidenceError.ErrorCode import com.spotify.confidence.ConfidenceError.FlagNotFoundError +import com.spotify.confidence.ConfidenceError.HttpError import com.spotify.confidence.ConfidenceError.ParseError import com.spotify.confidence.ConfidenceValue import com.spotify.confidence.Evaluation @@ -125,6 +126,8 @@ class ConfidenceFeatureProvider private constructor( throw OpenFeatureError.ParseError(e.message) } catch (e: FlagNotFoundError) { throw OpenFeatureError.FlagNotFoundError(e.flag) + } catch (e: HttpError) { + throw OpenFeatureError.GeneralError(e.message) } } companion object {