From b3e3508480d57c48347d5e368af84d9988f8e6cd Mon Sep 17 00:00:00 2001 From: amirmohammad Date: Sun, 8 Feb 2026 00:46:21 +0330 Subject: [PATCH] Expose metrics on Connection interface Add connectAtMillis(), idleAtMillis(), successCount(), callCount(), and noNewExchanges() to the public Connection interface so users can build features like ConnectionExpiringInterceptor. Rename Carrier.noNewExchanges() to Carrier.prohibitNewExchanges() to avoid a JVM method signature clash with the new Connection.noNewExchanges() getter. Closes #9219 --- okhttp/api/android/okhttp.api | 5 ++++ okhttp/api/jvm/okhttp.api | 5 ++++ .../kotlin/okhttp3/Connection.kt | 30 +++++++++++++++++++ .../internal/connection/ConnectPlan.kt | 2 +- .../okhttp3/internal/connection/Exchange.kt | 2 +- .../okhttp3/internal/connection/RealCall.kt | 2 ++ .../internal/connection/RealConnection.kt | 24 +++++++++++++-- .../internal/connection/RealConnectionPool.kt | 1 + .../okhttp3/internal/http/ExchangeCodec.kt | 2 +- .../internal/http1/Http1ExchangeCodec.kt | 12 ++++---- .../kotlin/okhttp3/ConnectionListenerTest.kt | 26 ++++++++++++++++ .../kotlin/okhttp3/KotlinSourceModernTest.kt | 10 +++++++ 12 files changed, 110 insertions(+), 11 deletions(-) diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index 5c2fb7b67142..61173ba0faac 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -343,10 +343,15 @@ public abstract interface class okhttp3/CompressionInterceptor$DecompressionAlgo } public abstract interface class okhttp3/Connection { + public abstract fun callCount ()I + public abstract fun connectAtMillis ()J public abstract fun handshake ()Lokhttp3/Handshake; + public abstract fun idleAtMillis ()Ljava/lang/Long; + public abstract fun noNewExchanges ()Z public abstract fun protocol ()Lokhttp3/Protocol; public abstract fun route ()Lokhttp3/Route; public abstract fun socket ()Ljava/net/Socket; + public abstract fun successCount ()I } public final class okhttp3/ConnectionPool { diff --git a/okhttp/api/jvm/okhttp.api b/okhttp/api/jvm/okhttp.api index 62eead9baf56..4e8e543a783f 100644 --- a/okhttp/api/jvm/okhttp.api +++ b/okhttp/api/jvm/okhttp.api @@ -343,10 +343,15 @@ public abstract interface class okhttp3/CompressionInterceptor$DecompressionAlgo } public abstract interface class okhttp3/Connection { + public abstract fun callCount ()I + public abstract fun connectAtMillis ()J public abstract fun handshake ()Lokhttp3/Handshake; + public abstract fun idleAtMillis ()Ljava/lang/Long; + public abstract fun noNewExchanges ()Z public abstract fun protocol ()Lokhttp3/Protocol; public abstract fun route ()Lokhttp3/Route; public abstract fun socket ()Ljava/net/Socket; + public abstract fun successCount ()I } public final class okhttp3/ConnectionPool { diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Connection.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Connection.kt index 20dda3de9dc7..e64786768415 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Connection.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Connection.kt @@ -89,4 +89,34 @@ interface Connection { * [Protocol.HTTP_1_0]. */ fun protocol(): Protocol + + /** + * Returns the wall-clock time (epoch millis) when this connection was created. This is the time + * the TCP and TLS handshakes completed. + */ + fun connectAtMillis(): Long + + /** + * Returns the wall-clock time (epoch millis) when this connection became idle, or null if it is + * currently active (carrying one or more calls). + */ + fun idleAtMillis(): Long? + + /** + * Returns the number of exchanges successfully completed on this connection. Each completed + * request/response pair increments this count. + */ + fun successCount(): Int + + /** + * Returns the number of calls currently carried by this connection. This is 0 when the + * connection is idle, and may be greater than 1 for HTTP/2 connections. + */ + fun callCount(): Int + + /** + * Returns true if this connection will not accept new exchanges. This may be because the + * connection has been marked for closure, or because an error has occurred on this connection. + */ + fun noNewExchanges(): Boolean } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index e90421ccdc44..f74ca58059c8 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -527,7 +527,7 @@ class ConnectPlan internal constructor( // Do nothing. } - override fun noNewExchanges() { + override fun prohibitNewExchanges() { // Do nothing. } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/Exchange.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/Exchange.kt index 4a9f62004fa7..6e9a7aa379dd 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/Exchange.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/Exchange.kt @@ -175,7 +175,7 @@ class Exchange( } fun noNewExchangesOnConnection() { - codec.carrier.noNewExchanges() + codec.carrier.prohibitNewExchanges() } fun cancel() { diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealCall.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealCall.kt index 9c7721fc5978..83da25b055e2 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealCall.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealCall.kt @@ -321,6 +321,7 @@ class RealCall( check(this.connection == null) this.connection = connection connection.calls.add(CallReference(this, callStackTrace)) + connection.idleAtEpochMillis = null } /** @@ -452,6 +453,7 @@ class RealCall( if (calls.isEmpty()) { connection.idleAtNs = System.nanoTime() + connection.idleAtEpochMillis = System.currentTimeMillis() if (connectionPool.connectionBecameIdle(connection)) { return connection.socket() } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnection.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnection.kt index a67c2a6788dd..4ff4f2ec5dc8 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnection.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnection.kt @@ -121,6 +121,12 @@ class RealConnection internal constructor( /** Timestamp when `allocations.size()` reached zero. Also assigned upon initial connection. */ var idleAtNs = Long.MAX_VALUE + /** Wall-clock time (epoch millis) when the connection was created. */ + internal var connectAtEpochMillis: Long = 0L + + /** Wall-clock time (epoch millis) when the connection became idle, or null if active. */ + internal var idleAtEpochMillis: Long? = null + /** * Returns true if this is an HTTP/2 connection. Such connections can be used in multiple HTTP * requests simultaneously. @@ -129,7 +135,7 @@ class RealConnection internal constructor( get() = http2Connection != null /** Prevent further exchanges from being created on this connection. */ - override fun noNewExchanges() { + override fun prohibitNewExchanges() { withLock { noNewExchanges = true } @@ -152,6 +158,8 @@ class RealConnection internal constructor( @Throws(IOException::class) fun start() { idleAtNs = System.nanoTime() + connectAtEpochMillis = System.currentTimeMillis() + idleAtEpochMillis = connectAtEpochMillis if (protocol == Protocol.HTTP_2 || protocol == Protocol.H2_PRIOR_KNOWLEDGE) { startHttp2() } @@ -284,7 +292,7 @@ class RealConnection internal constructor( internal fun useAsSocket() { javaNetSocket.soTimeout = 0 - noNewExchanges() + prohibitNewExchanges() } override fun route(): Route = route @@ -416,6 +424,16 @@ class RealConnection internal constructor( override fun protocol(): Protocol = protocol + override fun connectAtMillis(): Long = connectAtEpochMillis + + override fun idleAtMillis(): Long? = withLock { idleAtEpochMillis } + + override fun successCount(): Int = withLock { successCount } + + override fun callCount(): Int = withLock { calls.size } + + override fun noNewExchanges(): Boolean = withLock { noNewExchanges } + override fun toString(): String = "Connection{${route.address.url.host}:${route.address.url.port}," + " proxy=${route.proxy}" + @@ -456,6 +474,8 @@ class RealConnection internal constructor( connectionListener = ConnectionListener.NONE, ) result.idleAtNs = idleAtNs + result.connectAtEpochMillis = System.currentTimeMillis() + result.idleAtEpochMillis = result.connectAtEpochMillis return result } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnectionPool.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnectionPool.kt index 2bf7da124c50..6f7768262a38 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnectionPool.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnectionPool.kt @@ -314,6 +314,7 @@ class RealConnectionPool internal constructor( // If this was the last allocation, the connection is eligible for immediate eviction. if (references.isEmpty()) { connection.idleAtNs = now - keepAliveDurationNs + connection.idleAtEpochMillis = System.currentTimeMillis() return 0 } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/ExchangeCodec.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/ExchangeCodec.kt index f9273fcb41ab..ab4798abceb7 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/ExchangeCodec.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/ExchangeCodec.kt @@ -92,7 +92,7 @@ interface ExchangeCodec { e: IOException?, ) - fun noNewExchanges() + fun prohibitNewExchanges() fun cancel() } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/Http1ExchangeCodec.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/Http1ExchangeCodec.kt index f1a176c0299d..5e1f1b24882a 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/Http1ExchangeCodec.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/Http1ExchangeCodec.kt @@ -283,7 +283,7 @@ class Http1ExchangeCodec( private fun newUnknownLengthSource(url: HttpUrl): Source { check(state == STATE_OPEN_RESPONSE_BODY) { "state: $state" } state = STATE_READING_RESPONSE_BODY - carrier.noNewExchanges() + carrier.prohibitNewExchanges() return UnknownLengthSource(url) } @@ -396,7 +396,7 @@ class Http1ExchangeCodec( try { socket.source.read(sink, byteCount) } catch (e: IOException) { - carrier.noNewExchanges() + carrier.prohibitNewExchanges() responseBodyComplete(TRAILERS_RESPONSE_BODY_TRUNCATED) throw e } @@ -440,7 +440,7 @@ class Http1ExchangeCodec( val read = super.read(sink, minOf(bytesRemaining, byteCount)) if (read == -1L) { - carrier.noNewExchanges() // The server didn't supply the promised content length. + carrier.prohibitNewExchanges() // The server didn't supply the promised content length. val e = ProtocolException("unexpected end of stream") responseBodyComplete(TRAILERS_RESPONSE_BODY_TRUNCATED) throw e @@ -459,7 +459,7 @@ class Http1ExchangeCodec( if (bytesRemaining != 0L && !discard(ExchangeCodec.DISCARD_STREAM_TIMEOUT_MILLIS, MILLISECONDS) ) { - carrier.noNewExchanges() // Unread bytes remain on the stream. + carrier.prohibitNewExchanges() // Unread bytes remain on the stream. responseBodyComplete(TRAILERS_RESPONSE_BODY_TRUNCATED) } @@ -489,7 +489,7 @@ class Http1ExchangeCodec( val read = super.read(sink, minOf(byteCount, bytesRemainingInChunk)) if (read == -1L) { - carrier.noNewExchanges() // The server didn't supply the promised chunk length. + carrier.prohibitNewExchanges() // The server didn't supply the promised chunk length. val e = ProtocolException("unexpected end of stream") responseBodyComplete(TRAILERS_RESPONSE_BODY_TRUNCATED) throw e @@ -528,7 +528,7 @@ class Http1ExchangeCodec( if (hasMoreChunks && !discard(ExchangeCodec.DISCARD_STREAM_TIMEOUT_MILLIS, MILLISECONDS) ) { - carrier.noNewExchanges() // Unread bytes remain on the stream. + carrier.prohibitNewExchanges() // Unread bytes remain on the stream. responseBodyComplete(TRAILERS_RESPONSE_BODY_TRUNCATED) } closed = true diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/ConnectionListenerTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/ConnectionListenerTest.kt index 3cc5fd047006..fec00a470975 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/ConnectionListenerTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/ConnectionListenerTest.kt @@ -25,7 +25,10 @@ import assertk.assertThat import assertk.assertions.containsExactly import assertk.assertions.hasMessage import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isGreaterThan import assertk.assertions.isIn +import assertk.assertions.isNotNull import java.io.IOException import java.net.InetSocketAddress import java.net.UnknownHostException @@ -340,6 +343,29 @@ open class ConnectionListenerTest { assertThat(event.connection.route().proxy).isEqualTo(proxy) } + @Test + @Throws(IOException::class) + fun connectionMetrics() { + server.enqueue(MockResponse()) + val call = + client.newCall( + Request + .Builder() + .url(server.url("/")) + .build(), + ) + val response = call.execute() + assertThat(response.code).isEqualTo(200) + response.body.close() + val event = listener.removeUpToEvent(ConnectionEvent.ConnectEnd::class.java) + val connection = event.connection + assertThat(connection.connectAtMillis()).isGreaterThan(0L) + assertThat(connection.successCount()).isEqualTo(1) + assertThat(connection.callCount()).isEqualTo(0) + assertThat(connection.idleAtMillis()).isNotNull() + assertThat(connection.noNewExchanges()).isFalse() + } + private fun enableTls() { client = client diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/KotlinSourceModernTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/KotlinSourceModernTest.kt index 0565ce66afe8..0c138aee2300 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/KotlinSourceModernTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/KotlinSourceModernTest.kt @@ -292,6 +292,16 @@ class KotlinSourceModernTest { override fun handshake(): Handshake? = TODO() override fun protocol(): Protocol = TODO() + + override fun connectAtMillis(): Long = TODO() + + override fun idleAtMillis(): Long? = TODO() + + override fun successCount(): Int = TODO() + + override fun callCount(): Int = TODO() + + override fun noNewExchanges(): Boolean = TODO() } }