Idempotency with the apollo client

Hi,

I am looking for a way of making idempotent mutation requests with the apollo client. Normally for this, we can pass a header on corresponding mutation requests and manage a unique id on the client itself manually. But is there any smart mechanism in the kotlin apollo client which is able to handle an id or sth for idempotency? Which can be enabled f.e. on the apollo client creation?

Thanks in regards for the help with this.

Hi! :wave:

Apollo Kotlin doesn’t have any specific built-in feature related to idempotency currently, but you should be able to use an interceptor (either OkHttp’s, or Apollo’s HttpInterceptor, or ApolloInterceptor, depending on the specifics of your idempotency mechanism).

We’d be curious to know more details about your implementation or the server you use, if you can tell us more that would be appreciated!

2 Likes

Hi Benoit,

thanks a lot for the response. Oh so there is a idempotency mechanism of the interceptors which we can use? Can you please share more information on how I can use idempotency mechanism with the interceptors?

The desired functionality is, to identify a fired request which may get already processed by the backend and the response may not reach the client, f.e. because of a network interruption. This is especially important for mutations, where there can be sensitive processes behind them, where the backend may want to block a repetition and it is able to do that by identifying a duplicate request.

The “manual” (and annoying) solution for the client is, to work with an ID passed in a header and manage it manually when a response is received (f.e. reset or regenerate it).

Maybe this is sth. for an apollo feature request? :slight_smile: This is probably sth. which is needed by a lot of people…

But I am totally happy if this can be done with the interceptors. Every solution is better than handling ids in the client for every mutation.

Thank you for giving more context.

Well, to clarify, you may be able to implement some automated mechanism for this, using an interceptor. Interceptors are a way to have your own ad-hoc code called every time a request is sent by the Apollo client, with the ability to change it (for instance adding headers) - but that depends on the specifics of your backend, that’s why I was asking for more details :blush:. Are you already using Apollo Kotlin currently? If yes how are you handling it at the moment?

Thank you for the response.

Our backend just wants us to follow the usual idempotency principle. By having such an “request id” we pass on every request in the header as a correlation id. And if we redo that request because of a timeout f.e. (if we didn’t receive any response) we should redo it with a same id. For a client that means that it needs to manage this id and reset it properly f.e. when we have a response from the backend.

We are using the apollo-kotlin atm. I cannot post the GitHub link of the corresponding apollo library, my answer gets rejected automatically when trying this.

ATM we do not have any idempotency handling implemented.

… you may be able to implement some automated mechanism for this, using an interceptor. Interceptors are a way to have your own ad-hoc code called every time a request is sent by the Apollo client, with the ability to change it (for instance adding headers)

That would be cool if we can implement such automated mechanism with interceptor. That may save us from manually handling such an correlation id.

Can you please share more insights on how this can be implemented with such an interceptor?

The difficult part is the correlation id should be associated to a certain user action, and that usually can only be done at the UI level.

I can imagine something like this:

  1. a class that manages the id, which you can set on a Call
  2. an Interceptor that looks for it and resets it if a request is successful (keeps the same id if not)
class Idempotency : ExecutionContext.Element {
    var id: String = newUniqueId()
        private set

    fun resetId() {
        id = newUniqueId()
    }

    private fun newUniqueId(): String { ... }

    override val key: ExecutionContext.Key<*>
        get() = Key

    companion object Key : ExecutionContext.Key<Idempotency>
}

class IdempotencyInterceptor : ApolloInterceptor {
    override fun <D : Operation.Data> intercept(
        request: ApolloRequest<D>,
        chain: ApolloInterceptorChain,
    ): Flow<ApolloResponse<D>> {
        val idempotency = request.executionContext[Idempotency] ?: return chain.proceed(request)
        return flow {
            val requestWithHeader = request.newBuilder().addHttpHeader("idempotency-id", idempotency.id).build()
            val response = chain.proceed(requestWithHeader).single()
            if (response.errors == null || response.errors?.isEmpty() == true) {
                // There was no GraphQL error: reset idempotency id
                idempotency.resetId()
            }
            emit(response)
        }
    }
}

Exemple usage:

// Can be set once for the whole screen, or when the user performs a specific action: that depends on your use-case
val idempotency = Idempotency()

// ...

    val response = apolloClient.mutation(MyMutation())
        .addExecutionContext(idempotency)
        .execute()

    // At this point if an error occurred, the id will be kept the same - if no errors, it will be a new id

Warning: I haven’t tested this :sweat_smile:. But that’s the general idea.

1 Like

Wow cool, thank you so much Benoit, I will try it and let you know here how it goes.

Definitely, don’t hesitate, curious to know how that goes!

1 Like

Thanks again for your help dear Benoit. We discuss this idempotency topic a bit more and ask our selfs what we really expect from idempotency.

We expect an “standardized” handling. In case of (specific) backend error or in case of an ApolloException (f.e. because of a network error) we decide to retry the request a max number of times.

Therefore we decided to change the ApolloInterceptor you proposed a bit. But your interceptor was a really good base structure which helps us. For this approach we could also drop the ExecutionContext, because what we wanted works without it.

In the end our interceptor with the retry looks like this:

internal class RetryInterceptor : ApolloInterceptor {

    override fun <D : Operation.Data> intercept(
        request: ApolloRequest<D>,
        chain: ApolloInterceptorChain,
    ): Flow<ApolloResponse<D>> =
        flow {
            // In case we:
            // - have no response for whatever reasons
            // - the response is an error
            // -> we retry the request 3x times
            attemptRetry(request, chain, 0)
        }.retryWhen { cause, attempt ->
            // In case our request fails with an ApolloException f.e. exception is thrown by our apollo client because no network connection
            // Retry request max 3 times
            // We need to differentiate between "we retry request on receiving an backend error" and "we retry request because of an (internal) network error"
            cause is ApolloException && attempt < MAX_RETRY_ATTEMPTS
        }

    /** Method for attempt retries for a [ApolloRequest] object depending on the [ApolloResponse] retrieved until [MAX_RETRY_ATTEMPTS] */
    private suspend fun <D : Operation.Data> FlowCollector<ApolloResponse<D>>.attemptRetry(
        request: ApolloRequest<D>,
        chain: ApolloInterceptorChain,
        attempt: Int,
    ) {
        val response = chain.proceed(request).single()
        if (response == null || response.errors != null && response.errors?.isEmpty() == false && attempt < MAX_RETRY_ATTEMPTS) {
            attemptRetry(request, chain, attempt + 1)
        } else {
            emit(response)
        }
    }

    /** Contains static value for the max retries */
    private companion object {
        private const val MAX_RETRY_ATTEMPTS = 2
    }
}

And it fulfills the initial criteria of having the same correlation id header. Because when redo the same request in such an interceptor class everything stays the same, including the headers.

So again thank you very much for the help and support with this :slightly_smiling_face::raised_hands:.

1 Like

Hi again! That looks good :slight_smile:. Happy to help!

1 Like

Yes, I agree with you. I don’t have sufficient information before that. But you have clear everything. Thanks