In client, when using `Auth` plugin + creating a s...
# ktor
u
In client, when using
Auth
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)
When looking at the two ktor instances in debugger, both have the same
io.ktor.client.plugins.auth.Auth$Plugin@f77797a
instance, .. so idk, something fishy is going on
s
Can you synchronize those yourself perhaps? Your refresh token lambda can acquire a lock before it does anything.
u
I can but this to me is a major bug. Why share the instance when mutex is not shared - and there already is synchronization going on when using the same ktor instance
s
So you're doing manual synchronization there as well right? If you do
synchronize(this)
u
no no, I'm coming from okhttp, there I create such shallow copies via
okHttpClient.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 well
s
I am sorry if I am misunderstanding you somehow, but you say that you are doing
synchronized(this)
yourself in OkHttp right? How would it be different then to also use a lock inside your
refreshTokens
lambda?
a
I've commented on https://youtrack.jetbrains.com/issue/KTOR-7159. The copy made by the
HttpClient.config
call is effectively deep, not shallow.
u
I am sorry if I am misunderstanding you somehow, but you say that you are doing
synchronized(this)
yourself in OkHttp right?
How would it be different then to also use a lock inside your
refreshTokens
lambda?
Its not, but ktor already does the locking for me
No sure if I follow how it is a deep copy. It just
+
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?
a
The idea is that the created copy should be independent of the original client but with an overridden configuration. All the plugins are reinstalled every time the
HttpClient.config
method is called.
u
Sorry for the delay, hope you read this I see now, although I kind of feel this is a coincidence, since the
config
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?
image.png
btw now that I think this a bit throught, own
Mutex
doesn't help at all.
Copy code
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. 😕
s
Do a check inside the withLock so that if you just refreshed anyway you can eagerly get the recently refreshed token instead of refreshing again.
u
check what exactly?
s
If you just refreshed. When you refresh your tokens do you not get an expiration date for them? Inside the lock, before you refresh, get the stored tokens, see when their expiration date is or when they were fetched last or smth, and if it's fresh it means you just refreshed it before.
u
oh, no expiration date, just access+refresh token hashes
s
Alright, but you can store information about when you last refreshed right? If the last refreshed 2 seconds ago, you can skip refreshing again. Or smth like that
u
guess I could other than this, isn't there something more elegant? is there no way to create actual shallow copies?
if we leave
.config { .. }
to be the deep-ish copy, would adding such new api be feasible? okhttp & retrofit support this
btw2 "what is" a plugin actually? since the thing both client intances`userConfig` s point to is the same instance (the screenshot with debugger) yet Auth has the issue of separate mutexe etc etc
Copy code
private val plugins: MutableMap<AttributeKey<*>, (HttpClient) -> Unit> = mutableMapOf()
so plugin is just a
(HttpClient) -> Unit
lambda? i.e.
Copy code
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 ?
a
Can you please describe your use case as to why you need to share the
Auth
plugin?
u
I'm coming from Okhttp+Retrofit setup, now trying to migrate to ktor due to kmp. In my the okhttp+retrofit setup I have a
baseOkHttpClient
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 necessary
a
To solve the
Auth
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:
Copy code
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()
u
Hi, this works, neat, although may ask why it works? when I
.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 case
it doesn't make sense to me as why does it work
a
That's because, in your original solution, two auth providers were created instead. You can check that by stepping into a breakpoint here.