ursus
06/27/2024, 1:31 PMAuth
plugin + creating a shallow copy via val ktorWithAuthAndMoreStuff = ktorWithAuth.config { .. }
(where the ktorWithAuth has the auth plugin installed),
it doesn't seem to share the plugin instance, i.e. when I run concurrent requests via the two instances (ktorWithAuthAndMoreStuff + ktorWithAuth) and both get 401, I see 2 refreshes going on (refreshTokens
lambdas)!
This is a deal breaker for me, as I create bunch of shallow copies as to append functionality.
This must be a bug, or is this desired? Is using .config { .. }
not the way to create shallow copies?
When I use the same ( ktorWithAuth
) instance in the same concurrent manner, I see all 401ed request being blocked and awaiting the single refresh and then retried (i.e. everything as expected)ursus
06/27/2024, 1:39 PMio.ktor.client.plugins.auth.Auth$Plugin@f77797a
instance, .. so idk, something fishy is going onStylianos Gakis
06/27/2024, 2:56 PMursus
06/27/2024, 2:56 PMStylianos Gakis
06/27/2024, 2:59 PMsynchronize(this)
ursus
06/27/2024, 3:05 PMokHttpClient.newBuilder()
if I attach a interceptor there, the same instance gets shared, therefore I can do synchronized(this)
. Okhttp doesn't provide such detailed implementation of bearer auth, it just basically gives you interface Authenticator.authenticate()
and you have to implement it yourself - meaning the synchronization as well
Now, I'm moving to ktor and ktor has the implementation ready for me (bearer { .. }
which is great), so I'd expect to not do manual synchronization at all - which IS the case when using the same ktor instance (no synchronization needed on my part)
TLDR; when creating shallow copies, the Auth plugin instance is shared - therefore the mutex it holds should be as wellStylianos Gakis
06/27/2024, 5:39 PMsynchronized(this)
yourself in OkHttp right?
How would it be different then to also use a lock inside your refreshTokens
lambda?Aleksei Tirman [JB]
06/27/2024, 6:17 PMHttpClient.config
call is effectively deep, not shallow.ursus
06/27/2024, 6:37 PMI am sorry if I am misunderstanding you somehow, but you say that you are doingyourself in OkHttp right?synchronized(this)
How would it be different then to also use a lock inside yourlambda?refreshTokens
Its not, but ktor already does the locking for me
ursus
06/27/2024, 8:25 PM+
es the plugins & other config, making it a shallow copy. When I set a break point I see both client instances reference the same plugin instance
Am I missing something?Aleksei Tirman [JB]
06/28/2024, 7:32 AMHttpClient.config
method is called.ursus
06/30/2024, 7:31 PMconfig
points to same instances
Is there a particular reason as why this deep-ish copying is the default? If one wants to simply "share" configuration, they can always share the config instance (and there is bunch of your apis to make that easier .clone(), +=
.
Shallow copying would allow to share resources and promote creating "appending" client instances with specialized behavior per API
Now I cannot do basically anything. Sure I can have a my own Mutex, but that is not expected/error prone coming from OkHttp & I need to keep track of a whitelist of instances which will hold auth (doesn't scale)
Is there a way to mimic okhttp's behavior I'm desiring?ursus
06/30/2024, 7:32 PMursus
06/30/2024, 7:39 PMMutex
doesn't help at all.
refreshTokens {
mutex.withLock {
val tokens = authApiClient.refreshTokens()
tokens.toBearerTokens()
}
}
if I have say 2 authed ktor clients, then this will be ran twice, serialized, sure, but will run twice nonetheless, causing 2 refreshes
Unless I peek into the "parent" ktor client instance and pull out it's auth token and return that instead of calling refresh endpoint
Which is actually a bad idea, as parent instance might not be in the refresh serialization "queue" atd all. Won't work.
😕Stylianos Gakis
06/30/2024, 8:04 PMursus
06/30/2024, 8:04 PMStylianos Gakis
06/30/2024, 10:47 PMursus
06/30/2024, 10:48 PMStylianos Gakis
06/30/2024, 10:49 PMursus
06/30/2024, 10:50 PMursus
06/30/2024, 10:53 PM.config { .. }
to be the deep-ish copy, would adding such new api be feasible? okhttp & retrofit support thisursus
06/30/2024, 10:56 PMprivate val plugins: MutableMap<AttributeKey<*>, (HttpClient) -> Unit> = mutableMapOf()
so plugin is just a (HttpClient) -> Unit
lambda? i.e.
plugins[plugin.key] = { scope ->
val attributes = scope.attributes.computeIfAbsent(PLUGIN_INSTALLED_LIST) { Attributes(concurrent = true) }
val config = scope.config.pluginConfigurations[plugin.key]!!
val pluginData = plugin.prepare(config)
plugin.install(pluginData, scope)
attributes.put(plugin.key, pluginData)
}
this bit ?Aleksei Tirman [JB]
07/01/2024, 11:20 AMAuth
plugin?ursus
07/01/2024, 8:35 PMbaseOkHttpClient
which contains interceptors that are supposed to be attached to every request, say user agent.
val baseOkHttpClient = OkHttpClient.builder().addInterceptor(UserAgentInterceptor()).build()
Then I create authedOkHttpClient
as a shallow copy of the baseOkHttpClient
via val authedOkHttpClient = baseOkHttpClient.newBuilder(MyAuthenticator()).build()
Request launched via the autherOkHttpClient
instance will have both user agent + auth.
However, in our project we create few more of okhttpclient instances, each appending new behavior, specific to a new set of APIs.
Our app's domain let's say looks like Slack, i.e. multiple workspaces at the same time.
Each workspace api has workspaceId
in the path (/workspace/$workspaceId/...
) lets say this is 30 apis.
Instead of parametrizing every such request with $workspaceId
we create WorkspaceInterceptor
which is then added to workspaceOkHttpClient = authedOkHttpClient.newBuilder().addInterceptor(WorkspaceInterceptor(workspaceId).build()
. This means you can inject ``workspaceOkHttpClient`
into a feature and not weight down feature developer with creating the path correctly.
and theres bunch more, but essentially all are like this, creating a http client such that it inherits parent's behavior + appends its own.
Most of these are just interceptors, meaning stateless, so even deep copy would wokr, but auth is obviously not.
Also, there is only one "user" identity going on in the app, meaning single access+refresh tokens (so not a multi user app), meaning the Auth handling instance bit needs to be shared between all the shallow copies (that inherit from ``autherOkHttpClient``)
TLDR; ``workspaceOkHttpClient `` and the like expect to have auth implicitly on them since they "inherit" from authedOkHttpClient
Also, creating shallow copies like this allows for sharing of resources - thread pools. connections etc in okhttp- so each new okhttp client (created via .newBuilder()
) is essentially free - not sure if this is still relevant in ktor (I'd assume it uses IO
dispatcher, right which is shared anyways right?)
This is not necessarily a auth issue, but auth "interceptor" is the only stateful thing I can think of we're using. If there were more stateful "interceptors", they'd desire the same behavior.
Please let me know if I was clear enough, I can go into more details if necessaryAleksei Tirman [JB]
07/02/2024, 7:35 AMAuth
plugin-specific problem, you can instantiate a BearerAuthProvider
separately to later add it to the list of the providers for both of your clients. Here is an example:
val authProvider = BearerAuthProvider(
loadTokens = { BearerTokens("", "") },
refreshTokens = { println("refresh..."); BearerTokens("a", "b") },
realm = null
)
val originalClient = HttpClient(CIO) {
// Config
}
val client1 = originalClient.config {
install(Auth) {
providers.add(authProvider)
}
// ...
}
val client2 = originalClient.config {
install(Auth) {
providers.add(authProvider)
}
// ...
}
listOf(
async { client1.get("<https://httpbin.org/bearer>") },
async { client2.get("<https://httpbin.org/bearer>") }
).awaitAll()
ursus
07/02/2024, 11:08 PM.config { .. }
on the parent authed instance. aka accountKtorClient
, and set a breakpoint (before) adding the shared provider instance (bearerAuthProvider: BearerAuthProvider
), it's already in the providers
list.
Note: my setup is tiny bit different from yours, my accountKtorClient
(your originalClient equivalent) has the Auth and then subscriberKtorClient
(client1 equivalent) inherits it, and there's no client2 as in your caseursus
07/02/2024, 11:12 PMAleksei Tirman [JB]
07/03/2024, 8:53 AM