Transport Client and Authentication Interceptors in gRPC Swift v2

I'm migrating an unreleased iOS app from gRPC Swift v1 to v2 and looking for advice.

Context:

My app communicates with a gRPC server providing two logical services:

  • API Service for general operations (most methods require authentication).
  • Auth Service specifically for authentication.

These services run on the same gRPC server; the separation is purely logical and not technical. It could be better to think of them as one service for the purpose of this post.

For the transport layer, I'm using HTTP2ClientTransport.TransportServices from grpc-swift-nio-transport / GRPCNIOTransportHTTP2TransportServices, as this seems the recommended choice for iOS applications.

My Current Approach:

My understanding is that the idiomatic gRPC way is to create a single, long-lived client for performance and resource optimization. Based on this, my current implementation is:

  • Create one transport client shared by both the API and Auth clients.
  • Implement an authentication interceptor, which prompts the user for authentication when necessary.

However, this introduces complexity:

  • The interceptor must be created when initializing the transport client.
  • The interceptor needs access to the Auth client (which itself wraps the same transport client).
  • To solve this circular dependency, I'm currently using an optional variable for the Auth client inside the interceptor, which I set after initialization.

My Concern

I'm worried that this pattern (setting an optional variable after initialization) might be non-idiomatic / complex to understand.

Is my approach of creating a single transport client and managing authentication via an interceptor (with a post-initialization reference to the Auth client) acceptable, idiomatic, or is there a better design pattern recommended?

For a better understanding there is some example code for illustration (may not compile, writing this for the post).

class MyClient {

    // ...

    init() throws {
        // Interceptors
        let authenticationInterceptor = AuthenticationInterceptor()

        // gRPC "transport" client
        gRPCTransportClient = GRPCClient(
            transport: try .http2NIOTS(
                target: .dns(host: host, port: port),
                transportSecurity: .tls
            ),
            interceptorPipeline: [
                .apply(authenticationInterceptor, to: .services([Grpc_Api.descriptor]))
            ]
        )

        Task { [gRPCTransportClient] in
            try await gRPCTransportClient.runConnections()
        }

        // gRPC clients
        apiClient = Grpc_Api.Client(wrapping: gRPCTransportClient)
        authClient = Grpc_Auth.Client(wrapping: gRPCTransportClient)

        authenticationInterceptor.setAuthClient(authClient: authClient)
    }
}

class AuthenticationInterceptor: ClientInterceptor, @unchecked Sendable {

    private var authClient: Grpc_Auth.Client<HTTP2ClientTransport.TransportServices>?

    private let publicMethods: [GRPCCore.MethodDescriptor] = [
        Grpc_Api.Method.PublicMethod.descriptor
    ]

    init() {}

    func intercept<Input: Sendable, Output: Sendable>(
        request: StreamingClientRequest<Input>,
        context: ClientContext,
        next: (StreamingClientRequest<Input>, ClientContext) async throws ->
            StreamingClientResponse<Output>
    ) async throws -> StreamingClientResponse<Output> where Input: Sendable, Output: Sendable {
        var request = request

        if let authClient, !publicMethods.contains(context.descriptor) {
            // ... UI + gRPC call to get credentials and add metadata to the request
        }

        return try await next(request, context)
    }

    /// This is unsafe, be sure to only call this method in `MyClient.init`.
    fileprivate func setAuthClient(
        authClient: Grpc_Auth.Client<HTTP2ClientTransport.TransportServices>
    ) {
        self.authClient = authClient
    }
}

I'd appreciate any insights or experiences from the community!

Your current approach is fine. A few things to be aware of:

  • You've introduced a retain cycle between the client and the auth interceptor. That's fine but you need to explicitly break that cycle when the transport shuts down (e.g. after runConnections() returns) or hold it weakly..
  • You auth interceptor isn't thread safe because of the mutable authClient. Wrapping it in Mutex will be fine and allow you to remove the @unchecked from the Sendable.
1 Like

Hi George,

Thanks a lot for all your amazing work on gRPC Swift and related libraries, it's extremely helpful! Very cool to have async interceptors in v2 :)

Regarding the authClient reference, I actually didn't mark it as weak because I can't, the generated Grpc_Auth.Client type is a struct, which understandably gives the error: "'weak' may only be applied to class and class-bound protocol types, not 'Grpc_Auth.Client<HTTP2ClientTransport.TransportServices>'"

As you've mentioned, I'll indeed need to explicitly break the retain cycle, since the Grpc_Auth.Client struct holds a strong reference to the underlying gRPC transport class. My current plan is to keep a reference to the interceptor instance in my high level client class and explicitly break this reference in deinit (I've already tested this successfully). If you have any alternative suggestions or recommendations specifically for this tricky situation, I'd be interested to hear them.

And yes, I'll probably follow your advice to use a Mutex for thread safety. Although, in my current usage, the interceptor’s authClient is only set once during initialization and removed when the higher-level client is deinitialized, meaning a data race shouldn't currently be possible if I'm right. I agree that using a Mutex is safer and more future-proof. Additionally, not using a Mutex forces me to mark the whole class as @unchecked Sendable, which opens the door to introduce unintended unsafe behaviors.

Thanks again!

You're welcome!

Ah yes, good point. Breaking the retain cycle explicitly is my general recommendation for this type of situation anyway.

Rather than hold a reference to the interceptor I would tie its lifetime to the lifetime of the connection by calling nil-ing it out after runConnections() returns/throws:

Task { [gRPCTransportClient] in
    defer { 
        authInterceptor.setAuthClient(authClient: nil) 
    }
    try await gRPCTransportClient.runConnections()
    
}

This is quite a natural place to do it as runConnections() will return when the client has shutdown, you then only need to worry about shutting it down at the appropriate time.

This is the important bit! It's very easy to accidentally introduce a regression because of @unchecked.

1 Like

Love that! Thanks!