Kora облачно ориентированный серверный фреймворк написанный на Java для написания Java / Kotlin приложений с упором на производительность, эффективность, прозрачность сделанный выходцами из Т-Банк / Тинькофф

Kora is a cloud-oriented server-side Java framework for writing Java / Kotlin applications with a focus on performance, efficiency and transparency

Skip to content

gRPC server

The module starts a gRPC server based on grpc-java and connects handlers from the application graph to it. A handler is a BindableService, usually a class that extends a generated ...ImplBase and implements unary or streaming RPC methods.

Kora creates a NettyServerBuilder, adds server services, user-defined and standard ServerInterceptor, manages the server lifecycle, and participates in application readiness checks. If configuration parameters are not enough, the resulting NettyServerBuilder can be additionally configured in code through GrpcServerBuilderConfigurer.

For a step-by-step walkthrough before the reference details, see gRPC Server and Advanced gRPC Server.

Dependency

Dependency in build.gradle:

implementation "ru.tinkoff.kora:grpc-server"
implementation "io.grpc:grpc-protobuf:1.74.0"
implementation "javax.annotation:javax.annotation-api:1.3.2"

Module:

@KoraApp
public interface Application extends GrpcServerModule { }

Dependency in build.gradle.kts:

implementation("ru.tinkoff.kora:grpc-server")
implementation("io.grpc:grpc-protobuf:1.74.0")
implementation("javax.annotation:javax.annotation-api:1.3.2")

Module:

@KoraApp
interface Application : GrpcServerModule

Plugin

The code for the gRPC server is generated with the protobuf gradle plugin.

Plugin in build.gradle:

plugins {
    id "com.google.protobuf" version "0.9.4"
}

protobuf {
    protoc { artifact = "com.google.protobuf:protoc:3.25.3" }
    plugins {
        grpc { artifact = "io.grpc:protoc-gen-grpc-java:1.74.0" }
    }
    generateProtoTasks {
        all()*.plugins { grpc {} }
    }
}

sourceSets {
    main.java {
        srcDirs "build/generated/source/proto/main/grpc"
        srcDirs "build/generated/source/proto/main/java"
    }
}

Plugin in build.gradle.kts:

import com.google.protobuf.gradle.id

plugins {
    id("com.google.protobuf") version ("0.9.4")
}

protobuf {
    protoc { artifact = "com.google.protobuf:protoc:3.25.3" }
    plugins {
        id("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:1.74.0" }
    }
    generateProtoTasks {
        ofSourceSet("main").forEach { it.plugins { id("grpc") { } } }
    }
}

kotlin {
    sourceSets.main {
        kotlin.srcDir("build/generated/source/proto/main/grpc")
        kotlin.srcDir("build/generated/source/proto/main/java")
    }
}

Configuration

Only port typically needs to be set; all other parameters have defaults. A minimal configuration that binds a port from an environment variable and enables logging looks like this:

grpcServer {
    port = 8090
    telemetry.logging.enabled = true
}
grpcServer:
  port: 8090
  telemetry:
    logging:
      enabled: true

Basic configuration parameters:

grpcServer {
    port = 8090 //(1)!
    maxMessageSize = "4MiB" //(2)!
    reflectionEnabled = false //(3)!
}
  1. gRPC server port (default: 8090).
  2. Maximum incoming message size (default: 4MiB).
  3. Enables gRPC Server Reflection service (default: false).
grpcServer:
  port: 8090 #(1)!
  maxMessageSize: "4MiB" #(2)!
  reflectionEnabled: false #(3)!
  1. gRPC server port (default: 8090).
  2. Maximum incoming message size (default: 4MiB).
  3. Enables gRPC Server Reflection service (default: false).
Full Configuration

Example of a complete configuration described by GrpcServerConfig:

grpcServer {
    port = 8090 //(1)!
    maxMessageSize = "4MiB" //(2)!
    reflectionEnabled = false //(3)!
    shutdownWait = "30s" //(4)!
    maxConnectionAge = "0s" //(5)!
    maxConnectionAgeGrace = "0s" //(6)!
    keepAliveTime = "0s" //(7)!
    keepAliveTimeout = "0s" //(8)!
    telemetry {
        logging {
            enabled = false //(9)!
        }
        metrics {
            enabled = true //(10)!
            slo = [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] //(11)!
            tags = { // (12)!
                "key1" = "value1"
                "key2" = "value2"
            }
        }
        tracing {
            enabled = true //(13)!
            attributes = { // (14)!
                "key1" = "value1"
                "key2" = "value2"
            }
        }
    }
}
  1. gRPC server port (default: 8090).
  2. Maximum size of an incoming message (default: 4MiB). It can be specified as a number of bytes or as 4MiB, 4MB, 1000Kb, and similar values.
  3. Enables the gRPC Server Reflection service (default: false).
  4. Time to wait for processing before shutting down the server during graceful shutdown (default: 30s).
  5. Sets a custom maximum connection age after which the connection is gracefully terminated (default: not specified, optional). A random jitter of +/-10% is added to the value.
  6. Sets additional time for graceful connection termination after the maximum connection age is reached (default: not specified, optional). RPC calls that do not finish in time are cancelled so the connection can terminate.
  7. Sets the interval between PING frames (default: not specified, optional).
  8. Timeout for acknowledging a PING frame (default: not specified, optional). If no acknowledgement is received within this time, the connection is closed.
  9. Enables module logging (default: false).
  10. Enables module metrics (default: true).
  11. Configures SLO for the DistributionSummary metric (default: ru.tinkoff.kora.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO).
  12. Metric tags (default: {}).
  13. Enables module tracing (default: true).
  14. Tracing attributes (default: {}).
grpcServer:
  port: 8090 #(1)!
  maxMessageSize: "4MiB" #(2)!
  reflectionEnabled: false #(3)!
  shutdownWait: "30s" #(4)!
  maxConnectionAge: "0s" #(5)!
  maxConnectionAgeGrace: "0s" #(6)!
  keepAliveTime: "0s" #(7)!
  keepAliveTimeout: "0s" #(8)!
  telemetry:
    logging:
      enabled: false #(9)!
    metrics:
      enabled: true #(10)!
      slo: [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] #(11)!
      tags: #(12)!
        key1: value1
        key2: value2
    tracing:
      enabled: true #(13)!
      attributes: #(14)!
        key1: value1
        key2: value2
  1. gRPC server port (default: 8090).
  2. Maximum size of an incoming message (default: 4MiB). It can be specified as a number of bytes or as 4MiB, 4MB, 1000Kb, and similar values.
  3. Enables the gRPC Server Reflection service (default: false).
  4. Time to wait for processing before shutting down the server during graceful shutdown (default: 30s).
  5. Sets a custom maximum connection age after which the connection is gracefully terminated (default: not specified, optional). A random jitter of +/-10% is added to the value.
  6. Sets additional time for graceful connection termination after the maximum connection age is reached (default: not specified, optional). RPC calls that do not finish in time are cancelled so the connection can terminate.
  7. Sets the interval between PING frames (default: not specified, optional).
  8. Timeout for acknowledging a PING frame (default: not specified, optional). If no acknowledgement is received within this time, the connection is closed.
  9. Enables module logging (default: false).
  10. Enables module metrics (default: true).
  11. Configures SLO for the DistributionSummary metric (default: ru.tinkoff.kora.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO).
  12. Metric tags (default: {}).
  13. Enables module tracing (default: true).
  14. Tracing attributes (default: {}).

You can also configure Netty transport.

Configuration In Code

If configuration parameters are not enough, register a GrpcServerBuilderConfigurer component and additionally configure NettyServerBuilder in code. This component is called after configuration has been applied and after services, user-defined ServerInterceptor, and standard ServerInterceptor have been added.

@Component
public final class MyGrpcServerBuilderConfigurer implements GrpcServerBuilderConfigurer {

    @Override
    public NettyServerBuilder configure(NettyServerBuilder builder) {
        return builder.permitKeepAliveWithoutCalls(true);
    }
}
@Component
class MyGrpcServerBuilderConfigurer : GrpcServerBuilderConfigurer {

    override fun configure(builder: NettyServerBuilder): NettyServerBuilder {
        return builder.permitKeepAliveWithoutCalls(true)
    }
}

Module metrics are described in the Metrics Reference section.

Handlers

A handler is a class that extends the generated ...ImplBase and is registered in the application graph with the @Component annotation. The ...ImplBase class is produced from the proto contract by the protobuf gradle plugin; you override its RPC methods to implement server behavior. Ordinary Kora components such as services and repositories can be injected into a handler through its constructor.

Consider a proto contract with a single unary method:

src/main/proto/message.proto
syntax = "proto3";

package ru.tinkoff.kora.generated.grpc;

service UserService {
  rpc createUser(RequestEvent) returns (ResponseEvent) {} //(1)!
}

message RequestEvent {
  string name = 1;
  string code = 2;
}

message ResponseEvent {
  bytes id = 1;
}
  1. A unary RPC: one request message produces one response message.

The plugin generates UserServiceGrpc.UserServiceImplBase, and the handler overrides the generated method. The generated method receives the request message and a StreamObserver that is used to send responses back to the client:

@Component
public final class UserService extends UserServiceGrpc.UserServiceImplBase {

    @Override
    public void createUser(Message.RequestEvent request, StreamObserver<Message.ResponseEvent> responseObserver) { //(1)!
        var response = Message.ResponseEvent.newBuilder()
            .setId(ByteString.copyFromUtf8(UUID.randomUUID().toString()))
            .build();

        responseObserver.onNext(response); //(2)!
        responseObserver.onCompleted(); //(3)!
    }
}
  1. The generated method receives the request message and a StreamObserver for sending the response
  2. Sends a single response message to the client
  3. Signals that the call is complete; for a unary method it is called exactly once, after a single onNext
@Component
class UserService : UserServiceGrpc.UserServiceImplBase() {

    override fun createUser(request: Message.RequestEvent, responseObserver: StreamObserver<Message.ResponseEvent>) { //(1)!
        val response = Message.ResponseEvent.newBuilder()
            .setId(ByteString.copyFromUtf8(UUID.randomUUID().toString()))
            .build()

        responseObserver.onNext(response) //(2)!
        responseObserver.onCompleted() //(3)!
    }
}
  1. The generated method receives the request message and a StreamObserver for sending the response
  2. Sends a single response message to the client
  3. Signals that the call is complete; for a unary method it is called exactly once, after a single onNext

Server streaming

For a server-streaming RPC (returns (stream ...) in the proto), the client sends one request and the server sends back many messages. Call onNext for each message, then onCompleted once at the end:

@Override
public void getAllUsers(Message.RequestEvent request, StreamObserver<Message.ResponseEvent> responseObserver) {
    for (var user : userService.findAll()) {
        responseObserver.onNext(toResponse(user)); //(1)!
    }
    responseObserver.onCompleted(); //(2)!
}
  1. Sends one of several response messages
  2. Completes the response stream after the last message
override fun getAllUsers(request: Message.RequestEvent, responseObserver: StreamObserver<Message.ResponseEvent>) {
    userService.findAll().forEach { responseObserver.onNext(toResponse(it)) } //(1)!
    responseObserver.onCompleted() //(2)!
}
  1. Sends one of several response messages
  2. Completes the response stream after the last message

Client streaming

For a client-streaming RPC (rpc method(stream ...)), the client sends many messages and the server answers once at the end. The generated method returns a StreamObserver that receives the incoming request messages; the final response is produced from onCompleted:

@Override
public StreamObserver<Message.RequestEvent> createUsers(StreamObserver<Message.ResponseEvent> responseObserver) {
    return new StreamObserver<>() {
        private final List<Message.RequestEvent> received = new ArrayList<>();

        @Override
        public void onNext(Message.RequestEvent value) {
            received.add(value); //(1)!
        }

        @Override
        public void onError(Throwable t) {
            responseObserver.onError(t); //(2)!
        }

        @Override
        public void onCompleted() {
            responseObserver.onNext(aggregate(received)); //(3)!
            responseObserver.onCompleted();
        }
    };
}
  1. Collects each incoming request message
  2. Propagates a client-side stream error
  3. Produces the single aggregated response once the client has finished sending
override fun createUsers(responseObserver: StreamObserver<Message.ResponseEvent>): StreamObserver<Message.RequestEvent> {
    return object : StreamObserver<Message.RequestEvent> {
        private val received = mutableListOf<Message.RequestEvent>()

        override fun onNext(value: Message.RequestEvent) {
            received += value //(1)!
        }

        override fun onError(t: Throwable) {
            responseObserver.onError(t) //(2)!
        }

        override fun onCompleted() {
            responseObserver.onNext(aggregate(received)) //(3)!
            responseObserver.onCompleted()
        }
    }
}
  1. Collects each incoming request message
  2. Propagates a client-side stream error
  3. Produces the single aggregated response once the client has finished sending

Bidirectional streaming

For a bidirectional-streaming RPC (rpc method(stream ...) returns (stream ...)), both sides exchange many messages on the same call. The method returns a StreamObserver for the incoming requests and can send responses at any time through responseObserver:

@Override
public StreamObserver<Message.RequestEvent> updateUsers(StreamObserver<Message.ResponseEvent> responseObserver) {
    return new StreamObserver<>() {
        @Override
        public void onNext(Message.RequestEvent value) {
            responseObserver.onNext(process(value)); //(1)!
        }

        @Override
        public void onError(Throwable t) {
            responseObserver.onError(t);
        }

        @Override
        public void onCompleted() {
            responseObserver.onCompleted(); //(2)!
        }
    };
}
  1. Responds to each incoming message as it arrives
  2. Completes the response stream when the client stops sending
override fun updateUsers(responseObserver: StreamObserver<Message.ResponseEvent>): StreamObserver<Message.RequestEvent> {
    return object : StreamObserver<Message.RequestEvent> {
        override fun onNext(value: Message.RequestEvent) {
            responseObserver.onNext(process(value)) //(1)!
        }

        override fun onError(t: Throwable) {
            responseObserver.onError(t)
        }

        override fun onCompleted() {
            responseObserver.onCompleted() //(2)!
        }
    }
}
  1. Responds to each incoming message as it arrives
  2. Completes the response stream when the client stops sending

Error handling

Description: gRPC represents call errors with an io.grpc.Status code and an optional description rather than with HTTP response codes. To fail a call, complete the response observer with responseObserver.onError(status.asRuntimeException()), or throw a StatusRuntimeException from the handler. The auto-registered TelemetryInterceptor observes the terminal Status when the call is closed (on close, onHalfClose, onCancel, and onComplete) and records logging, metrics, and tracing accordingly.

Causes: choose the Status code that matches the failure — for example Status.NOT_FOUND for a missing entity, Status.INVALID_ARGUMENT for invalid input, Status.UNAUTHENTICATED or Status.PERMISSION_DENIED for authorization failures, and Status.INTERNAL for unexpected server errors.

Recommendations:

  • Attach a human-readable message with withDescription(...) and keep the original exception with withCause(...) so telemetry can record it.
  • Complete a call exactly once: never call onError after onCompleted, and never call either twice.
  • Do not leak internal exception details to clients; map them to an appropriate Status first.

Handling example: a unary handler that returns NOT_FOUND when an entity is missing and maps unexpected failures to INTERNAL:

@Override
public void getUser(Message.RequestEvent request, StreamObserver<Message.ResponseEvent> responseObserver) {
    try {
        var user = userService.getUser(request.getName())
            .orElseThrow(() -> Status.NOT_FOUND
                .withDescription("User not found: " + request.getName())
                .asRuntimeException()); //(1)!
        responseObserver.onNext(toResponse(user));
        responseObserver.onCompleted();
    } catch (StatusRuntimeException e) {
        responseObserver.onError(e); //(2)!
    } catch (Exception e) {
        responseObserver.onError(Status.INTERNAL
            .withDescription("Failed to get user")
            .withCause(e) //(3)!
            .asRuntimeException());
    }
}
  1. Builds a NOT_FOUND error with a description
  2. Forwards an already-mapped Status error to the client
  3. Keeps the original exception as the cause so telemetry can record it
override fun getUser(request: Message.RequestEvent, responseObserver: StreamObserver<Message.ResponseEvent>) {
    try {
        val user = userService.getUser(request.name)
            ?: throw Status.NOT_FOUND
                .withDescription("User not found: ${request.name}")
                .asRuntimeException() //(1)!
        responseObserver.onNext(toResponse(user))
        responseObserver.onCompleted()
    } catch (e: StatusRuntimeException) {
        responseObserver.onError(e) //(2)!
    } catch (e: Exception) {
        responseObserver.onError(
            Status.INTERNAL
                .withDescription("Failed to get user")
                .withCause(e) //(3)!
                .asRuntimeException()
        )
    }
}
  1. Builds a NOT_FOUND error with a description
  2. Forwards an already-mapped Status error to the client
  3. Keeps the original exception as the cause so telemetry can record it

Signatures

The shape of a handler method is fixed by the proto contract and the generated ...ImplBase:

By Req and Resp we mean the generated request and response message types.

  • Unary: void myMethod(Req request, StreamObserver<Resp> responseObserver)
  • Server streaming: void myMethod(Req request, StreamObserver<Resp> responseObserver) (multiple onNext, one onCompleted)
  • Client streaming: StreamObserver<Req> myMethod(StreamObserver<Resp> responseObserver)
  • Bidirectional streaming: StreamObserver<Req> myMethod(StreamObserver<Resp> responseObserver)

The generated method returns void (or the request StreamObserver), so results are delivered asynchronously through the StreamObserver callbacks; responses may be completed from another thread.

By Req and Resp we mean the generated request and response message types.

  • Unary: myMethod(request: Req, responseObserver: StreamObserver<Resp>)
  • Server streaming: myMethod(request: Req, responseObserver: StreamObserver<Resp>) (multiple onNext, one onCompleted)
  • Client streaming: myMethod(responseObserver: StreamObserver<Resp>): StreamObserver<Req>
  • Bidirectional streaming: myMethod(responseObserver: StreamObserver<Resp>): StreamObserver<Req>

When you generate coroutine stubs with the grpc-kotlin plugin (io.grpc:protoc-gen-grpc-kotlin) and extend the generated ...CoroutineImplBase, handler methods can be suspend functions (and streaming methods can use Flow). Kora auto-registers CoroutineContextInjectInterceptor, which injects the Kora Context into the handler's CoroutineContext; it activates only when kotlinx-coroutines is on the classpath.

Interceptors

An io.grpc.ServerInterceptor processes a call before it is passed to a gRPC service. Interceptors are suitable for cross-cutting logic: logging, authorization, tracing, working with Metadata, and error mapping.

Unlike the HTTP server, the gRPC server module has no @GrpcService or @InterceptWith annotation: every ServerInterceptor registered as a @Component is applied globally to all services on the server. To limit an interceptor to a single service or method, inspect the call at runtime — see Scoping and authorization.

Default

When the server starts, Kora adds standard interceptors:

  • TelemetryInterceptor — enables server telemetry (logging, metrics, tracing) depending on connected modules and grpcServer.telemetry settings, and maps the terminal Status/exception when the call closes
  • ContextServerInterceptor — propagates the Kora Context into call processing so it is available inside the handler
  • CoroutineContextInjectInterceptor — adds CoroutineContext support for Kotlin coroutine handlers (active only when kotlinx-coroutines is on the classpath)

User-defined ServerInterceptor beans from the application graph are added to NettyServerBuilder before the standard interceptors. For full NettyServerBuilder configuration, use GrpcServerBuilderConfigurer.

Execution order

gRPC invokes interceptors in the reverse order of registration, so the last interceptor added runs first (outermost). Because Kora registers user interceptors first and the standard interceptors last, an incoming call is processed in this order:

CoroutineContextInjectInterceptor -> ContextServerInterceptor -> TelemetryInterceptor -> user interceptors -> handler

Consequences of this order:

  • The Kora Context and Kotlin CoroutineContext are established around your interceptors and the handler, so they are available inside the handler's listener callbacks.
  • TelemetryInterceptor wraps your interceptors and the handler, so it observes the final Status (including errors thrown or reported through the response observer).
  • When several user interceptors exist, they run in the reverse of their graph registration order; do not rely on a specific order between them for correctness.

Custom

To add a custom interceptor, create a ServerInterceptor implementation with the @Component annotation:

@Component
public class GrpcExceptionHandlerServerInterceptor implements ServerInterceptor {

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, 
                                                                 Metadata metadata,
                                                                 ServerCallHandler<ReqT, RespT> serverCallHandler) {
        // do something

        return serverCallHandler.startCall(serverCall, metadata);
    }
}
@Component
class GrpcExceptionHandlerServerInterceptor : ServerInterceptor {

    override fun <ReqT, RespT> interceptCall(
        serverCall: ServerCall<ReqT, RespT>,
        metadata: Metadata,
        serverCallHandler: ServerCallHandler<ReqT, RespT>
    ): ServerCall.Listener<ReqT> {
        // do something

        return serverCallHandler.startCall(serverCall, metadata)
    }
}

Scoping and authorization

Because an interceptor is global, scope it to a specific service or method by inspecting call.getMethodDescriptor(): getServiceName() returns the service name (the generated constant ...Grpc.SERVICE_NAME), and getFullMethodName() returns service/method.

Request headers arrive as Metadata. Read a header with a Metadata.Key, and reject a call by closing it with a Status and returning an empty listener so the handler is never invoked. The example below applies API-key authorization to a single service only:

@Component
public final class ApiKeyServerInterceptor implements ServerInterceptor {

    private static final Metadata.Key<String> AUTHORIZATION =
        Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); //(1)!

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call,
                                                                 Metadata headers,
                                                                 ServerCallHandler<ReqT, RespT> next) {
        if (!UserServiceGrpc.SERVICE_NAME.equals(call.getMethodDescriptor().getServiceName())) { //(2)!
            return next.startCall(call, headers);
        }

        var apiKey = headers.get(AUTHORIZATION); //(3)!
        if (apiKey == null || !apiKey.equals("secret")) {
            call.close(Status.UNAUTHENTICATED.withDescription("Invalid API key"), new Metadata()); //(4)!
            return new ServerCall.Listener<>() {}; //(5)!
        }

        return next.startCall(call, headers);
    }
}
  1. Metadata.Key for reading the authorization header as an ASCII string
  2. Applies the interceptor only to UserService; other services pass through untouched
  3. Reads the header value from the request Metadata
  4. Rejects the call with an UNAUTHENTICATED status
  5. Returns an empty listener so the handler is never called
@Component
class ApiKeyServerInterceptor : ServerInterceptor {

    override fun <ReqT : Any?, RespT : Any?> interceptCall(
        call: ServerCall<ReqT, RespT>,
        headers: Metadata,
        next: ServerCallHandler<ReqT, RespT>
    ): ServerCall.Listener<ReqT> {
        if (UserServiceGrpc.SERVICE_NAME != call.methodDescriptor.serviceName) { //(2)!
            return next.startCall(call, headers)
        }

        val apiKey = headers.get(AUTHORIZATION) //(3)!
        if (apiKey != "secret") {
            call.close(Status.UNAUTHENTICATED.withDescription("Invalid API key"), Metadata()) //(4)!
            return object : ServerCall.Listener<ReqT>() {} //(5)!
        }

        return next.startCall(call, headers)
    }

    companion object {
        private val AUTHORIZATION: Metadata.Key<String> =
            Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) //(1)!
    }
}
  1. Metadata.Key for reading the authorization header as an ASCII string
  2. Applies the interceptor only to UserService; other services pass through untouched
  3. Reads the header value from the request Metadata
  4. Rejects the call with an UNAUTHENTICATED status
  5. Returns an empty listener so the handler is never called

Lifecycle and readiness

The server is managed by the GrpcNettyServer component, which is created as a @Root component and follows the application lifecycle:

  • On startup it builds and starts the Netty server on the configured port. If the port is already in use, startup fails with a clear error.
  • On shutdown it performs a graceful shutdown: it stops accepting new calls and waits up to shutdownWait for in-flight calls to finish, then forcibly terminates any remaining calls.

GrpcNettyServer also implements a readiness probe: the server reports not ready while it is starting up or shutting down, and ready only while it is running. In a Kubernetes deployment this lets the readiness probe reflect the real server state and drain traffic during graceful shutdown.

Reflection

gRPC Server Reflection is supported and provides information about available gRPC services on the server. Reflection helps clients and tools build RPC requests at runtime without precompiled service information. For example, it is used by gRPC CLI, which can inspect server proto descriptions and send test RPC calls. gRPC Server Reflection is supported only for proto-based services.

You can learn more about gRPC Server Reflection in the grpc-java guide.

Dependency

You must additionally add the gRPC Server Reflection dependency.

Dependency in build.gradle:

implementation "io.grpc:grpc-services:1.74.0"

Dependency in build.gradle.kts:

implementation("io.grpc:grpc-services:1.74.0")

Configuration

You must also enable the gRPC Server Reflection service in the configuration. Kora adds it to the server only if the application has the io.grpc.protobuf.services.ProtoReflectionService class, so configuration alone is not enough without the dependency.

grpcServer {
    reflectionEnabled = false //(1)!
}
  1. Enables the gRPC Server Reflection service (default: false).
grpcServer:
  reflectionEnabled: false #(1)!
  1. Enables the gRPC Server Reflection service (default: false).

Usage

With reflection enabled, tools such as grpcurl can discover services and send RPC calls without a precompiled client. For a server listening on port 8090:

grpcurl -plaintext localhost:8090 list #(1)!
grpcurl -plaintext localhost:8090 describe ru.tinkoff.kora.generated.grpc.UserService #(2)!
grpcurl -plaintext -d '{"name": "Bob", "code": "123"}' \
    localhost:8090 ru.tinkoff.kora.generated.grpc.UserService/createUser #(3)!
  1. Lists the services exposed by the server
  2. Describes a service and its methods
  3. Sends a unary RPC; -plaintext is used because the example server has no TLS

Telemetry

gRPC Server uses a telemetry contract for logging, metrics, and tracing of calls. Telemetry configuration (section telemetry { logging / metrics / tracing }) is described in the Configuration section. Extension points are located in ru.tinkoff.kora.grpc.server.common.telemetry.

Server observability is driven by the TelemetryInterceptor through the GrpcServerTelemetry facade and is configured under grpcServer.telemetry.

For each gRPC call, a GrpcServerTelemetry.GrpcServerTelemetryContext is created, which is closed upon call completion. The call is described through telemetry handler parameters, including service, method, response status, and duration.

The default factory DefaultGrpcServerTelemetryFactory combines three factories: - GrpcServerLoggerFactory builds GrpcServerLogger for logging call start/end; - GrpcServerMetricsFactory builds GrpcServerMetrics for writing call metrics; - GrpcServerTracerFactory builds GrpcServerTracer for distributed tracing.

Metrics and tracing are described in the Metrics Reference section.