On this page

Qt GRPC Client Guide

The Qt GRPC client guide.

Service Methods

In gRPC, service methods can be defined in a protobuf schema to specify the communication between clients and servers. The protobuf compiler, protoc, can then generate the required server and client interfaces based on these definitions. gRPC supports four types of service methods:

  • Unary Calls — The client sends a single request and receives a single response.
    rpc UnaryCall (Request) returns (Response);

    The corresponding client handler is QGrpcCallReply.

  • Server Streaming — The client sends a single request and receives multiple responses.
    rpc ServerStreaming (Request) returns (stream Response);

    The corresponding client handler is QGrpcServerStream.

  • Client Streaming — The client sends multiple requests and receives a single response.
    rpc ClientStreaming (stream Request) returns (Response);

    The corresponding client handler is QGrpcClientStream.

  • Bidirectional Streaming — The client and server exchange multiple messages.
    rpc BidirectionalStreaming (stream Request) returns (stream Response);

    The corresponding client handler is QGrpcBidiStream.

gRPC communication always starts with the client, which initiates the remote procedure call (RPC) by sending the first message to the server. The server then concludes any type of communication by returning a StatusCode.

All client RPC handlers are derived from the QGrpcOperation class, which provides shared functionality. Due to the asynchronous nature of RPCs, they are naturally managed through Qt's Signals & Slots mechanism.

A key signal common to all RPC handlers is finished, which indicates the completion of an RPC. The handler emits this signal exactly once during its lifetime. This signal delivers the corresponding QGrpcStatus, providing additional information about the success or failure of the RPC.

There are also operation-specific functionalities, such as messageReceived for incoming messages, writeMessage for sending messages to the server, and writesDone for closing client-side communication. The table below outlines the supported functionality of the RPC client handlers:

FunctionalityQGrpcCallReplyQGrpcServerStreamQGrpcClientStreamQGrpcBidiStream
finished✓ (read final response)✓ (read final response)
messageReceived
writeMessage
writesDone

Getting Started

To use the Qt GRPC C++ API, start by using an already available protobuf schema or define your own. We will use the clientguide.proto file as an example:

syntax = "proto3";
package client.guide; // enclosing namespace

message Request {
    int64 time = 1;
    sint32 num = 2;
}

message Response {
    int64 time = 1;
    sint32 num = 2;
}

service ClientGuideService {
    rpc UnaryCall (Request) returns (Response);
    rpc ServerStreaming (Request) returns (stream Response);
    rpc ClientStreaming (stream Request) returns (Response);
    rpc BidirectionalStreaming (stream Request) returns (stream Response);
}

To use this .proto file for our Qt GRPC client in C++, we must run the protoc compiler with the Qt generator plugins on it. Fortunately, Qt provides the qt_add_grpc and qt_add_protobuf CMake functions to streamline this process.

set(proto_files "${CMAKE_CURRENT_LIST_DIR}/../proto/clientguide.proto")

find_package(Qt6 COMPONENTS Protobuf Grpc)
qt_standard_project_setup(REQUIRES 6.9)

qt_add_executable(clientguide_client main.cpp interceptors.cpp)

# Using the executable as input target will append the generated files to it.
qt_add_protobuf(clientguide_client
    PROTO_FILES ${proto_files}
)
qt_add_grpc(clientguide_client CLIENT
    PROTO_FILES ${proto_files}
)

target_link_libraries(clientguide_client PRIVATE Qt6::Protobuf Qt6::Grpc)

This results in two header files being generated in the current build directory:

  • clientguide.qpb.h: Generated by qtprotobufgen. Declares the Request and Response protobuf messages from the schema.
  • clientguide_client.grpc.qpb.h: Generated by qtgrpcgen. Declares the client interface for calling the methods of a gRPC server implementing the ClientGuideService from the schema.

The following client interface is generated:

namespace client::guide {
namespace ClientGuideService {

class Client : public QGrpcClientBase
{
    ...
    std::unique_ptr<QGrpcCallReply> UnaryCall(const client::guide::Request &arg);
    std::unique_ptr<QGrpcServerStream> ServerStreaming(const client::guide::Request &arg);
    std::unique_ptr<QGrpcClientStream> ClientStreaming(const client::guide::Request &arg);
    std::unique_ptr<QGrpcBidiStream> BidirectionalStreaming(const client::guide::Request &arg);
    ...
};

} // namespace ClientGuideService
} // namespace client::guide

Note: Users are responsible for managing the unique RPC handlers returned by the Client interface, ensuring their existence at least until the finished signal is emitted. After receiving this signal, the handler can be safely reassigned or destroyed.

Server Setup

The server implementation for the ClientGuideService follows a straightforward approach. It validates the request message's time field, returning the INVALID_ARGUMENT status code if the time is in the future:

const auto time = now();
if (request->time() > time)
    return { grpc::StatusCode::INVALID_ARGUMENT, "Request time is in the future!" };

Additionally, the server sets the current time in every response message:

response->set_num(request->num());
response->set_time(time);
return grpc::Status::OK;

For valid time requests, the service methods behave as follows:

  • UnaryCall: Responds with the num field from the request.
  • ServerStreaming: Sends num responses matching the request message.
  • ClientStreaming: Counts the number of request messages and sets this count as num.
  • BidirectionalStreaming: Immediately responds with the num field from each incoming request message.
Client Setup

We begin by including the generated header files:

#include "clientguide.qpb.h"
#include "clientguide_client.grpc.qpb.h"

For this example, we create the ClientGuide class to manage all communication, making it easier to follow. We begin by setting up the backbone of all gRPC communication: a channel.

channel = std::make_shared<QGrpcHttp2Channel>(
    QUrl("http://localhost:50056")
    /* without channel options. */
);
ClientGuide clientGuide(channel);

The Qt GRPC library offers QGrpcHttp2Channel, which you can attach to the generated client interface:

explicit ClientGuide(std::shared_ptr<QAbstractGrpcChannel> channel)
{
    m_client.attachChannel(std::move(channel));
}

With this setup, the client will communicate over HTTP/2 using TCP as the transport protocol. The communication will be unencrypted (i.e. without SSL/TLS setup).

Creating a request message

Here's a simple wrapper to create request messages:

static guide::Request createRequest(int32_t num,
                                    ExpectedResult expected = ExpectedResult::Success)
{
    guide::Request request;
    request.setNum(num);
    // The server-side logic fails the RPC if the time is in the future.
    const auto time = expected == ExpectedResult::Failure ?
        std::numeric_limits<int64_t>::max() : now();
    request.setTime(time);
    return request;
}

This function takes an integer and an optional boolean. By default its messages use the current time, so the server logic should accept them. When called with fail set to true, however, it produces messages that the server shall reject.

Single Shot RPCs

There are different paradigms for working with RPC client handlers. Specifically, you can choose a class-based design where the RPC handler is a member of the enclosing class, or you can manage the lifetime of the RPC handler through the finished signal.

There are two important things to remember when applying the single-shot paradigm. The code below demonstrates how it would work for unary calls, but it's the same for any other RPC type.

std::unique_ptr<QGrpcCallReply> reply = m_client.UnaryCall(requestMessage);
const auto *replyPtr = reply.get(); // 1
QObject::connect(
    replyPtr, &QGrpcCallReply::finished, replyPtr,
    [reply = std::move(reply)](const QGrpcStatus &status) {
        ...
    },
    Qt::SingleShotConnection        // 2
);
  • 1: Since we manage the lifetime of the unique RPC object within the lambda, moving it into the lambda's capture would invalidate get() and other member functions. Therefore, we must copy the pointers address before moving it.
  • 2: The finished signal is emitted only once, making this a true single-shot connection. It is important to mark this connection as SingleShotConnection! If not, the capture of reply will not be destroyed, leading to a hidden memory leak that is hard to discover.

The SingleShotConnection argument in the connect call ensures that the slot functor (the lambda) is destroyed after being emitted, freeing the resources associated with the slot, including its captures.

Remote Procedure Calls

Unary Calls

Unary calls require only the finished signal to be handled. When this signal is emitted, we can check the status of the RPC to determine if it was successful. If it was, we can read the single and final response from the server.

In this example, we use the single-shot paradigm. Ensure you carefully read the Single Shot RPCs section.

void unaryCall(const guide::Request &request, const QGrpcCallOptions &opts = { })
{
    std::unique_ptr<QGrpcCallReply> reply = m_client.UnaryCall(request, opts);
    const auto *replyPtr = reply.get();
    connect(
        replyPtr, &QGrpcCallReply::finished, replyPtr,
        [reply = std::move(reply)](const QGrpcStatus &status) {
            if (status.isOk()) {
                if (const auto response = reply->read<guide::Response>())
                    qDebug() << "Client (UnaryCall) finished, received:" << *response;
                else
                    qDebug("Client (UnaryCall) deserialization failed");
            } else {
                qDebug() << "Client (UnaryCall) failed:" << status;
            }
        },
        Qt::SingleShotConnection);
}

The function starts the RPC by invoking the UnaryCall member function of the generated client interface m_client. The lifetime is solely managed by the finished signal.

Running the code

In main, we simply invoke this function three times, letting the second invocation fail:

clientGuide.unaryCall(ClientGuide::createRequest(1));
clientGuide.unaryCall(ClientGuide::createRequest(2, ExpectedResult::Failure));
clientGuide.unaryCall(ClientGuide::createRequest(3));

A possible output of running this could look like the following:

Welcome to the clientguide!
Starting the server process ...
    Server listening on: localhost:50056
    Server (UnaryCall): Request( time: 1733498584776, num: 1 )
    Server (UnaryCall): Request( time: 9223372036854775807, num: 2 )
    Server (UnaryCall): Request( time: 1733498584776, num: 3 )
Client (UnaryCall) finished, received: Response( time:  1733498584778257 , num:  1  )
Client (UnaryCall) failed: QGrpcStatus( code: QtGrpc::StatusCode::InvalidArgument, message: "Request time is in the future!" )
Client (UnaryCall) finished, received: Response( time:  1733498584778409 , num:  3  )

We see the server receiving the three messages, with the second containing a large value for its time. On the client side, the first and last calls returned an Ok status code, but the second message failed with the InvalidArgument status code due to the message time being in the future.

Server Streaming

In a server stream, the client sends an initial request, and the server responds with one or more messages. In addition to the finished signal you also have to handle the messageReceived signal.

In this example, we use the single-shot paradigm to manage the streaming RPC lifecycle. Ensure you carefully read the Single Shot RPCs section.

As with any RPC, we connect to the finished signal first:

void serverStreaming(const guide::Request &initialRequest)
{
    std::unique_ptr<QGrpcServerStream> stream = m_client.ServerStreaming(initialRequest);
    const auto *streamPtr = stream.get();

    connect(
        streamPtr, &QGrpcServerStream::finished, streamPtr,
        [stream = std::move(stream)](const QGrpcStatus &status) {
            if (status.isOk())
                qDebug("Client (ServerStreaming) finished");
            else
                qDebug() << "Client (ServerStreaming) failed:" << status;
        },
        Qt::SingleShotConnection);

To handle the server messages, we connect to the messageReceived signal and read the response when the signal is emitted.

    connect(streamPtr, &QGrpcServerStream::messageReceived, streamPtr, [streamPtr] {
        if (const auto response = streamPtr->read<guide::Response>())
            qDebug() << "Client (ServerStream) received:" << *response;
        else
            qDebug("Client (ServerStream) deserialization failed");
    });
}
Running the code

The server logic streams back the amount received in the initial request to the client. We create such a request and invoke the function.

clientGuide.serverStreaming(ClientGuide::createRequest(3));

A possible output of running the server streaming could look like this:

Welcome to the clientguide!
Starting the server process ...
    Server listening on: localhost:50056
    Server (ServerStreaming): Request( time: 1733504435800, num: 3 )
Client (ServerStream) received: Response( time:  1733504435801724 , num:  0  )
Client (ServerStream) received: Response( time:  1733504435801871 , num:  1  )
Client (ServerStream) received: Response( time:  1733504435801913 , num:  2  )
Client (ServerStreaming) finished

Once the server starts, it receives a request with a num value of 3 and responds with three Response messages before completing the communication.

Client Streaming

In a client stream, the client sends one or more requests, and the server responds with a single final response. The finished signal must be handled, and messages can be sent using the writeMessage function. The writesDone function can then be used to indicate that the client has finished writing and that no more messages will be sent.

We use a class-based approach to interact with the streaming RPC, incorporating the handler as a member of the class. As with any RPC, we connect to the finished signal:

void clientStreaming(const guide::Request &initialRequest)
{
    m_clientStream = m_client.ClientStreaming(initialRequest);
    for (int32_t i = 1; i < 3; ++i)
        m_clientStream->writeMessage(createRequest(initialRequest.num() + i));
    m_clientStream->writesDone();

    connect(m_clientStream.get(), &QGrpcClientStream::finished, m_clientStream.get(),
        [this](const QGrpcStatus &status) {
            if (status.isOk()) {
                if (const auto response = m_clientStream->read<guide::Response>()) {
                    qDebug() << "Client (ClientStreaming) finished, received:"
                             << *response;
                }
                m_clientStream.reset();
            } else {
                qDebug() << "Client (ClientStreaming) failed:" << status;
                qDebug("Restarting the client stream");
                clientStreaming(createRequest(0));
            }
        });
}

The function starts the client stream with an initial message. Then it continues to write two additional messages before signaling the end of communication by calling writesDone. If the streaming RPC succeeds, we read the final response from the server and reset the RPC object. If the RPC fails, we retry by invoking the same function, which overwrites the m_clientStream member and reconnects the finished signal. We cannot simply reassign the m_clientStream member within the lambda, as this would lose the necessary connection.

Running the code

In main, we invoke the clientStreaming function with a failing message, triggering an RPC failure and executing the retry logic.

clientGuide.clientStreaming(ClientGuide::createRequest(0, ExpectedResult::Failure));

A possible output of running the client streaming could look like this:

Welcome to the clientguide!
Starting the server process ...
    Server listening on: localhost:50056
    Server (ClientStreaming): Request( time: 9223372036854775807, num: 0 )
Client (ClientStreaming) failed: QGrpcStatus( code: QtGrpc::StatusCode::InvalidArgument, message: "Request time is in the future!" )
Restarting the client stream
    Server (ClientStreaming): Request( time: 1733912946696, num: 0 )
    Server (ClientStreaming): Request( time: 1733912946697, num: 1 )
    Server (ClientStreaming): Request( time: 1733912946697, num: 2 )
Client (ClientStreaming) finished, received: Response( time:  1733912946696922 , num:  3  )

The server receives an initial message that causes the RPC to fail, triggering the retry logic. The retry starts the RPC with a valid message, after which three messages are sent to the server before completing gracefully.

Bidirectional Streaming

Bidirectional streaming offers the most flexibility, allowing both the client and server to send and receive messages simultaneously. It requires the finished and messageReceived signal to be handled and provides the write functionality through writeMessage.

We use a class-based approach with member function slot connections to demonstrate the functionality, incorporating the handler as a member of the class. Additionally, we utilize the pointer-based read function. The two members used are:

std::unique_ptr<QGrpcBidiStream> m_bidiStream;
guide::Response m_bidiResponse;

We create a function to start the bidirectional streaming from an initial message and connect the slot functions to the respective finished and messageReceived signals.

void bidirectionalStreaming(const guide::Request &initialRequest)
{
    m_bidiStream = m_client.BidirectionalStreaming(initialRequest);
    connect(m_bidiStream.get(), &QGrpcBidiStream::finished, this, &ClientGuide::bidiFinished);
    connect(m_bidiStream.get(), &QGrpcBidiStream::messageReceived, this,
            &ClientGuide::bidiMessageReceived);
}

The slot functionality is straightforward. The finished slot simply prints and resets the RPC object:

void bidiFinished(const QGrpcStatus &status)
{
    if (status.isOk())
        qDebug("Client (BidirectionalStreaming) finished");
    else
        qDebug() << "Client (BidirectionalStreaming) failed:" << status;
    m_bidiStream.reset();
}

The messageReceived slot reads into the m_bidiResponse member, continuing to write messages until the received response number hits zero. At that point, we half-close the client-side communication using writesDone.

void bidiMessageReceived()
{
    if (m_bidiStream->read(&m_bidiResponse)) {
        qDebug() << "Client (BidirectionalStreaming) received:" << m_bidiResponse;
        if (m_bidiResponse.num() > 0) {
            m_bidiStream->writeMessage(createRequest(m_bidiResponse.num() - 1));
            return;
        }
    } else {
        qDebug("Client (BidirectionalStreaming) deserialization failed");
    }
    m_bidiStream->writesDone();
}
Running the code

The server logic simply returns a message as soon as it reads something, creating a response with the number from the request. In main, we create such a request, which ultimately serves as a counter.

clientGuide.bidirectionalStreaming(ClientGuide::createRequest(3));

A possible output of running the bidirectional streaming could look like this:

Welcome to the clientguide!
Starting the server process ...
    Server listening on: localhost:50056
    Server (BidirectionalStreaming): Request( time: 1733503832107, num: 3 )
Client (BidirectionalStreaming) received: Response( time:  1733503832108708 , num:  3  )
    Server (BidirectionalStreaming): Request( time: 1733503832109, num: 2 )
Client (BidirectionalStreaming) received: Response( time:  1733503832109024 , num:  2  )
    Server (BidirectionalStreaming): Request( time: 1733503832109, num: 1 )
Client (BidirectionalStreaming) received: Response( time:  1733503832109305 , num:  1  )
    Server (BidirectionalStreaming): Request( time: 1733503832109, num: 0 )
Client (BidirectionalStreaming) received: Response( time:  1733503832109529 , num:  0  )
Client (BidirectionalStreaming) finished

ClientGuide Interceptors

For an introduction to the interception mechanism and how interceptor interfaces participate in the lifecycle of an RPC, see the Qt GRPC Interceptors Overview.

The client guide example demonstrates three interceptors combined in a single chain:

  • A metrics interceptor that records RPC statistics and injects a trace id.
  • An authentication interceptor that attaches a bearer token.
  • A sanitizing interceptor that validates and normalizes messages.

Together they demonstrate how interceptors can observe, modify, and validate RPC traffic at different stages of the call lifecycle.

Metrics Interceptor

The metrics interceptor is placed first in the chain so it can observe the entire RPC lifecycle. It records basic statistics such as received payload sizes, RPC durations, and success or failure counts for each RPC method.

class MetricsInterceptor final : public QGrpcStartInterceptor,
                                 public QGrpcMessageReceivedInterceptor,
                                 public QGrpcFinishedInterceptor
{
public:
    ~MetricsInterceptor() override;

    Continuation onStart(QGrpcInterceptionContext &context,
                         QProtobufMessage &message,
                         QGrpcCallOptions &callOptions) override;

    void onMessageReceived(QGrpcInterceptionContext &context,
                           QByteArray &messageData) override;

    void onFinished(QGrpcInterceptionContext &context,
                    QGrpcStatus &status) override;

private:
    using Clock = std::chrono::steady_clock;
    using Ms = std::chrono::duration<double, std::milli>;

    struct RpcMetrics {
        quint64 bytesReceived = 0;
        quint64 completed = 0;
        quint64 failed = 0;
        QList<double> durations;
    };

    QHash<quint64, Clock::time_point> m_activeRPCs;
    QMap<QtGrpc::RpcDescriptor, RpcMetrics> m_metrics;
};
Metrics Interceptor Implementation

When an RPC starts, the interceptor records the current timestamp and stores it using the unique operationId as a key. A trace identifier is also added to the outgoing metadata so the call can be correlated across services.

QGrpcStartInterceptor::Continuation
MetricsInterceptor::onStart(QGrpcInterceptionContext &context,
                            QProtobufMessage & /*message*/,
                            QGrpcCallOptions &callOptions)
{
    m_activeRPCs.insert(context.operationId(), std::chrono::steady_clock::now());
    callOptions.addMetadata("x-trace-id"_L1, QUuid::createUuid().toByteArray());

    return Continuation::Proceed;
}

void MetricsInterceptor::onMessageReceived(QGrpcInterceptionContext &context,
                                           QByteArray &messageData)
{
    m_metrics[context.descriptor()].bytesReceived += messageData.size();
}

void MetricsInterceptor::onFinished(QGrpcInterceptionContext &context,
                                    QGrpcStatus &status)
{
    const auto it = m_activeRPCs.find(context.operationId());
    Q_ASSERT(it != m_activeRPCs.cend());

    const auto duration = Ms(Clock::now() - it.value()).count();
    m_activeRPCs.erase(it);

    auto &metrics = m_metrics[context.descriptor()];
    if (!status.isOk()) {
        ++metrics.failed;
        return;
    }
    ++metrics.completed;
    metrics.durations.append(duration);
}

While the RPC is active, incoming messages contribute to the collected metrics by accumulating the number of received payload bytes.

When the RPC finishes, the interceptor computes the total duration and updates aggregated statistics per RPC descriptor.

For demonstration purposes, the collected metrics are printed when the interceptor instance is destroyed.

MetricsInterceptor::~MetricsInterceptor()
{
    Q_ASSERT(m_activeRPCs.isEmpty());
    const auto keys = m_metrics.keys();
    for (const auto &k : keys) {
        qInfo() << "Metrics for:" << k;
        const auto &m = m_metrics[k];
        qInfo() << "  Bytes received    :" << m.bytesReceived;
        qInfo() << "  Failed RPCs       :" << m.failed;
        qInfo() << "  Completed RPCs    :" << m.completed;
        qInfo() << "  Completed Durations (ms):" << m.durations;
    }
}
Authentication Interceptor

The authentication interceptor attaches a bearer token to outgoing RPC calls. If the application already provides an authorization header, the interceptor leaves it unchanged.

If the server reports an authentication failure, the interceptor refreshes the token so that subsequent RPC calls use updated credentials.

class TokenProvider
{
public:
    TokenProvider();
    QByteArray token();
    void refresh();

private:
    QByteArray m_token;
};

class AuthInterceptor final : public QGrpcStartInterceptor,
                              public QGrpcTrailingMetadataInterceptor,
                              public QGrpcFinishedInterceptor
{
public:
    Continuation onStart(QGrpcInterceptionContext &context,
                         QProtobufMessage &message,
                         QGrpcCallOptions &callOptions) override;

    void onTrailingMetadata(QGrpcInterceptionContext &context,
                            QMultiHash<QByteArray, QByteArray> &metadata) override;

    void onFinished(QGrpcInterceptionContext &context,
                    QGrpcStatus &status) override;

private:
    TokenProvider m_tokenProvider;
    QByteArray m_serverAuthHint;
};
Auth Interceptor Implementation

The TokenProvider encapsulates token management. In this example, it generates a simplified token string for demonstration purposes only. It does not represent a real authentication token. In real applications, tokens would typically be obtained and refreshed through an external identity system responsible for issuing and managing authentication tokens.

TokenProvider::TokenProvider() { refresh(); }
QByteArray TokenProvider::token() { return m_token; }
void TokenProvider::refresh()
{
    // NOTE: This is NOT a real JWT or secure token.
    // It only mimics the structure: header.payload.signature
    // The payload is just a timestamp used for expiry checks.
    auto header = R"({"alg":"none","typ":"JWT"})"_ba; // fake header
    auto payload = QByteArray::number(now()); // fake "iat"
    auto signature = "no_signature"_ba; // fake signing

    m_token = header + '.' + payload + '.' + signature;
}

On the server side, incoming calls validate the authorization metadata. If validation fails, the server returns an Unauthenticated status and provides additional information in trailing metadata.

if (auto authStatus = validateAuth(context); !authStatus.ok())
    return authStatus;

When an RPC starts, the interceptor checks whether the call already contains an authorization header. If not, it attaches a bearer token to the outgoing metadata.

Trailing metadata is inspected for authentication hints returned by the server. If present, the hint is stored so it can be reported if the call ultimately fails with an authentication error.

When the RPC finishes with an Unauthenticated status, the interceptor refreshes the token. In this example, the refresh simply regenerates a token with a new current time. In real-world scenarios, this would involve requesting a new token from an external identity system.

QGrpcStartInterceptor::Continuation
AuthInterceptor::onStart(QGrpcInterceptionContext & /*context*/,
                         QProtobufMessage & /*messasge*/,
                         QGrpcCallOptions &callOptions)
{
    constexpr QByteArrayView AuthKey("authorization");

    if (!callOptions.metadata(QtGrpc::MultiValue).contains(AuthKey))
        callOptions.addMetadata(AuthKey, "Bearer " + m_tokenProvider.token());

    return Continuation::Proceed;
}

void AuthInterceptor::onTrailingMetadata(QGrpcInterceptionContext & /*context*/,
                                         QMultiHash<QByteArray, QByteArray> &metadata)
{
    constexpr QByteArrayView AuthErrorKey("www-authenticate");
    const auto it = metadata.constFind(AuthErrorKey);
    if (it == metadata.cend())
        return;
    m_serverAuthHint = it.value();
}

void AuthInterceptor::onFinished(QGrpcInterceptionContext & /*context*/, QGrpcStatus &status)
{
    // Only handle Unauthenticated status codes.
    if (status.code() != QtGrpc::StatusCode::Unauthenticated)
        return;

    qDebug("[AuthInterceptor] Refreshing token for next call");
    // NOTE: Simplified for example. Real tokens are obtained via proper auth flow.
    m_tokenProvider.refresh();
    if (!m_serverAuthHint.isEmpty()) {
        status = QGrpcStatus{ status.code(), status.message() + u", Hint: "_s + m_serverAuthHint };
        m_serverAuthHint.clear();
    }
}
Sanitizing Interceptor

The sanitizing interceptor validates and normalizes request and response data. It demonstrates how interceptors can enforce basic client-side constraints before messages are sent and after responses are received.

In this example the interceptor clamps request values to acceptable ranges, normalizes timestamps, and rejects calls whose metadata exceeds a predefined size limit.

class SanitizingInterceptor final : public QGrpcStartInterceptor,
                                    public QGrpcWriteMessageInterceptor,
                                    public QGrpcMessageReceivedInterceptor
{
public:
    Continuation onStart(QGrpcInterceptionContext &context,
                         QProtobufMessage &message,
                         QGrpcCallOptions &callOptions) override;

    void onWriteMessage(QGrpcInterceptionContext &context,
                        QProtobufMessage &message) override;

    void onMessageReceived(QGrpcInterceptionContext &context,
                           QByteArray &messageData) override;
};
Sanitizing Interceptor Implementation

Helper functions are used to calculate the total metadata size and to apply basic normalization rules to request and response messages.

qsizetype getTotalBytes(const QMultiHash<QByteArray, QByteArray> &md)
{
    qsizetype total = 0;
    for (const auto &[k, v] : md.asKeyValueRange())
        total += k.size() + v.size();
    return total;
}

template <typename T>
void sanitizeMessage(T &request)
{
    const auto num = request.num();
    if (num < 0 || num > 5)
        request.setNum(qBound(0, num, 5));

    const auto time = request.time();
    const auto currentTime = now();
    if (time < 0 || time > currentTime)
        request.setTime(qMax(0, currentTime));
}

When an RPC starts, the interceptor first verifies that the outgoing metadata does not exceed the allowed size. If the limit is exceeded, the call is aborted by returning Continuation::Drop

If the request message matches the expected type, its fields are sanitized before the RPC is sent.

For streaming calls, outgoing messages are also sanitized in the onWriteMessage hook.

Finally, incoming responses are intercepted. The serialized payload is deserialized, normalized, and serialized again before it is delivered to the application.

QGrpcStartInterceptor::Continuation
SanitizingInterceptor::onStart(QGrpcInterceptionContext &context, QProtobufMessage &message,
                               QGrpcCallOptions &callOptions)
{
    constexpr quint16 MaxMetadata = 1024 * 8; // 8 KiB
    auto totalMdBytes = getTotalBytes(callOptions.metadata(QtGrpc::MultiValue));
    if (totalMdBytes > MaxMetadata) {
        qDebug() << "[Sanitizing] Aborting call with metadata size of" << totalMdBytes << "bytes";
        return Continuation::Drop;
    }

    onWriteMessage(context, message);

    return Continuation::Proceed;
}

void SanitizingInterceptor::onWriteMessage(QGrpcInterceptionContext & /*context*/,
                                           QProtobufMessage &message)
{
    // For handling more complex scenarios with various Request message types,
    // it would make sense to branch on the context.descriptor() appropriately.
    if (auto *request = qprotobufmessage_cast<client::guide::Request *>(&message))
        sanitizeMessage(*request);
}

void SanitizingInterceptor::onMessageReceived(QGrpcInterceptionContext &context,
                                              QByteArray &messageData)
{
    // messageData can only be of type client::guide::Response. For more
    // complex handling add appropriate handling with context.descriptor().
    // Note that the deserialization and serialization introduces overhead.
    client::guide::Response response;
    auto serializer = context.channel().serializer();
    if (serializer->deserialize(&response, messageData))
        return;
    sanitizeMessage(response);
    messageData = serializer->serialize(&response);
}
Interceptor Usage

Construct the interceptor chain in a helper function and return it to the caller.

inline QGrpcInterceptorChain createInterceptors()
{
    QGrpcInterceptorChain chain;
    chain.set(
        std::make_unique<MetricsInterceptor>(),
        std::make_unique<AuthInterceptor>(),
        std::make_unique<SanitizingInterceptor>()
    );
    return chain;
}

Before attaching the chain to the channel, verify that it was populated successfully (for example by checking isEmpty()) and apply an appropriate error-handling strategy if creation failed.

auto interceptors = createInterceptors();
if (interceptors.isEmpty()) {
    qWarning("Failed to create the interceptor chain");
    return EXIT_FAILURE; // or some other suitable fallback
}
channel = std::make_shared<QGrpcHttp2Channel>(
    QUrl("http://localhost:50056"),
    std::move(interceptors)
);

For unary calls, the example schedules a sequence of requests with a short delay between them. This makes it easier to observe how the interceptors affect successive calls: trace metadata is added, authentication failures refresh the token for later requests, and invalid metadata can cause a call to be dropped before it is sent.

int delayMs = 150;
for (int i = 1; i < 6; ++i) {
    QTimer::singleShot(delayMs, [&, i] {
        auto expected = i % 2 == 0 ? ExpectedResult::Failure : ExpectedResult::Success;
        clientGuide.unaryCall(ClientGuide::createRequest(i, expected));
    });
    delayMs += 150;
}
QTimer::singleShot(delayMs, [&] {
    QGrpcCallOptions invalidOpts;
    invalidOpts.addMetadata("huge_key"_ba, "huge_value"_ba.repeated(8'000));
    clientGuide.unaryCall(guide::Request{ }, invalidOpts); // this call will fail.
});
delayMs += 150;

The example also performs a bidirectional streaming RPC. The bidirectionalStreaming() implementation would normally exchange 666 messages, but the sanitizing interceptor clamps the request values so that the stream terminates after five exchanges.

QTimer::singleShot(delayMs, [&] {
    clientGuide.bidirectionalStreaming(ClientGuide::createRequest(666));
});
Running the code

Run clientguide_client with the -I option to enable interceptor support. A possible output is shown below:

Welcome to the clientguide!
Running with Interceptor support
Starting the server process ...
    Server listening on: localhost:50056
    Server (UnaryCall): Request( time: 1774360832277, num: 1 ), Metadata: { x-trace-id: {360bb041-9ab5-4940-95c1-0d4c89491570} }
Client (UnaryCall) finished, received: Response( time: 1774360832278, num: 1 )
    Server (UnaryCall): Request( time: 1774360832427, num: 2 ), Metadata: { x-trace-id: {01817a8f-9f0e-404e-a21c-767f81e74a2d} }
Client (UnaryCall) finished, received: Response( time: 1774360832429, num: 2 )
    Server (UnaryCall): Request( time: 1774360832577, num: 3 ), Metadata: { x-trace-id: {78cb336f-0396-4bbc-b1ee-59914651feef} }
Client (UnaryCall) finished, received: Response( time: 1774360832578, num: 3 )
[AuthInterceptor] Refreshing token for next call
Client (UnaryCall) failed: QGrpcStatus( code: QtGrpc::StatusCode::Unauthenticated, message: "Token expired with age: 603 ms, Hint: Bearer realm=\"clientguide\", error=\"invalid_token\"" )
    Server (UnaryCall): Request( time: 1774360832876, num: 5 ), Metadata: { x-trace-id: {63a40327-2f46-43c4-9e2c-44b67f297bd2} }
Client (UnaryCall) finished, received: Response( time: 1774360832877, num: 5 )
[Sanitizing] Aborting call with metadata size of 80129 bytes
Client (UnaryCall) failed: QGrpcStatus( code: QtGrpc::StatusCode::Aborted, message: "Interceptors dropped the call" )
    Server (BidirectionalStreaming) accepted call, Metadata: { x-trace-id: {fc945bdb-5c8a-4c65-8bfb-8655800d5774} }
    Server (BidirectionalStreaming): Request( time: 1774360833177, num: 5 )
Client (BidirectionalStreaming) received: Response( time: 1774360833178, num: 5 )
    Server (BidirectionalStreaming): Request( time: 1774360833178, num: 4 )
Client (BidirectionalStreaming) received: Response( time: 1774360833179, num: 4 )
    Server (BidirectionalStreaming): Request( time: 1774360833179, num: 3 )
Client (BidirectionalStreaming) received: Response( time: 1774360833179, num: 3 )
    Server (BidirectionalStreaming): Request( time: 1774360833179, num: 2 )
Client (BidirectionalStreaming) received: Response( time: 1774360833180, num: 2 )
    Server (BidirectionalStreaming): Request( time: 1774360833180, num: 1 )
Client (BidirectionalStreaming) received: Response( time: 1774360833180, num: 1 )
    Server (BidirectionalStreaming): Request( time: 1774360833180, num: 0 )
Client (BidirectionalStreaming) received: Response( time: 1774360833180, num: 0 )
Client (BidirectionalStreaming) finished
All operations completed! Automatically shutting down...
Metrics for: QtGrpc::RpcDescriptor( service: "client.guide.ClientGuideService", method: "BidirectionalStreaming", type: QtGrpc::RpcType::BidiStreaming )
Bytes received    : 52
Failed RPCs       : 0
Completed RPCs    : 1
Completed Durations (ms): QList(3.97233)
Metrics for: QtGrpc::RpcDescriptor( service: "client.guide.ClientGuideService", method: "UnaryCall", type: QtGrpc::RpcType::UnaryCall )
Bytes received    : 36
Failed RPCs       : 2
Completed RPCs    : 4
Completed Durations (ms): QList(1.937, 1.90971, 1.624, 1.50429)

The output shows the interceptor chain in action. Outgoing unary calls reach the server with trace metadata added by the metrics interceptor. When the server reports an authentication failure, the authentication interceptor refreshes the token for subsequent calls and augments the error message with the server-provided hint. A later call is rejected locally by the sanitizing interceptor because its metadata exceeds the configured limit. The bidirectional streaming call shows that interceptors also apply to streaming RPCs: the sanitizing interceptor clamps the request value, so the stream terminates after five message exchanges instead of continuing from 666. Finally, when the client shuts down, the metrics interceptor prints the collected statistics for each RPC method.

Example project @ code.qt.io

© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.