feat(network): Implement dynamic proxy support for Ktor client#262
feat(network): Implement dynamic proxy support for Ktor client#262rainxchzed wants to merge 1 commit intomainfrom
Conversation
This commit introduces support for HTTP and SOCKS proxies in the application's network layer. It allows the `HttpClient` to be dynamically reconfigured at runtime when proxy settings change. The implementation creates a `GitHubClientProvider` that observes a `ProxyConfig` flow. When the proxy configuration is updated, it closes the existing Ktor `HttpClient` and creates a new one with the updated proxy settings. Platform-specific Ktor engines are used for proxy implementation: `OkHttp` on Android and `CIO` on JVM (desktop). - **feat(network)**: Added `ProxyConfig` data class to model proxy settings. - **feat(network)**: Introduced `ProxyManager` to hold and update the global proxy configuration. - **feat(network)**: Implemented `GitHubClientProvider` to manage the lifecycle of `HttpClient` and recreate it when proxy settings change. - **feat(network)**: Added platform-specific `HttpClientFactory` implementations for Android (`OkHttp`) and JVM (`CIO`) to handle proxy configuration. - **chore(deps)**: Added Ktor client engine dependencies: `ktor-client-okhttp` for Android and `ktor-client-cio` for JVM.
WalkthroughThe pull request introduces proxy configuration support across the codebase. A new ProxyConfig data model and ProxyManager singleton enable runtime proxy management. The HTTP client factory is restructured to use an expect/actual pattern with platform-specific implementations for Android (OkHttp) and JVM (CIO). A new GitHubClientProvider class manages cached HTTP client instances and reactively rebuilds them when proxy configuration changes. Dependency injection wiring and build configuration are updated accordingly. Changes
Sequence DiagramsequenceDiagram
participant PM as ProxyManager
participant GCP as GitHubClientProvider
participant HCF as HttpClientFactory
participant Android as Android<br/>HttpClient
participant JVM as JVM<br/>HttpClient
actor User
User->>PM: setProxyConfig(newConfig)
activate PM
PM->>PM: _proxyConfig.update()
PM-->>GCP: emits via currentProxyConfig flow
deactivate PM
activate GCP
GCP->>GCP: closes previous HttpClient
GCP->>HCF: createGitHubHttpClient(tokenStore, rateLimitRepository, newConfig)
alt Android Platform
activate Android
HCF->>Android: createPlatformHttpClient(newConfig)
Android->>Android: configure OkHttp engine with proxy
Android-->>HCF: HttpClient instance
deactivate Android
else JVM Platform
activate JVM
HCF->>JVM: createPlatformHttpClient(newConfig)
JVM->>JVM: configure CIO engine with proxy
JVM-->>HCF: HttpClient instance
deactivate JVM
end
HCF->>HCF: apply RateLimitInterceptor<br/>ContentNegotiation, Timeout, Retry
HCF-->>GCP: configured HttpClient
GCP->>GCP: store in _client flow
GCP-->>User: new client ready via client flow
deactivate GCP
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt (1)
98-110:⚠️ Potential issue | 🟠 MajorDynamic proxy updates are bypassed by the HttpClient singleton.
GitHubClientProvideris registered with proxy awareness, but theHttpClientsingleton binding callscreateGitHubHttpClient(...)without passingproxyConfig, defaulting to null. All repositories injectingHttpClientdirectly—includingInstalledAppsRepositoryImpl,StarredRepositoryImpl,SearchRepositoryImpl,HomeRepositoryImpl,DetailsRepositoryImpl, and others—receive the static client and never observe proxy changes. SourceHttpClientfromGitHubClientProvider(via itscurrentClient()orclientFlow) or remove the direct singleton binding so all consumers get the proxy-aware client.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt` around lines 98 - 110, The HttpClient singleton is created without proxyConfig so it never observes dynamic proxy changes; instead, make consumers receive the proxy-aware client from GitHubClientProvider by removing the direct single<HttpClient> binding and returning the provider's client (e.g., use GitHubClientProvider.currentClient() or its client Flow/ currentClient() accessor) so all repositories (InstalledAppsRepositoryImpl, StarredRepositoryImpl, SearchRepositoryImpl, HomeRepositoryImpl, DetailsRepositoryImpl, etc.) inject the proxy-aware HttpClient; alternatively, if a single HttpClient instance must be provided, construct it via GitHubClientProvider (calling its client-producing API) so ProxyManager.currentProxyConfig is honored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt`:
- Around line 24-41: The current stateIn initialValue creates a leaked
HttpClient and _client stays null, and closing the old client before creating
the new one can drop the last working client; fix by eagerly creating the
initial client once (call createGitHubHttpClient(tokenStore,
rateLimitRepository)), assign it to _client.value before building the Flow, then
change the map handler to first create the replacement client (using
createGitHubHttpClient with proxyConfig), only after successful creation set
_client.value = newClient and then close the previous client (old =
_client.value before assignment), and make stateIn use the cached _client.value
(or the already-created initial client) as its initialValue so no client is
leaked; reference symbols: client (Flow<HttpClient>), proxyConfigFlow, _client,
createGitHubHttpClient, stateIn, initialValue.
- Around line 43-48: currentClient() can create a client without the latest
proxy settings because it uses _client.value ?: createGitHubHttpClient(...) —
fix by ensuring createGitHubHttpClient is called with the current proxy
configuration (read proxyConfigFlow.value) when creating and assigning _client
in currentClient(), or alternatively expose client as a StateFlow and return
client.value so callers always see updates; update currentClient(), _client
assignment, and the createGitHubHttpClient invocation to consume
proxyConfigFlow.value (or switch callers to the new client StateFlow).
In
`@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt`:
- Around line 9-20: The JVM CIO proxy setup in createPlatformHttpClient ignores
ProxyConfig.username/password; fix by detecting when proxyConfig?.username or
.password is present and install a process-wide java.net.Authenticator via
Authenticator.setDefault that returns a PasswordAuthentication using those
credentials for requests to the proxy host/port; keep the existing
ProxyBuilder.http(...) and ProxyBuilder.socks(...) usage for the engine, and
ensure the Authenticator references proxyConfig.host and proxyConfig.port so
authenticated HTTP/SOCKS proxies work across the JVM process (or alternatively,
if you prefer not to modify global state, throw/return an error when credentials
are provided to ProxyConfig to document the limitation).
---
Outside diff comments:
In `@core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt`:
- Around line 98-110: The HttpClient singleton is created without proxyConfig so
it never observes dynamic proxy changes; instead, make consumers receive the
proxy-aware client from GitHubClientProvider by removing the direct
single<HttpClient> binding and returning the provider's client (e.g., use
GitHubClientProvider.currentClient() or its client Flow/ currentClient()
accessor) so all repositories (InstalledAppsRepositoryImpl,
StarredRepositoryImpl, SearchRepositoryImpl, HomeRepositoryImpl,
DetailsRepositoryImpl, etc.) inject the proxy-aware HttpClient; alternatively,
if a single HttpClient instance must be provided, construct it via
GitHubClientProvider (calling its client-producing API) so
ProxyManager.currentProxyConfig is honored.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (9)
core/data/build.gradle.ktscore/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.ktcore/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.ktcore/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.ktcore/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.ktcore/data/src/commonMain/kotlin/zed/rainxch/core/data/network/ProxyManager.ktcore/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.ktcore/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyConfig.ktgradle/libs.versions.toml
| val client: Flow<HttpClient> = proxyConfigFlow | ||
| .distinctUntilChanged() | ||
| .map { proxyConfig -> | ||
| _client.value?.close() | ||
|
|
||
| val newClient = createGitHubHttpClient( | ||
| tokenStore = tokenStore, | ||
| rateLimitRepository = rateLimitRepository, | ||
| proxyConfig = proxyConfig | ||
| ) | ||
| _client.value = newClient | ||
| newClient | ||
| } | ||
| .stateIn( | ||
| scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), | ||
| started = SharingStarted.Lazily, | ||
| initialValue = createGitHubHttpClient(tokenStore, rateLimitRepository) | ||
| ) |
There was a problem hiding this comment.
Prevent leaked initial HttpClient and keep a single cached instance
The stateIn initialValue creates a client that never gets stored in _client, so _client stays null and you can end up with two live clients (and the initial one is never closed). Also, closing before successfully creating the replacement can drop the last working client if construction fails.
🛠️ Proposed fix
val client: Flow<HttpClient> = proxyConfigFlow
.distinctUntilChanged()
.map { proxyConfig ->
- _client.value?.close()
-
- val newClient = createGitHubHttpClient(
+ val oldClient = _client.value
+ val newClient = createGitHubHttpClient(
tokenStore = tokenStore,
rateLimitRepository = rateLimitRepository,
proxyConfig = proxyConfig
)
_client.value = newClient
+ oldClient?.close()
newClient
}
.stateIn(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
started = SharingStarted.Lazily,
- initialValue = createGitHubHttpClient(tokenStore, rateLimitRepository)
+ initialValue = createGitHubHttpClient(tokenStore, rateLimitRepository)
+ .also { _client.value = it }
)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt`
around lines 24 - 41, The current stateIn initialValue creates a leaked
HttpClient and _client stays null, and closing the old client before creating
the new one can drop the last working client; fix by eagerly creating the
initial client once (call createGitHubHttpClient(tokenStore,
rateLimitRepository)), assign it to _client.value before building the Flow, then
change the map handler to first create the replacement client (using
createGitHubHttpClient with proxyConfig), only after successful creation set
_client.value = newClient and then close the previous client (old =
_client.value before assignment), and make stateIn use the cached _client.value
(or the already-created initial client) as its initialValue so no client is
leaked; reference symbols: client (Flow<HttpClient>), proxyConfigFlow, _client,
createGitHubHttpClient, stateIn, initialValue.
| fun currentClient(): HttpClient { | ||
| return _client.value | ||
| ?: createGitHubHttpClient(tokenStore, rateLimitRepository).also { | ||
| _client.value = it | ||
| } | ||
| } |
There was a problem hiding this comment.
currentClient() can bypass active proxy settings
Because client is lazily started, _client can remain null even when proxy settings are already set; currentClient() then creates a no‑proxy client and proxy updates never apply for callers that only use currentClient().
🛠️ Possible fix (use latest proxy config for eager creation)
fun currentClient(): HttpClient {
return _client.value
- ?: createGitHubHttpClient(tokenStore, rateLimitRepository).also {
- _client.value = it
- }
+ ?: createGitHubHttpClient(
+ tokenStore = tokenStore,
+ rateLimitRepository = rateLimitRepository,
+ proxyConfig = proxyConfigFlow.value
+ ).also { _client.value = it }
}Additional change needed outside this range (to make proxyConfigFlow.value available):
- proxyConfigFlow: Flow<ProxyConfig?>
+ private val proxyConfigFlow: StateFlow<ProxyConfig?>(Alternatively, expose client as a StateFlow and return client.value.)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt`
around lines 43 - 48, currentClient() can create a client without the latest
proxy settings because it uses _client.value ?: createGitHubHttpClient(...) —
fix by ensuring createGitHubHttpClient is called with the current proxy
configuration (read proxyConfigFlow.value) when creating and assigning _client
in currentClient(), or alternatively expose client as a StateFlow and return
client.value so callers always see updates; update currentClient(), _client
assignment, and the createGitHubHttpClient invocation to consume
proxyConfigFlow.value (or switch callers to the new client StateFlow).
| actual fun createPlatformHttpClient(proxyConfig: ProxyConfig?): HttpClient { | ||
| return HttpClient(CIO) { | ||
| engine { | ||
| proxy = proxyConfig?.let { config -> | ||
| when (config.type) { | ||
| ProxyConfig.ProxyType.HTTP -> ProxyBuilder.http( | ||
| Url("http://${config.host}:${config.port}") | ||
| ) | ||
| ProxyConfig.ProxyType.SOCKS -> ProxyBuilder.socks( | ||
| config.host, config.port | ||
| ) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find ProxyConfig definition
fd -t f "ProxyConfig" --extension ktRepository: rainxchzed/Github-Store
Length of output: 145
🏁 Script executed:
# Examine the full HttpClientFactory.jvm.kt file
find . -path "*core/data*" -name "HttpClientFactory.jvm.kt" -type fRepository: rainxchzed/Github-Store
Length of output: 152
🏁 Script executed:
# Search for ProxyConfig class definition
rg -t kt "class ProxyConfig|data class ProxyConfig" -A 10Repository: rainxchzed/Github-Store
Length of output: 93
🌐 Web query:
Ktor 3.2.3 CIO ProxyBuilder userinfo credentials support Url
💡 Result:
In Ktor 3.2.3, ProxyBuilder.http(url: Url) does not support proxy credentials via URL userinfo (for example http://user:pass@proxy:8080). On JVM it builds a java.net.Proxy using only url.host and url.port, so url.user / url.password are ignored. (raw.githubusercontent.com)
Relevant docs/source:
API docs (3.2.x): https://api.ktor.io/3.2.x/ktor-client-core/io.ktor.client.engine/-proxy-builder/index.html
JVM implementation (3.2.3): https://raw.githubusercontent.com/ktorio/ktor/3.2.3/ktor-client/ktor-client-core/jvm/src/io/ktor/client/engine/ProxyConfigJvm.kt
``` ([api.ktor.io](https://api.ktor.io/3.2.x/ktor-client-core/io.ktor.client.engine/-proxy-builder/index.html))
Citations:
- 1: https://raw.githubusercontent.com/ktorio/ktor/3.2.3/ktor-client/ktor-client-core/jvm/src/io/ktor/client/engine/ProxyConfigJvm.kt
- 2: https://api.ktor.io/3.2.x/ktor-client-core/io.ktor.client.engine/-proxy-builder/index.html
---
🏁 Script executed:
```shell
cat -n "core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ProxyConfig.kt"
Repository: rainxchzed/Github-Store
Length of output: 387
🏁 Script executed:
cat -n "./core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt"Repository: rainxchzed/Github-Store
Length of output: 1019
🏁 Script executed:
# Search for all usages of ProxyConfig to understand how credentials are handled elsewhere
rg -t kotlin "ProxyConfig" -B 2 -A 5Repository: rainxchzed/Github-Store
Length of output: 11474
🏁 Script executed:
cat -n "core/data/src/androidMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.android.kt"Repository: rainxchzed/Github-Store
Length of output: 1718
🏁 Script executed:
# Check if ProxyBuilder.socks supports credentials
web_search Ktor 3.2.3 ProxyBuilder.socks credentials SOCKS5 authenticationRepository: rainxchzed/Github-Store
Length of output: 117
🌐 Web query:
Ktor 3.2.3 ProxyBuilder.socks credentials SOCKS5 authentication
💡 Result:
In Ktor 3.2.3, ProxyBuilder.socks(host, port) does not accept credentials—it only builds a SOCKS proxy from host+port. In JVM, it’s literally a java.net.Proxy(Proxy.Type.SOCKS, InetSocketAddress(host, port)), so SOCKS5 username/password auth (if your proxy requires it) must be provided via the JVM’s java.net.Authenticator (global). [1] [2]
JVM example (works with engines that use java.net.Proxy, e.g. OkHttp)
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.engine.*
import java.net.Authenticator
import java.net.PasswordAuthentication
val proxyHost = "proxy.example.com"
val proxyPort = 1080
val proxyUser = "user"
val proxyPass = "pass"
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? =
if (requestingHost.equals(proxyHost, ignoreCase = true))
PasswordAuthentication(proxyUser, proxyPass.toCharArray())
else null
})
val client = HttpClient(OkHttp) {
engine {
proxy = ProxyBuilder.socks(proxyHost, proxyPort)
}
}This pattern (set a Proxy(Type.SOCKS, …) + Authenticator) is the typical way to do SOCKS5 auth on JVM. [3]
Note: Authenticator.setDefault(...) is process-wide, not per-client/per-request. [3]
Sources:
[1] Ktor API: ProxyBuilder.socks(host, port) (no credentials) (api.ktor.io)
[2] Ktor 3.2.3 JVM implementation: SOCKS proxy is java.net.Proxy(Type.SOCKS, InetSocketAddress(...)) (raw.githubusercontent.com)
[3] Example using SOCKS Proxy + Authenticator (same approach) (git.inmo.dev)
Citations:
- 1: https://api.ktor.io/3.2.x/ktor-client-core/io.ktor.client.engine/-proxy-builder/index.html
- 2: https://raw.githubusercontent.com/ktorio/ktor/3.2.3/ktor-client/ktor-client-core/jvm/src/io/ktor/client/engine/ProxyConfigJvm.kt
- 3: https://git.inmo.dev/InsanusMokrassar/docs/src/commit/e2c111c94f23da040e698e8c493f814b4cb62029/docs/tgbotapi/introduction/proxy-setup.md?utm_source=openai
Proxy credentials are ignored on JVM with CIO engine.
ProxyConfig supports username and password, but the JVM CIO proxy setup does not use them. Unlike the Android implementation (which handles credentials via proxyAuthenticator), the JVM code silently ignores these fields. Authenticated HTTP/SOCKS proxies will fail.
Ktor's ProxyBuilder.http() and ProxyBuilder.socks() do not support per-request proxy credentials. To fix this, either:
- Document this as a known limitation and reject authenticated proxies with a clear error, or
- Use
java.net.Authenticator.setDefault()(process-wide) to handle proxy auth (note: this affects the entire JVM process), or - Consider switching to an HTTP engine with better proxy auth support.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@core/data/src/jvmMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.jvm.kt`
around lines 9 - 20, The JVM CIO proxy setup in createPlatformHttpClient ignores
ProxyConfig.username/password; fix by detecting when proxyConfig?.username or
.password is present and install a process-wide java.net.Authenticator via
Authenticator.setDefault that returns a PasswordAuthentication using those
credentials for requests to the proxy host/port; keep the existing
ProxyBuilder.http(...) and ProxyBuilder.socks(...) usage for the engine, and
ensure the Authenticator references proxyConfig.host and proxyConfig.port so
authenticated HTTP/SOCKS proxies work across the JVM process (or alternatively,
if you prefer not to modify global state, throw/return an error when credentials
are provided to ProxyConfig to document the limitation).
This commit introduces support for HTTP and SOCKS proxies in the application's network layer. It allows the
HttpClientto be dynamically reconfigured at runtime when proxy settings change.The implementation creates a
GitHubClientProviderthat observes aProxyConfigflow. When the proxy configuration is updated, it closes the existing KtorHttpClientand creates a new one with the updated proxy settings. Platform-specific Ktor engines are used for proxy implementation:OkHttpon Android andCIOon JVM (desktop).ProxyConfigdata class to model proxy settings.ProxyManagerto hold and update the global proxy configuration.GitHubClientProviderto manage the lifecycle ofHttpClientand recreate it when proxy settings change.HttpClientFactoryimplementations for Android (OkHttp) and JVM (CIO) to handle proxy configuration.ktor-client-okhttpfor Android andktor-client-ciofor JVM.Summary by CodeRabbit
Release Notes