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
V1 V2

HTTP client

The HTTP client module describes outgoing HTTP calls: transport implementation, request mapping, response mapping, telemetry, and interceptors. In Kora, clients can be described declaratively with @HttpClient and @HttpRoute, or used imperatively through the common HttpClient interface when a request must be built in code.

The declarative approach is suitable for most integrations with external services: the method contract becomes the remote call contract, and Kora creates the implementation at compile time without using Reflection at runtime. The imperative approach is useful for low-level or dynamic scenarios where path, headers, query parameters, or body are easier to assemble manually.

All HTTP client calls in Kora are synchronous and blocking: HttpClient.execute() returns HttpClientResponse directly, and a declarative method returns its result directly. Concurrency is expected to come from virtual threads rather than from reactive or coroutine return types.

Recommendation

We recommend using an approach where the OpenAPI file is the primary contract and clients are created from it using the generator. This approach allows you to achieve consistency between the consumer and owner of the contract and update the API faster when the contract changes by replacing the contract file. For more information about the generator, see the section on generating from OpenAPI.

For a step-by-step walkthrough before the reference details, see HTTP Client and Advanced HTTP Client.

OkHttp

HTTP client implementation based on the OkHttp library. The Kora module itself is written in Java, but the OkHttp library is a Kotlin library and brings its own dependencies. This transport is the one to pick when HTTP/2 or HTTP/3, GZip compression, or other OkHttp-specific options are required.

Dependency

Dependency build.gradle:

implementation "io.koraframework:http-client-ok"

Module:

@KoraApp
public interface Application extends OkHttpClientModule { }

Dependency build.gradle.kts:

implementation("io.koraframework:http-client-ok")

Module:

@KoraApp
interface Application : OkHttpClientModule

The HttpClient interface implementation is OkHttpClient from the io.koraframework.http.client.ok package.

Configuration

Basic OkHttp client configuration parameters:

httpClient {
    connectTimeout = "5s" //(1)!
    readTimeout = "2m" //(2)!
}
  1. Maximum time to establish a connection (default: 5s)
  2. Maximum time to read a response (default: 2m)
httpClient:
  connectTimeout: "5s" #(1)!
  readTimeout: "2m" #(2)!
  1. Maximum time to establish a connection (default: 5s)
  2. Maximum time to read a response (default: 2m)
Full Configuration

Example of the complete configuration described in the OkHttpClientConfig and HttpClientConfig classes (default or example values are specified):

httpClient {
    ok {
        followRedirects = true //(1)!
        retryOnConnectionFailure = true //(2)!
        httpVersion = "HTTP_1_1" //(3)!
    }
    connectTimeout = "5s" //(4)!
    readTimeout = "2m" //(5)!
    useEnvProxy = false //(6)!
    proxy {
        host = "localhost" //(7)!
        port = 8090 //(8)!
        user = "user" //(9)!
        password = "password" //(10)!
        nonProxyHosts = [ "host1", "host2" ] //(11)!
    }
}
  1. Whether to follow HTTP redirects (default: true)
  2. Whether to retry a request after a connection failure; this can affect the maximum connection establishment time (default: true)
  3. Maximum HTTP protocol version to use, available values: HTTP_1_1 / HTTP_2 / HTTP_3 (default: HTTP_1_1)
  4. Maximum time to establish a connection (default: 5s)
  5. Maximum time to read a response (default: 2m)
  6. Whether to use https_proxy / HTTPS_PROXY / http_proxy / HTTP_PROXY and no_proxy / NO_PROXY environment variables for proxy configuration (default: false)
  7. Proxy host (required if the proxy section is present, no default)
  8. Proxy port (required if the proxy section is present, no default)
  9. Proxy user (optional, no default)
  10. Proxy password (optional, no default)
  11. Hosts to exclude from proxying (optional, no default)
httpClient:
  ok:
    followRedirects: true #(1)!
    retryOnConnectionFailure: true #(2)!
    httpVersion: "HTTP_1_1" #(3)!
  connectTimeout: "5s" #(4)!
  readTimeout: "2m" #(5)!
  useEnvProxy: false #(6)!
  proxy:
    host: "localhost" #(7)!
    port: 8090  #(8)!
    user: "user"  #(9)!
    password: "password" #(10)!
    nonProxyHosts: [ "host1", "host2" ] #(11)!
  1. Whether to follow HTTP redirects (default: true)
  2. Whether to retry a request after a connection failure; this can affect the maximum connection establishment time (default: true)
  3. Maximum HTTP protocol version to use, available values: HTTP_1_1 / HTTP_2 / HTTP_3 (default: HTTP_1_1)
  4. Maximum time to establish a connection (default: 5s)
  5. Maximum time to read a response (default: 2m)
  6. Whether to use https_proxy / HTTPS_PROXY / http_proxy / HTTP_PROXY and no_proxy / NO_PROXY environment variables for proxy configuration (default: false)
  7. Proxy host (required if the proxy section is present, no default)
  8. Proxy port (required if the proxy section is present, no default)
  9. Proxy user (optional, no default)
  10. Proxy password (optional, no default)
  11. Hosts to exclude from proxying (optional, no default)

Telemetry is not configured in the transport section: logging, metrics and tracing are configured per declarative client under httpClient.<clientName>.telemetry, see Client Configuration.

Module metrics are described in the Metrics Reference section.

Configurer

The transport builder can be customized with a Configurer<OkHttpClient.Builder> component. Kora applies it as the last step, after its own configuration has been applied:

@Component
public final class SomeConfigurer implements Configurer<OkHttpClient.Builder> {

    @Override
    public OkHttpClient.Builder configure(OkHttpClient.Builder builder) {
        return builder.callTimeout(Duration.ofSeconds(30));
    }
}
@Component
class SomeConfigurer : Configurer<OkHttpClient.Builder> {

    override fun configure(builder: OkHttpClient.Builder): OkHttpClient.Builder {
        return builder.callTimeout(Duration.ofSeconds(30))
    }
}

Configurer lives in io.koraframework.common and is the shared customization contract of all Kora transports.

Apache HttpClient

HTTP client implementation based on Apache HttpClient 5. It uses the classic (blocking) API and a pooling connection manager, which fits the synchronous Kora client contract directly.

Dependency

Dependency build.gradle:

implementation "io.koraframework:http-client-apache"

Module:

@KoraApp
public interface Application extends ApacheHttpClientModule { }

Dependency build.gradle.kts:

implementation("io.koraframework:http-client-apache")

Module:

@KoraApp
interface Application : ApacheHttpClientModule

The HttpClient interface implementation is ApacheHttpClient from the io.koraframework.http.client.apache package.

Configuration

Basic Apache HttpClient configuration parameters:

httpClient {
    connectTimeout = "5s" //(1)!
    readTimeout = "2m" //(2)!
}
  1. Maximum time to establish a connection (default: 5s)
  2. Maximum time to read a response, mapped to the Apache response timeout (default: 2m)
httpClient:
  connectTimeout: "5s" #(1)!
  readTimeout: "2m" #(2)!
  1. Maximum time to establish a connection (default: 5s)
  2. Maximum time to read a response, mapped to the Apache response timeout (default: 2m)
Full Configuration

Example of the complete configuration described in the ApacheHttpClientConfig and HttpClientConfig classes (default or example values are specified):

httpClient {
    apache {
        followRedirects = true //(1)!
        maxRedirects = 3 //(2)!
        maxConnections = 1000 //(3)!
    }
    connectTimeout = "5s" //(4)!
    readTimeout = "2m" //(5)!
    useEnvProxy = false //(6)!
    proxy {
        host = "localhost" //(7)!
        port = 8090 //(8)!
        user = "user" //(9)!
        password = "password" //(10)!
        nonProxyHosts = [ "host1", "host2" ] //(11)!
    }
}
  1. Whether to follow HTTP redirects (default: true)
  2. Maximum number of redirects to follow for a single request (default: 3)
  3. Maximum number of pooled connections, applied both in total and per route (default: number of available processors multiplied by 250)
  4. Maximum time to establish a connection (default: 5s)
  5. Maximum time to read a response (default: 2m)
  6. Whether to use https_proxy / HTTPS_PROXY / http_proxy / HTTP_PROXY and no_proxy / NO_PROXY environment variables for proxy configuration (default: false)
  7. Proxy host (required if the proxy section is present, no default)
  8. Proxy port (required if the proxy section is present, no default)
  9. Proxy user (optional, no default)
  10. Proxy password (optional, no default)
  11. Hosts to exclude from proxying (optional, no default)
httpClient:
  apache:
    followRedirects: true #(1)!
    maxRedirects: 3 #(2)!
    maxConnections: 1000 #(3)!
  connectTimeout: "5s" #(4)!
  readTimeout: "2m" #(5)!
  useEnvProxy: false #(6)!
  proxy:
    host: "localhost" #(7)!
    port: 8090  #(8)!
    user: "user"  #(9)!
    password: "password" #(10)!
    nonProxyHosts: [ "host1", "host2" ] #(11)!
  1. Whether to follow HTTP redirects (default: true)
  2. Maximum number of redirects to follow for a single request (default: 3)
  3. Maximum number of pooled connections, applied both in total and per route (default: number of available processors multiplied by 250)
  4. Maximum time to establish a connection (default: 5s)
  5. Maximum time to read a response (default: 2m)
  6. Whether to use https_proxy / HTTPS_PROXY / http_proxy / HTTP_PROXY and no_proxy / NO_PROXY environment variables for proxy configuration (default: false)
  7. Proxy host (required if the proxy section is present, no default)
  8. Proxy port (required if the proxy section is present, no default)
  9. Proxy user (optional, no default)
  10. Proxy password (optional, no default)
  11. Hosts to exclude from proxying (optional, no default)

Configurer

The Apache transport accepts two configurers: Configurer<RequestConfig.Builder> for the default request configuration and Configurer<HttpClientBuilder> for the client itself. Both are optional:

@Component
public final class SomeRequestConfigurer implements Configurer<RequestConfig.Builder> {

    @Override
    public RequestConfig.Builder configure(RequestConfig.Builder builder) {
        return builder.setConnectionRequestTimeout(1, TimeUnit.SECONDS);
    }
}

@Component
public final class SomeClientConfigurer implements Configurer<HttpClientBuilder> {

    @Override
    public HttpClientBuilder configure(HttpClientBuilder builder) {
        return builder.setUserAgent("my-service");
    }
}
@Component
class SomeRequestConfigurer : Configurer<RequestConfig.Builder> {

    override fun configure(builder: RequestConfig.Builder): RequestConfig.Builder {
        return builder.setConnectionRequestTimeout(1, TimeUnit.SECONDS)
    }
}

@Component
class SomeClientConfigurer : Configurer<HttpClientBuilder> {

    override fun configure(builder: HttpClientBuilder): HttpClientBuilder {
        return builder.setUserAgent("my-service")
    }
}

Native client

Implementation of an HTTP client based on the native client provided in the JDK. Kora runs it on a virtual thread executor, so the transport needs no thread pool configuration of its own.

Dependency

Dependency build.gradle:

implementation "io.koraframework:http-client-jdk"

Module:

@KoraApp
public interface Application extends JdkHttpClientModule { }

Dependency build.gradle.kts:

implementation("io.koraframework:http-client-jdk")

Module:

@KoraApp
interface Application : JdkHttpClientModule

The HttpClient interface implementation is JdkHttpClient from the io.koraframework.http.client.jdk package.

Configuration

Basic JDK HttpClient configuration parameters:

httpClient {
    connectTimeout = "5s" //(1)!
    readTimeout = "2m" //(2)!
}
  1. Maximum time to establish a connection (default: 5s)
  2. Maximum time to read a response (default: 2m)
httpClient:
  connectTimeout: "5s" #(1)!
  readTimeout: "2m" #(2)!
  1. Maximum time to establish a connection (default: 5s)
  2. Maximum time to read a response (default: 2m)
Full Configuration

Example of the complete configuration described in the JdkHttpClientConfig and HttpClientConfig classes (default or example values are specified):

httpClient {
    jdk {
        followRedirects = true //(1)!
        httpVersion = "HTTP_1_1" //(2)!
    }
    connectTimeout = "5s" //(3)!
    readTimeout = "2m" //(4)!
    useEnvProxy = false //(5)!
    proxy {
        host = "localhost" //(6)!
        port = 8090 //(7)!
        user = "user" //(8)!
        password = "password" //(9)!
        nonProxyHosts = [ "host1", "host2" ] //(10)!
    }
}
  1. Whether to follow HTTP redirects (default: true)
  2. Which HTTP protocol version to use, available values: HTTP_1_1 / HTTP_2 (default: HTTP_1_1)
  3. Maximum time to establish a connection (default: 5s)
  4. Maximum time to read a response (default: 2m)
  5. Whether to use https_proxy / HTTPS_PROXY / http_proxy / HTTP_PROXY and no_proxy / NO_PROXY environment variables for proxy configuration (default: false)
  6. Proxy host (required if the proxy section is present, no default)
  7. Proxy port (required if the proxy section is present, no default)
  8. Proxy user (optional, no default)
  9. Proxy password (optional, no default)
  10. Hosts to exclude from proxying (optional, no default)
httpClient:
  jdk:
    followRedirects: true #(1)!
    httpVersion: "HTTP_1_1" #(2)!
  connectTimeout: "5s" #(3)!
  readTimeout: "2m" #(4)!
  useEnvProxy: false #(5)!
  proxy:
    host: "localhost" #(6)!
    port: 8090 #(7)!
    user: "user" #(8)!
    password: "password" #(9)!
    nonProxyHosts: [ "host1", "host2" ] #(10)!
  1. Whether to follow HTTP redirects (default: true)
  2. Which HTTP protocol version to use, available values: HTTP_1_1 / HTTP_2 (default: HTTP_1_1)
  3. Maximum time to establish a connection (default: 5s)
  4. Maximum time to read a response (default: 2m)
  5. Whether to use https_proxy / HTTPS_PROXY / http_proxy / HTTP_PROXY and no_proxy / NO_PROXY environment variables for proxy configuration (default: false)
  6. Proxy host (required if the proxy section is present, no default)
  7. Proxy port (required if the proxy section is present, no default)
  8. Proxy user (optional, no default)
  9. Proxy password (optional, no default)
  10. Hosts to exclude from proxying (optional, no default)

Configurer

@Component
public final class SomeConfigurer implements Configurer<java.net.http.HttpClient.Builder> {

    @Override
    public java.net.http.HttpClient.Builder configure(java.net.http.HttpClient.Builder builder) {
        return builder.sslContext(SSLContext.getDefault());
    }
}
@Component
class SomeConfigurer : Configurer<java.net.http.HttpClient.Builder> {

    override fun configure(builder: java.net.http.HttpClient.Builder): java.net.http.HttpClient.Builder {
        return builder.sslContext(SSLContext.getDefault())
    }
}

Declarative Client

It is suggested to use special annotations to create a declarative client:

  • @HttpClient - indicates that the interface is a declarative HTTP client
  • @HttpRoute - specifies HTTP request type and request path
@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello();
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello()
}

HttpMethod is a holder of String constants (GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH, QUERY), so method = "GET" is equally valid.

A client interface may extend other interfaces: routes declared in a supertype are implemented as well, and an overriding method in the client replaces the inherited route.

Client Configuration

By default, configuration for a particular @HttpClient implementation is looked up at httpClient.{lower case class name}. If the path must be specified explicitly, pass it as the annotation value:

@HttpClient("httpClient.someClient") //(1)!
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello();
}
  1. The path to the configuration of this particular client
@HttpClient("httpClient.someClient") //(1)!
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello()
}
  1. The path to the configuration of this particular client

@HttpClient can also specify tags for injected components:

  • httpClientTag — tag used to select a particular transport HttpClient when the graph contains several implementations with different @Tag values
  • telemetryTag — tag used to select a particular HttpClientTelemetryFactory
@HttpClient(
    value = "httpClient.someClient",
    httpClientTag = CustomTransport.class,
    telemetryTag = CustomTelemetry.class
)
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello();
}
@HttpClient(
    value = "httpClient.someClient",
    httpClientTag = CustomTransport::class,
    telemetryTag = CustomTelemetry::class
)
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello()
}

These tags select which component to inject when several are present in the graph. The other half is providing that component under the same @Tag. For example, to give one client a dedicated transport (a separate connection pool, different timeouts, a custom OkHttpConfigurer, etc.) provide a tagged HttpClient and reference the same tag class from httpClientTag:

public final class CustomTransport { } //(1)!

@Module
public interface TransportModule {

    @Tag(CustomTransport.class) //(2)!
    default HttpClient customHttpClient(okhttp3.OkHttpClient okHttp) {
        return new OkHttpClient(okHttp); //(3)!
    }
}
  1. A marker class used only as a tag
  2. Provided under the same tag referenced by httpClientTag
  3. ru.tinkoff.kora.http.client.ok.OkHttpClient — the Kora transport wrapping an okhttp3.OkHttpClient
class CustomTransport //(1)!

@Module
interface TransportModule {

    @Tag(CustomTransport::class) //(2)!
    fun customHttpClient(okHttp: okhttp3.OkHttpClient): HttpClient {
        return OkHttpClient(okHttp) //(3)!
    }
}
  1. A marker class used only as a tag
  2. Provided under the same tag referenced by httpClientTag
  3. ru.tinkoff.kora.http.client.ok.OkHttpClient — the Kora transport wrapping an okhttp3.OkHttpClient

  4. A marker class used only as a tag

  5. Provided under the same tag referenced by httpClientTag

telemetryTag works the same way for a tagged HttpClientTelemetryFactory. When a tag is omitted, the default untagged transport and telemetry are used.

Basic declarative client configuration parameters:

httpClient {
    someClient {
        url = "https://localhost:8090" //(1)!
        requestTimeout = "10s" //(2)!
    }
}
  1. Base service URL where requests will be sent (required, no default)
  2. Maximum request time (optional, no default)
httpClient:
  someClient:
    url: "https://localhost:8090" #(1)!
    requestTimeout: "10s" #(2)!
  1. Base service URL where requests will be sent (required, no default)
  2. Maximum request time (optional, no default)
Full Configuration

Example configuration in the case of the httpClient.someClient path described in the DeclarativeHttpClientConfig and HttpClientTelemetryConfig classes:

httpClient {
    someClient {
        url = "https://localhost:8090" //(1)!
        requestTimeout = "10s" //(2)!
        telemetry {
            logging {
                enabled = false //(3)!
                mask = "***" //(4)!
                maskQueries = [ ] //(5)!
                maskHeaders = [ "authorization", "cookie", "set-cookie" ] //(6)!
                pathFull = false //(7)!
                maxRequestBodyLogSize = "2MiB" //(8)!
                maxResponseBodyLogSize = "2MiB" //(9)!
            }
            metrics {
                enabled = false //(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)!
                pathFull = true //(14)!
                attributes = { // (15)!
                    "key1" = "value1"
                    "key2" = "value2"
                }
            }
        }
    }
}
  1. Base service URL where requests will be sent (required, no default)
  2. Maximum request time: may include DNS resolution, connection, request body write, server processing, and response body read. If the call requires redirects or retries, they must all finish within one period (optional, no default)
  3. Enables module logging (default: false)
  4. Mask used to hide specified headers and request or response parameters (default: ***)
  5. List of request parameters to hide (default: [])
  6. List of request or response headers to hide (default: [ "authorization", "cookie", "set-cookie" ])
  7. Whether to log the full request path instead of the route template; when not specified, the full path is logged only at TRACE level and the template otherwise (optional, no default)
  8. Maximum request body size that is still written to the log; a larger body is skipped with a warning (default: 2MiB)
  9. Maximum response body size that is still written to the log; a larger body is skipped with a warning (default: 2MiB)
  10. Enables module metrics (default: false)
  11. Configures SLO buckets in milliseconds for metrics (default: io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO)
  12. Configures metric tags (default: {})
  13. Enables module tracing (default: true)
  14. Whether the span carries the full url.full attribute instead of only url.path (default: true)
  15. Configures tracing attributes (default: {})
httpClient:
  someClient:
    url: "https://localhost:8090" #(1)!
    requestTimeout: "10s" #(2)!
    telemetry:
      logging:
        enabled: false #(3)!
        mask: "***" #(4)!
        maskQueries: [ ] #(5)!
        maskHeaders: [ "authorization", "cookie", "set-cookie" ] #(6)!
        pathFull: false #(7)!
        maxRequestBodyLogSize: "2MiB" #(8)!
        maxResponseBodyLogSize: "2MiB" #(9)!
      metrics:
        enabled: false #(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)!
        pathFull: true #(14)!
        attributes: #(15)!
          key1: value1
          key2: value2
  1. Base service URL where requests will be sent (required, no default)
  2. Maximum request time: may include DNS resolution, connection, request body write, server processing, and response body read. If the call requires redirects or retries, they must all finish within one period (optional, no default)
  3. Enables module logging (default: false)
  4. Mask used to hide specified headers and request or response parameters (default: ***)
  5. List of request parameters to hide (default: [])
  6. List of request or response headers to hide (default: [ "authorization", "cookie", "set-cookie" ])
  7. Whether to log the full request path instead of the route template; when not specified, the full path is logged only at TRACE level and the template otherwise (optional, no default)
  8. Maximum request body size that is still written to the log; a larger body is skipped with a warning (default: 2MiB)
  9. Maximum response body size that is still written to the log; a larger body is skipped with a warning (default: 2MiB)
  10. Enables module metrics (default: false)
  11. Configures SLO buckets in milliseconds for metrics (default: io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO)
  12. Configures metric tags (default: {})
  13. Enables module tracing (default: true)
  14. Whether the span carries the full url.full attribute instead of only url.path (default: true)
  15. Configures tracing attributes (default: {})
Metrics and logging are disabled by default

In Kora 2.0 telemetry.metrics.enabled and telemetry.logging.enabled default to false, and telemetry.tracing.enabled to true. Nothing fails and nothing is written to the log when metrics are off — http.client.request.duration simply never appears. Enable them explicitly per client.

Method Configuration

For a particular method, some parameters can be configured separately. The method configuration path is determined by the client path and the method name: if the client path is httpClient.someClient, the final path for the hello method is httpClient.someClient.hello.

Method configuration is applied over client configuration: method requestTimeout replaces the client value, and method telemetry settings override only explicitly specified fields.

Basic method configuration parameters:

httpClient {
    someClient {
        hello {
            requestTimeout = "10s" //(1)!
        }
    }
}
  1. Maximum request time (optional, no default)
httpClient:
  someClient:
    hello:
      requestTimeout: "10s" #(1)!
  1. Maximum request time (optional, no default)
Full Configuration

Full method configuration example described in the HttpClientOperationConfig class. Every field is optional: an omitted field inherits the client value.

httpClient {
    someClient {
        hello {
            requestTimeout = "10s" //(1)!
            telemetry {
                logging {
                    enabled = false //(2)!
                    mask = "***" //(3)!
                    maskQueries = [ ] //(4)!
                    maskHeaders = [ "authorization", "cookie", "set-cookie" ] //(5)!
                    pathFull = false //(6)!
                    maxRequestBodyLogSize = "2MiB" //(7)!
                    maxResponseBodyLogSize = "2MiB" //(8)!
                }
                metrics {
                    enabled = false //(9)!
                    slo = [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] //(10)!
                    tags = { // (11)!
                        "key1" = "value1"
                        "key2" = "value2"
                    }
                }
                tracing {
                    enabled = true //(12)!
                    pathFull = true //(13)!
                    attributes = { // (14)!
                        "key1" = "value1"
                        "key2" = "value2"
                    }
                }
            }
        }
    }
}
  1. Maximum request time: may include DNS resolution, connection, request body write, server processing, and response body read. If the call requires redirects or retries, they must all finish within one period (optional, inherits the client value)
  2. Enables module logging (optional, inherits the client value)
  3. Mask used to hide specified headers and request or response parameters (optional, inherits the client value)
  4. List of request parameters to hide (optional, inherits the client value)
  5. List of request or response headers to hide (optional, inherits the client value)
  6. Whether to log the full request path instead of the route template (optional, inherits the client value)
  7. Maximum request body size that is still written to the log (optional, inherits the client value)
  8. Maximum response body size that is still written to the log (optional, inherits the client value)
  9. Enables module metrics (optional, inherits the client value)
  10. Configures SLO buckets in milliseconds for metrics (optional, inherits the client value)
  11. Configures metric tags (optional, inherits the client value)
  12. Enables module tracing (optional, inherits the client value)
  13. Whether the span carries the full url.full attribute instead of only url.path (optional, inherits the client value)
  14. Configures tracing attributes (optional, inherits the client value)
httpClient:
  someClient:
    hello:
      requestTimeout: "10s" #(1)!
      telemetry:
        logging:
          enabled: false #(2)!
          mask: "***" #(3)!
          maskQueries: [ ] #(4)!
          maskHeaders: [ "authorization", "cookie", "set-cookie" ] #(5)!
          pathFull: false #(6)!
          maxRequestBodyLogSize: "2MiB" #(7)!
          maxResponseBodyLogSize: "2MiB" #(8)!
        metrics:
          enabled: false #(9)!
          slo: [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] #(10)!
          tags: #(11)!
            key1: value1
            key2: value2
        tracing:
          enabled: true #(12)!
          pathFull: true #(13)!
          attributes: #(14)!
            key1: value1
            key2: value2
  1. Maximum request time: may include DNS resolution, connection, request body write, server processing, and response body read. If the call requires redirects or retries, they must all finish within one period (optional, inherits the client value)
  2. Enables module logging (optional, inherits the client value)
  3. Mask used to hide specified headers and request or response parameters (optional, inherits the client value)
  4. List of request parameters to hide (optional, inherits the client value)
  5. List of request or response headers to hide (optional, inherits the client value)
  6. Whether to log the full request path instead of the route template (optional, inherits the client value)
  7. Maximum request body size that is still written to the log (optional, inherits the client value)
  8. Maximum response body size that is still written to the log (optional, inherits the client value)
  9. Enables module metrics (optional, inherits the client value)
  10. Configures SLO buckets in milliseconds for metrics (optional, inherits the client value)
  11. Configures metric tags (optional, inherits the client value)
  12. Enables module tracing (optional, inherits the client value)
  13. Whether the span carries the full url.full attribute instead of only url.path (optional, inherits the client value)
  14. Configures tracing attributes (optional, inherits the client value)

Request

This section describes HTTP request transformations for a declarative HTTP client. Use special annotations to specify request parameters.

Parameter Conversion

HttpClientParameterWriter<T> converts a parameter value to a string before Kora puts it into a path, query parameter, header, or cookie. The interface has one method:

public interface HttpClientParameterWriter<T> {
    String convert(T value);
}

String, Integer, Long, Boolean and Java primitives are written directly and need no writer at all. For every other type Kora looks up an HttpClientParameterWriter<T> component by the exact parameter type. If the parameter has type Map<String, T>, the writer is looked up for value type T; if Map<String, List<T>> is used, it is applied to every list item; for List<T> / Set<T> / Collection<T> it is applied to every element.

Built-in writers are available for Boolean, Short, Integer, Long, Double, Float, UUID, BigDecimal, BigInteger, Duration, OffsetTime, OffsetDateTime, LocalTime, LocalDate, LocalDateTime, ZonedDateTime, and Instant. Date and time types are written in ISO format. For custom types, provide an HttpClientParameterWriter<T> component:

public record UserId(long value) {}

@Module
public interface UserIdModule {

    default HttpClientParameterWriter<UserId> userIdParameterWriter() {
        return value -> Long.toString(value.value());
    }
}
data class UserId(val value: Long)

@Module
interface UserIdModule {

    fun userIdParameterWriter(): HttpClientParameterWriter<UserId> {
        return HttpClientParameterWriter { value -> value.value.toString() }
    }
}

After that, the type can be used in client parameters:

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
    User get(@Path("id") UserId id);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
    fun get(@Path("id") id: UserId): User
}

For enums, EnumHttpClientParameterWriter from io.koraframework.http.client.common.request.mapper builds a writer from the enum constants and a mapping function; it is also what the OpenAPI generator emits for enum parameters.

HttpClientParameterWriter<T> was not found

The build fails with No component found for dependency: HttpClientParameterWriter<T>. Either the type is custom and no writer component exists, or the writer has a @Tag that the parameter does not. Declare an HttpClientParameterWriter<T> component for that exact type.

Path parameter

@Path - denotes the value of the request path part, the parameter itself is specified in {quote} in the path and the name of the parameter is specified in value or is equal to the name of the method argument by default. Path values are URL-encoded, so a space becomes %20.

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/{pathName}")
    void hello(@Path("pathName") String pathValue);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/{pathName}")
    fun hello(@Path("pathName") pathValue: String)
}

Every {name} placeholder in the path must have a matching @Path parameter; otherwise the build fails with Path template contains parameters that have no matching @Path method parameter.

Query parameter

@Query - query parameter value, the name is specified in value or defaults to the method argument name. Single values, List<T>, Set<T>, Collection<T>, Map<String, T>, and Map<String, List<T>> are supported. For non-string values, an available HttpClientParameterWriter<T> is used. An empty collection is sent as a parameter without a value.

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello(@Query("queryName") String queryValue,
               @Query("queryNameList") List<String> queryValues);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(@Query("queryName") queryValue: String,
              @Query("queryNameList") queryValues: List<String>)
}

Query parameters can also be sent in key-value format using Map, where the key is the parameter name and must be String. If a Map value is a list, every item is sent as a separate value of the same parameter. If a list item is null, the parameter is sent without a value.

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello(@Query Map<String, String> queryValues);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(@Query queryValues: Map<String, String>)
}

@Header - value of request header, parameter name is specified in value or defaults to the method argument name. Single values, List<T>, Set<T>, Collection<T>, Map<String, T>, and a ready HttpHeaders object are supported.

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello(@Header("headerName") String headerValue,
               @Header("headerNameList") List<String> headerValues);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(@Header("headerName") headerValue: String,
              @Header("headerNameList") headerValues: List<String>)
}

Headers can be sent in key-value format using HttpHeaders or Map, where the key is the header name and must be String. For non-string values, an available HttpClientParameterWriter<T> is used:

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello(@Header HttpHeaders headers);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(@Header headers: HttpHeaders)
}

Request body

Specifying the body of a request requires using a method argument without special annotations. Out of the box byte[], ByteBuffer, String, HttpBodyOutput, FormUrlEncoded and FormMultipart are supported, because HttpClientRequestMapperModule provides HttpClientRequestMapper implementations for exactly those types.

Json

In order to indicate that the body is Json and needs to embed JsonWriter<T>, that is required to use the special @Json tag annotation:

@HttpClient
public interface SomeClient {

    record MyBody(String name) { }

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    void hello(@Json MyBody body); //(1)!
}
  1. Specifies that the body should be written as Json
@HttpClient
interface SomeClient {

    data class MyBody(val name: String)

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun hello(@Json body: MyBody) //(1)!
}
  1. Specifies that the body should be written as Json

Json module is required, and a JsonWriter<MyBody> must exist — usually by annotating the type itself with @Json.

Text form

Use FormUrlEncoded (from ru.tinkoff.kora.http.common.form) as the body argument type to send a body with the application/x-www-form-urlencoded content type (form data). No @Json or @Mapping annotation is needed — Kora has a built-in writer for this type.

FormUrlEncoded is a collection of named parts, where every part can hold one or several values:

  • FormUrlEncoded.FormPart(String name, String value) — a part with a single value
  • FormUrlEncoded.FormPart(String name, List<String> values) — a part with several values (the field is repeated for each value)
  • new FormUrlEncoded(FormPart...) / new FormUrlEncoded(List<FormPart>) / new FormUrlEncoded(Map<String, FormPart>) — construct the form; parts declared with the same name are merged into one

Declare the client method with a FormUrlEncoded parameter:

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.POST, path = "/form/encoded")
    HttpResponseEntity<String> formEncoded(FormUrlEncoded body);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.POST, path = "/form/encoded")
    fun formEncoded(body: FormUrlEncoded): HttpResponseEntity<String>
}

An example of a call with this form:

var response = someClient.formEncoded(new FormUrlEncoded(
        new FormUrlEncoded.FormPart("name", "Bob"),
        new FormUrlEncoded.FormPart("password", "12345"),
        new FormUrlEncoded.FormPart("roles", List.of("admin", "user")) //(1)!
));
  1. Sent as roles=admin&roles=user
val response = someClient.formEncoded(
    FormUrlEncoded(
        FormUrlEncoded.FormPart("name", "Bob"),
        FormUrlEncoded.FormPart("password", "12345"),
        FormUrlEncoded.FormPart("roles", listOf("admin", "user")) //(1)!
    )
)
  1. Sent as roles=admin&roles=user
Binary Form

Use FormMultipart (from ru.tinkoff.kora.http.common.form) as the body argument type to send a multipart/form-data body (binary form), typically used for file uploads mixed with text fields. No @Json or @Mapping annotation is needed.

FormMultipart is a list of parts built through static factory methods:

  • FormMultipart.data(String name, String value) — a plain text field
  • FormMultipart.file(String name, String fileName, String contentType, byte[] content) — a file part loaded into memory (fileName and contentType may be null)
  • FormMultipart.file(String name, String fileName, String contentType, Flow.Publisher<ByteBuffer> content) — a streamed file part for large content that should not be buffered fully in memory
  • new FormMultipart(List<? extends FormPart>) — construct the form from the parts

Declare the client method with a FormMultipart parameter:

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.POST, path = "/form/multipart")
    HttpResponseEntity<String> formMultipart(FormMultipart body);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.POST, path = "/form/multipart")
    fun formMultipart(body: FormMultipart): HttpResponseEntity<String>
}

An example of a call with this form:

var response = someClient.formMultipart(new FormMultipart(List.of(
        FormMultipart.data("field1", "some data content"), //(1)!
        FormMultipart.file("field2", "example1.txt", "text/plain",
                "some file content".getBytes(StandardCharsets.UTF_8)) //(2)!
)));
  1. A plain text field
  2. A file part with file name and content type
val response = someClient.formMultipart(
    FormMultipart(
        listOf(
            FormMultipart.data("field1", "some data content"), //(1)!
            FormMultipart.file(
                "field2",
                "example1.txt",
                "text/plain",
                "some file content".toByteArray(StandardCharsets.UTF_8)
            ) //(2)!
        )
    )
)
  1. A plain text field
  2. A file part with file name and content type

FormMultipart.file(String name, String fileName, HttpBodyOutput content) sends a part as a stream instead of a byte array.

Custom body

If the body needs to be written in a way different from the standard mechanisms, it is possible to use a special HttpClientRequestMapper interface to implement your custom logic:

@HttpClient
public interface SomeClient {

    record UserBody(String id) {}

    final class UserRequestMapper implements HttpClientRequestMapper<UserBody> {

        @Override
        public HttpBodyOutput apply(UserBody value) {
            return HttpBody.plaintext(value.id());
        }
    }

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    HttpResponseEntity<String> hello(@Mapping(UserRequestMapper.class) UserBody body);
}
@HttpClient
interface SomeClient {

    data class UserBody(val id: String)

    class UserRequestMapper : HttpClientRequestMapper<UserBody> {

        override fun apply(value: UserBody): HttpBodyOutput {
            return HttpBody.plaintext(value.id)
        }
    }

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun hello(@Mapping(UserRequestMapper::class) body: UserBody): HttpResponseEntity<String>
}

Example: Protobuf Serialization

Note that HttpBody.of takes the content type first and the payload second:

@HttpClient
public interface ProtobufClient {

    final class ProtobufRequestMapper implements HttpClientRequestMapper<MyMessage> {

        @Override
        public HttpBodyOutput apply(MyMessage value) {
            byte[] protobufBytes = value.toByteArray();
            return HttpBody.of("application/x-protobuf", protobufBytes);
        }
    }

    @HttpRoute(method = HttpMethod.POST, path = "/message")
    void sendMessage(@Mapping(ProtobufRequestMapper.class) MyMessage message);
}
@HttpClient
interface ProtobufClient {

    class ProtobufRequestMapper : HttpClientRequestMapper<MyMessage> {

        override fun apply(value: MyMessage): HttpBodyOutput {
            val protobufBytes = value.toByteArray()
            return HttpBody.of("application/x-protobuf", protobufBytes)
        }
    }

    @HttpRoute(method = HttpMethod.POST, path = "/message")
    fun sendMessage(@Mapping(ProtobufRequestMapper::class) message: MyMessage)
}
When a mapper needs @Component

A mapper referenced by @Mapping that is final (Java) or not open (Kotlin) and has a single public no-argument constructor is instantiated by the generated client itself — it must not be a graph component. Any other mapper — one with constructor dependencies such as a JsonReader<T>, an open class, or one with several constructors — is taken from the dependency container and therefore must be declared as @Component. Decide by the constructor, not by the annotation above the method.

@Cookie - Cookie value, the parameter name is specified in value or defaults to the method argument name. Single values, List<T>, Set<T>, Collection<T>, Map<String, T>, and a ready Cookie object are supported. Every cookie is written as its own Cookie header value in name=value form.

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello(@Cookie("cookieName") String cookieValue);
}
@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(@Cookie("cookieName") cookieValue: String)
}

Required parameters

By default, all arguments declared in a method are required (NotNull).

By default, all arguments declared in a method that do not use the Kotlin Nullability syntax are considered required (NotNull).

Optional parameters

If a method argument is optional, that is, it may not exist then, @Nullable annotation can be used:

@HttpClient
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello(@Nullable @Query("queryValue") String queryValue); //(1)!
}
  1. Kora is built on JSpecify, so org.jspecify.annotations.Nullable is the recommended annotation; any annotation whose simple name is Nullable is accepted.

It is expected to use the Kotlin Nullability syntax and mark such a parameter as Nullable:

@HttpClient
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(@Query("queryValue") queryValue: String?)
}

A null query parameter, header, or cookie is simply omitted from the request.

Response

The section describes the transformation of an HTTP response from a declarative HTTP client.

Response body

Kora ships HttpClientResponseMapper implementations for a limited set of types, all declared in HttpClientResponseMapperModule:

Return type Requires
void nothing, the body is not read
String nothing
byte[] nothing
ByteBuffer nothing
HttpBodyInput nothing, the body stays a stream
T with @Json a JsonReader<T>
HttpResponseEntity<T> an HttpClientResponseMapper<T> for the payload
Either<T, E> an HttpClientResponseMapper for each of T and E

Any other type needs a mapper of its own, see Custom response.

Json

If the body is to be read as Json, the @Json annotation must be used over the method to specify handler with JsonReader<T>.

@HttpClient
public interface SomeClient {

    record MyResponse(String name) { }

    @Json //(1)!
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    MyResponse hello();
}
  1. Indicates that the response should be read as Json
@HttpClient
interface SomeClient {

    data class MyResponse(val name: String)

    @Json //(1)!
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(): MyResponse
}
  1. Indicates that the response should be read as Json

Json module is required.

Response Entity

If the intention is to read the body and also get the headers and status code of the response, it is intended to use HttpResponseEntity, which is a wrapper over the response body and exposes code(), headers() and body().

Below is an example similar to the Json example along with the HttpResponseEntity wrapper:

@HttpClient
public interface SomeClient {

    record MyResponse(String name) { }

    @Json
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    HttpResponseEntity<MyResponse> hello();
}
@HttpClient
interface SomeClient {

    data class MyResponse(val name: String)

    @Json
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(): HttpResponseEntity<MyResponse>
}

Kora builds the entity mapper itself from the payload mapper, so HttpResponseEntity<Void> — the usual shape when only the status code matters — needs an HttpClientResponseMapper<Void> in the graph. There is no built-in one, so declare it as a component and do not point at it with @Mapping: with @Mapping the mapper would have to produce the whole HttpResponseEntity<Void>, while the framework's template factory expects a payload mapper and wraps it into the entity itself.

@HttpClient("httpClient.userApi")
public interface UserApiClient {

    @Component
    final class VoidResponseMapper implements HttpClientResponseMapper<Void> {

        @Override
        public Void apply(HttpClientResponse response) throws IOException {
            try (var body = response.body()) {
                body.asInputStream().readAllBytes();
            }
            return null;
        }
    }

    @HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
    HttpResponseEntity<Void> deleteUser(@Path String userId);
}
@HttpClient("httpClient.userApi")
interface UserApiClient {

    @Component
    class VoidResponseMapper : HttpClientResponseMapper<Void> {

        override fun apply(response: HttpClientResponse): Void? {
            response.body().use { body ->
                body.asInputStream().readAllBytes()
            }
            return null
        }
    }

    @HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
    fun deleteUser(@Path userId: String): HttpResponseEntity<Void>
}

Without that component the build fails with No component found for dependency: HttpClientResponseMapper<java.lang.Void>.

Either

Either<T, E> describes a call where a non-successful status code is a normal outcome rather than an exception. Kora maps a 2xx response with the mapper of T into Either.Left and any other status code with the mapper of E into Either.Right, and never throws HttpClientResponseException for such a method.

@HttpClient
public interface SomeClient {

    record Success(String id) {}

    record Error(String message) {}

    @HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
    Either<@Json Success, @Json Error> get(@Path String id); //(1)!
}
  1. @Json is a type-use annotation here, so the success and error payloads can be tagged independently
@HttpClient
interface SomeClient {

    data class Success(val id: String)

    data class Error(val message: String)

    @HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
    fun get(@Path id: String): Either<@Json Success, @Json Error> //(1)!
}
  1. @Json is a type-use annotation here, so the success and error payloads can be tagged independently

Either exposes isLeft() / isRight() and the nullable accessors left() / right(). HttpResponseEntity<Either<T, E>> is supported as well when the status code and headers are also required.

Custom response

If you need to read the response in a different way, you can use the special HttpClientResponseMapper interface:

@HttpClient
public interface SomeClient {

    record MyResponse(String name) { }

    final class ResponseMapper implements HttpClientResponseMapper<MyResponse> {

        @Override
        public MyResponse apply(HttpClientResponse response) throws IOException, HttpClientDecoderException {
            try (var is = response.body().asInputStream()) {
                final byte[] bytes = is.readAllBytes();
                var body = new String(bytes, StandardCharsets.UTF_8);
                return new MyResponse(body);
            }
        }
    }

    @Mapping(ResponseMapper.class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    MyResponse hello();
}
@HttpClient
interface SomeClient {

    data class MyResponse(val name: String)

    class ResponseMapper : HttpClientResponseMapper<MyResponse> {

        override fun apply(response: HttpClientResponse): MyResponse {
            response.body().asInputStream().use {
                val bytes: ByteArray = it.readAllBytes()
                val body = String(bytes, StandardCharsets.UTF_8)
                return MyResponse(body)
            }
        }
    }

    @Mapping(ResponseMapper::class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(): MyResponse
}
A @Mapping mapper handles every status code

When a method declares @Mapping, Kora stops checking for a successful status code and hands every response to that mapper, including 4xx and 5xx. Throw from the mapper yourself if a non-successful code must remain an error. A @Tag on the method only picks which HttpClientResponseMapper component is injected — the 2xx check still applies and a non-successful code still throws HttpClientResponseException.

Example: Error Handling in Mapper

@HttpClient
public interface ApiClient {

    record ApiResponse(String status, String data) {}

    @Component
    final class SafeResponseMapper implements HttpClientResponseMapper<ApiResponse> {

        private final JsonReader<ApiResponse> jsonReader;

        public SafeResponseMapper(JsonReader<ApiResponse> jsonReader) {
            this.jsonReader = jsonReader;
        }

        @Override
        public ApiResponse apply(HttpClientResponse response) throws IOException {
            int code = response.code();
            final byte[] body;
            try (var is = response.body().asInputStream()) {
                body = is.readAllBytes();
            }

            if (code >= 400) {
                // Handle error: log or throw exception
                throw new HttpClientResponseException(code, response.headers(), body);
            }

            if (body.length == 0) {
                return null;
            }
        }
    }

    @HttpRoute(method = HttpMethod.GET, path = "/api/data")
    @Mapping(SafeResponseMapper.class)
    ApiResponse getData();
}
@HttpClient
interface ApiClient {

    data class ApiResponse(val status: String, val data: String)

    @Component
    class SafeResponseMapper(
        private val jsonReader: JsonReader<ApiResponse>
    ) : HttpClientResponseMapper<ApiResponse> {

        override fun apply(response: HttpClientResponse): ApiResponse {
            val code = response.code()
            val body = response.body().asInputStream().use { it.readAllBytes() }

            if (code >= 400) {
                // Handle error: log or throw exception
                throw HttpClientResponseException(code, response.headers(), body)
            }

            if (body.isEmpty()) {
                return null
            }
        }
    }

    @HttpRoute(method = HttpMethod.GET, path = "/api/data")
    @Mapping(SafeResponseMapper::class)
    fun getData(): ApiResponse
}

This mapper takes a JsonReader in its constructor, so it is a graph component and carries @Component.

Response Error

By default, when neither a @Mapping mapper nor @ResponseCodeMapper is specified, conversion is applied only for 2xx HTTP response codes. For all other codes, HttpClientResponseException is thrown. It contains the HTTP response code, response body, and response headers.

Either<T, E> and HttpResponseEntity<Either<T, E>> are the exception to this rule: they map every status code and never throw.

Client Exceptions

All standard HTTP client exceptions inherit from HttpClientException, which is a RuntimeException. This lets you catch a specific error type or all client errors with one common type:

try {
    client.getUser("123");
} catch (HttpClientResponseException e) {
    var code = e.getCode();
    var headers = e.getHeaders();
    var body = e.getBytes();
} catch (HttpClientException e) {
    throw e;
}

Main exception types:

  • HttpClientResponseException — response was received, but its code was not handled as successful. Contains getCode(), getHeaders(), and getBytes().
  • HttpClientTimeoutException — request, connection, or read timeout expired.
  • HttpClientConnectionException — error while establishing or maintaining a connection to the remote host.
  • HttpClientEncoderException — error while converting a user value into a request body.
  • HttpClientDecoderException — error while converting a response body into a user type.
  • HttpClientUnknownException — other transport client error that did not match a more specific category.

HttpClientResponseException is created by HttpClientResponseException.fromResponse(response) after reading the response body. If the whole body is already buffered it is captured in full; otherwise only the first 4096 bytes are read into getBytes() so that a failing call never has to buffer an arbitrarily large error page.

Conversion by Code

If specific conversions are required depending on the HTTP status code of the response, you can use the @ResponseCodeMapper annotation to specify a correspondence between the HTTP status code and the HttpClientResponseMapper resolver.

You can also use ResponseCodeMapper.DEFAULT to define default behavior for all unlisted HTTP codes. If mapper is specified for a code, that particular HttpClientResponseMapper is used. If type is specified, Kora selects a response mapper for that type and then casts the result to the method return type. This is useful for closed response hierarchies where different HTTP statuses correspond to different result subtypes. If neither is specified, Kora asks the graph for an HttpClientResponseMapper of the method return type (HttpClientResponseMapper<Void> for a void method). A status code that is not listed and has no DEFAULT entry still throws HttpClientResponseException.

@HttpClient
public interface SomeClient {

    record UserResponse(UserResponse.Payload payload, UserResponse.Error error) {

        public record Error(int code, String message) {}

        public record Payload(String message) {}
    }

    @ResponseCodeMapper(code = ResponseCodeMapper.DEFAULT, mapper = ResponseErrorMapper.class)
    @ResponseCodeMapper(code = 200, mapper = ResponseSuccessMapper.class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    UserResponse hello();
}
@HttpClient
interface SomeClient {

    data class UserResponse(val payload: Payload?, val error: Error?) {

        data class Error(val code: Int, val message: String)

        data class Payload(val message: String)
    }

    @ResponseCodeMapper(code = ResponseCodeMapper.DEFAULT, mapper = ResponseErrorMapper::class)
    @ResponseCodeMapper(code = 200, mapper = ResponseSuccessMapper::class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(): UserResponse
}

In the example above, ResponseSuccessMapper will be used for status code 200, and for all other status codes the ResponseErrorMapper will be used.

Example with the type parameter:

@HttpClient
public interface SomeClient {

    sealed interface UserResponse permits Success, Error {}

    record Success(String id) implements UserResponse {}

    record Error(String message) implements UserResponse {}

    @Json
    @ResponseCodeMapper(code = 200, type = Success.class)
    @ResponseCodeMapper(code = 404, type = Error.class)
    @HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
    UserResponse get(@Path String id);
}
@HttpClient
interface SomeClient {

    sealed interface UserResponse

    data class Success(val id: String) : UserResponse

    data class Error(val message: String) : UserResponse

    @Json
    @ResponseCodeMapper(code = 200, type = Success::class)
    @ResponseCodeMapper(code = 404, type = Error::class)
    @HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
    fun get(@Path id: String): UserResponse
}

If the mapped type is not assignable to the method return type, Kora treats the mapper result as an exception and throws it instead of returning it — that is how an error branch can be modelled as a thrown exception for a specific status code.

Signatures

Declarative HTTP client methods are blocking:

The T refers to the type of the return value. It can be a body type (void, String, byte[], a @Json type, etc.), or HttpResponseEntity<T> to also read the status code and headers. A @Nullable T return allows an empty successful body.

  • T myMethod()
  • void myMethod()

By T we mean the type of the return value, which may be T, T? (nullable for an empty successful body), or Unit. T can be a body type or HttpResponseEntity<T> to also read the status code and headers.

  • myMethod(): Tsynchronous (blocking): the calling thread waits for the response

By default a non-2xx response throws HttpClientResponseException regardless of signature; use @ResponseCodeMapper or HttpResponseEntity to handle other status codes without an exception.

Asynchronous signatures are not supported

In Kotlin a suspend client method is a compile-time error: Suspend methods are not supported by the HTTP client generator. In Java a CompletionStage<T> or Mono<T> return type only produces the warning Method has async signature, this might not work correctly — the generated code still performs a blocking call and the type will not be satisfied.

Run independent calls in parallel with virtual threads instead, for example with StructuredTaskScope:

try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.<Object>awaitAllSuccessfulOrThrow())) {
    var profile = scope.fork(() -> profileHttpClient.getProfile(userId));
    var recommendations = scope.fork(() -> recommendationsHttpClient.getForUser(userId));
    scope.join();
    return new Dashboard(profile.get(), recommendations.get());
}

Interceptors

You can create interceptors to change behavior or create additional behavior using the HttpClientInterceptor interface. Interceptors can be attached to specific methods or the entire @HttpClient class using the @InterceptWith annotation. Kora ships ready-made interceptors (such as Root URL and the authorization interceptors), and you can implement your own — see method-level and class-level examples below.

public interface HttpClientInterceptor {

    HttpClientResponse processRequest(InterceptChain chain, HttpClientRequest request) throws Exception; //(1)!

    interface InterceptChain {
        HttpClientResponse process(HttpClientRequest request) throws Exception; //(2)!
    }
}
  1. Called for every request the interceptor is attached to
  2. Continues the chain (the next interceptor, or the actual transport call)

An interceptor can:

  • Modify the request before sending — rebuild it via request.toBuilder() (add a header, change the URI, replace the body), then pass the new request to chain.process(ctx, newRequest)
  • Continue the chain — return chain.process(ctx, request) unchanged
  • Short-circuit — return a response without calling chain.process(...) (for example a cached response)
  • Inspect or transform the response — call chain.process(...) and chain a thenApply / thenCompose / exceptionally on the returned CompletionStage
  • Fail the call — throw an exception or return a failed CompletionStage to break the chain

Example that adds a header to every request and inspects the response status:

@Component
public final class TracingInterceptor implements HttpClientInterceptor {

    @Override
    public CompletionStage<HttpClientResponse> processRequest(Context ctx, InterceptChain chain, HttpClientRequest request) throws Exception {
        HttpClientRequest modified = request.toBuilder()
            .header("x-request-id", UUID.randomUUID().toString()) //(1)!
            .build();

        return chain.process(ctx, modified).thenApply(response -> {
            if (response.code() >= 500) {
                // observe server errors
            }
            return response;
        });
    }
}
  1. request.toBuilder() returns an HttpClientRequestBuilder initialized from the current request
@Component
class TracingInterceptor : HttpClientInterceptor {

    override fun processRequest(
        ctx: Context,
        chain: HttpClientInterceptor.InterceptChain,
        request: HttpClientRequest
    ): CompletionStage<HttpClientResponse> {
        val modified = request.toBuilder()
            .header("x-request-id", UUID.randomUUID().toString()) //(1)!
            .build()

        return chain.process(ctx, modified).thenApply { response ->
            if (response.code() >= 500) {
                // observe server errors
            }
            response
        }
    }
}
  1. request.toBuilder() returns an HttpClientRequestBuilder initialized from the current request

For the imperative HttpClient, an interceptor is attached with httpClient.with(interceptor) instead of @InterceptWith.

Root URL

RootUriInterceptor is a ready-made interceptor that adds a base URL to relative requests. If the request already contains a scheme (http:// or https://), the interceptor leaves it unchanged. If the request is relative, RootUriInterceptor adds the root address and guarantees one / separator between the root and the path.

@Module
public interface ClientModule {

    default RootUriInterceptor rootUriInterceptor() {
        return new RootUriInterceptor("https://api.example.com");
    }
}
@Module
interface ClientModule {

    fun rootUriInterceptor(): RootUriInterceptor {
        return RootUriInterceptor("https://api.example.com")
    }
}

After registering the interceptor, connect it to the client:

Interceptors are attached with the @InterceptWith annotation, either to a specific method or to the whole @HttpClient interface.

public interface HttpClientInterceptor {

    HttpClientResponse processRequest(InterceptChain chain, HttpClientRequest request) throws Exception; //(1)!

    interface InterceptChain {
        HttpClientResponse process(HttpClientRequest request) throws Exception; //(2)!
    }
}
  1. Called for every request of the intercepted method
  2. Passes the request further down the chain and returns the response

The request is immutable, so a modified request is produced with request.toBuilder().

Method-level interceptor:

Custom interceptor

Method-level interceptor:

@HttpClient
public interface SomeClient {

    @Component
    final class MethodInterceptor implements HttpClientInterceptor {

        private final Component1 component1;

        public MethodInterceptor(Component1 component1) {
            this.component1 = component1;
        }

        @Override
        public HttpClientResponse processRequest(InterceptChain chain, HttpClientRequest request) throws Exception {
            component1.doSomething();
            return chain.process(request);
        }
    }

    @InterceptWith(MethodInterceptor.class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello();
}
@HttpClient
interface SomeClient {

    @Component
    class MethodInterceptor(val component1: Component1) : HttpClientInterceptor {

        override fun processRequest(
            chain: HttpClientInterceptor.InterceptChain,
            request: HttpClientRequest
        ): HttpClientResponse {
            component1.doSomething()
            return chain.process(request)
        }
    }

    @InterceptWith(MethodInterceptor::class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello()
}

An interceptor is taken from the dependency container, so it follows the same rule as a mapper: it needs @Component when it has constructor dependencies. @InterceptWith also accepts a tag attribute to pick a tagged interceptor implementation.

Example: adding a header

public final class RequestIdInterceptor implements HttpClientInterceptor {

    @Override
    public HttpClientResponse processRequest(InterceptChain chain, HttpClientRequest request) throws Exception {
        var modified = request.toBuilder()
                .header("x-request-id", UUID.randomUUID().toString())
                .build();
        return chain.process(modified);
    }
}
class RequestIdInterceptor : HttpClientInterceptor {

    override fun processRequest(
        chain: HttpClientInterceptor.InterceptChain,
        request: HttpClientRequest
    ): HttpClientResponse {
        val modified = request.toBuilder()
            .header("x-request-id", UUID.randomUUID().toString())
            .build()
        return chain.process(modified)
    }
}

Interceptor execution order:

Interceptors declared on the client run before interceptors declared on the method, and within one element they run in declaration order. Each interceptor can:

  • Modify the request before sending
  • Call the next interceptor in the chain (chain.process(request))
  • Modify or inspect the response after receiving
  • Throw an exception to break the chain
Request  → Client interceptors → Method interceptors → Telemetry → HTTP Server
Response ← Client interceptors ← Method interceptors ← Telemetry ← HTTP Server

Client interceptor

If the interceptor must be applied to all methods of a client, @InterceptWith is placed on the interface:

@HttpClient
@InterceptWith(ClientInterceptor.class) //(1)!
public interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello")
    void hello();

    @HttpRoute(method = HttpMethod.POST, path = "/world")
    void world();
}
  1. Applied to every method of this client
@HttpClient
@InterceptWith(ClientInterceptor::class) //(1)!
interface SomeClient {

    @HttpRoute(method = HttpMethod.GET, path = "/hello")
    fun hello()

    @HttpRoute(method = HttpMethod.POST, path = "/world")
    fun world()
}
  1. Applied to every method of this client

If interceptors are specified on both the client and the method, both interceptor sets are applied for that call. There is no application-wide registry of HTTP client interceptors: an interceptor only applies where @InterceptWith names it.

Authorization

Kora provides out-of-the-box interceptors that can be used for Basic/ApiKey/Bearer/OAuth authorization.

Basic

You need to configure an interceptor and configuration for Basic authorization:

@Module
public interface BasicAuthModule {

    @ConfigSource("openapiAuth.basicAuth")
    interface BasicAuthConfig {

        String username();

        String password();
    }

    default BasicAuthHttpClientInterceptor basicAuther(BasicAuthConfig config) {
        return new BasicAuthHttpClientInterceptor(config.username(), config.password());
    }
}
@Module
interface BasicAuthModule {

    @ConfigSource("openapiAuth.basicAuth")
    interface BasicAuthConfig {

        fun username(): String

        fun password(): String
    }

    fun basicAuther(config: BasicAuthConfig): BasicAuthHttpClientInterceptor {
        return BasicAuthHttpClientInterceptor(config.username(), config.password())
    }
}

The two-argument constructor wraps the credentials into a BasicAuthHttpClientTokenProvider. You can also provide your own HttpClientTokenProvider implementation in the constructor if rules for getting secrets are different.

Then add the interceptor for the entire HTTP client or specific methods.

@HttpClient
public interface SomeClient {

    @InterceptWith(BasicAuthHttpClientInterceptor.class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello();
}
@HttpClient
interface SomeClient {

    @InterceptWith(BasicAuthHttpClientInterceptor::class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello()
}

ApiKey

You need to configure an interceptor and configuration for ApiKey authorization. ApiKeyLocation supports HEADER, QUERY and COOKIE:

@Module
public interface ApiKeyAuthModule {

    @ConfigSource("openapiAuth.apiKeyAuth")
    interface ApiKeyAuthConfig {

        String apiKey();
    }

    default ApiKeyHttpClientInterceptor apiKeyAuther(ApiKeyAuthConfig config) {
        return new ApiKeyHttpClientInterceptor(ApiKeyLocation.HEADER, "X-API-KEY", config.apiKey());
    }
}
@Module
interface ApiKeyAuthModule {

    @ConfigSource("openapiAuth.apiKeyAuth")
    interface ApiKeyAuthConfig {

        fun apiKey(): String
    }

    fun apiKeyAuther(config: ApiKeyAuthConfig): ApiKeyHttpClientInterceptor {
        return ApiKeyHttpClientInterceptor(ApiKeyLocation.HEADER, "X-API-KEY", config.apiKey())
    }
}

Then add the interceptor for the entire HTTP client or specific methods.

@HttpClient
public interface SomeClient {

    @InterceptWith(ApiKeyHttpClientInterceptor.class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello();
}
@HttpClient
interface SomeClient {

    @InterceptWith(ApiKeyHttpClientInterceptor::class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello()
}

Bearer

You need to configure an interceptor for Bearer authorization:

@Module
public interface BearerAuthModule {

    default BearerAuthHttpClientInterceptor bearerAuther(HttpClientTokenProvider tokenProvider) {
        return new BearerAuthHttpClientInterceptor(tokenProvider);
    }
}
@Module
interface BearerAuthModule {

    fun bearerAuther(tokenProvider: HttpClientTokenProvider): BearerAuthHttpClientInterceptor {
        return BearerAuthHttpClientInterceptor(tokenProvider)
    }
}

You will need to implement the Bearer token provisioning yourself using your custom HttpClientTokenProvider implementation, or use the constructor that accepts a static Bearer Token.

public interface HttpClientTokenProvider {

    @Nullable
    String getToken(HttpClientRequest request); //(1)!
}
  1. Returning null leaves the request unchanged and no Authorization header is added

Then add the interceptor for the entire HTTP client or specific methods.

@HttpClient
public interface SomeClient {

    @InterceptWith(BearerAuthHttpClientInterceptor.class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    void hello();
}
@HttpClient
interface SomeClient {

    @InterceptWith(BearerAuthHttpClientInterceptor::class)
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello()
}

OAuth

Authorization by OAuth is similar to Bearer, you need to implement HttpClientTokenProvider yourself and put it in dependency container.

HttpClientTokenProvider

HttpClientTokenProvider — interface for providing authorization tokens dynamically. Used when the token needs to be refreshed or obtained from an external source (e.g., an OAuth2 token endpoint). The method is blocking, so the token can simply be fetched inline.

Implementation example:

@Component
public final class MyTokenProvider implements HttpClientTokenProvider {

    private final OAuthClient oauthClient;
    private volatile String cachedToken;
    private volatile long tokenExpiry;

    public MyTokenProvider(OAuthClient oauthClient) {
        this.oauthClient = oauthClient;
    }

    @Override
    public String getToken(HttpClientRequest request) {
        if (cachedToken != null && System.currentTimeMillis() < tokenExpiry) {
            return cachedToken;
        }

        var response = oauthClient.refreshToken();
        this.cachedToken = response.accessToken();
        this.tokenExpiry = System.currentTimeMillis() + response.expiresIn() * 1000;
        return this.cachedToken;
    }
}
@Component
class MyTokenProvider(
    private val oauthClient: OAuthClient
) : HttpClientTokenProvider {

    @Volatile
    private var cachedToken: String? = null

    @Volatile
    private var tokenExpiry: Long = 0

    override fun getToken(request: HttpClientRequest): String? {
        val token = cachedToken
        if (token != null && System.currentTimeMillis() < tokenExpiry) {
            return token
        }

        val response = oauthClient.refreshToken()
        cachedToken = response.accessToken()
        tokenExpiry = System.currentTimeMillis() + response.expiresIn() * 1000
        return cachedToken
    }
}

Usage with BearerAuthHttpClientInterceptor:

@Module
public interface AuthModule {

    default BearerAuthHttpClientInterceptor bearerAuthInterceptor(HttpClientTokenProvider tokenProvider) {
        return new BearerAuthHttpClientInterceptor(tokenProvider);
    }
}
@Module
interface AuthModule {

    fun bearerAuthInterceptor(tokenProvider: HttpClientTokenProvider): BearerAuthHttpClientInterceptor {
        return BearerAuthHttpClientInterceptor(tokenProvider)
    }
}

The OpenAPI generator expects the same interface, tagged with the generated ApiSecurity marker class.

Exception handling

Various exceptions may occur during HTTP requests. All exceptions inherit from the base HttpClientException, which is an unchecked RuntimeException living in io.koraframework.http.client.common.exception.

Exception hierarchy:

HttpClientException
├── HttpClientTimeoutException
├── HttpClientConnectionException
├── HttpClientResponseException
├── HttpClientEncoderException
├── HttpClientDecoderException
└── HttpClientUnknownException

Handling example:

@Component
public final class SomeService {

    private final SomeClient client;

    public SomeService(SomeClient client) {
        this.client = client;
    }

    public void call() {
        try {
            client.hello();
        } catch (HttpClientTimeoutException e) {
            // Timeout: log, retry
        } catch (HttpClientConnectionException e) {
            // Connection error: check service availability
        } catch (HttpClientResponseException e) {
            // Response error: code, body, headers
            int code = e.getCode();
            byte[] body = e.getBytes();
            var headers = e.getHeaders();
        } catch (HttpClientEncoderException e) {
            // Serialization error: validate data
        } catch (HttpClientDecoderException e) {
            // Deserialization error: log
        } catch (HttpClientUnknownException e) {
            // Unknown error: e.getCause()
        }
    }
}
@Component
class SomeService(
    private val client: SomeClient
) {
    fun call() {
        try {
            client.hello()
        } catch (e: HttpClientTimeoutException) {
            // Timeout: log, retry
        } catch (e: HttpClientConnectionException) {
            // Connection error: check service availability
        } catch (e: HttpClientResponseException) {
            // Response error: code, body, headers
            val code = e.code
            val body = e.bytes
            val headers = e.headers
        } catch (e: HttpClientEncoderException) {
            // Serialization error: validate data
        } catch (e: HttpClientDecoderException) {
            // Deserialization error: log
        } catch (e: HttpClientUnknownException) {
            // Unknown error: e.cause
        }
    }
}

Timeout Exception

Thrown when the request exceeds the configured timeout (requestTimeout, connectTimeout or readTimeout).

Causes:

  • Server doesn't respond within requestTimeout
  • Connection establishment timeout exceeded (connectTimeout)
  • Response read timeout exceeded (readTimeout)
  • Network delays

Recommendations:

  • Configure appropriate timeouts in settings, per client and per method
  • Implement retry logic for temporary failures
  • Use a circuit breaker to protect against cascading failures

Connection Exception

Thrown when connection to the server cannot be established.

Causes:

  • DNS resolution failure
  • Server unavailable (port closed, firewall)
  • Connection refused
  • SSL/TLS handshake failed

Recommendations:

  • Check service availability (health check)
  • Use fallback to a backup service
  • Configure retry with exponential backoff

Response Exception

Thrown when the server returns an HTTP status code outside 2xx and the method does not declare its own mapper via @Mapping or @ResponseCodeMapper, and does not return Either.

Available data:

  • getCode() — HTTP status code (400, 404, 500, etc.)
  • getBytes() — response body, truncated to 4096 bytes when the body was not fully buffered
  • getHeaders() — response headers

Recommendations:

  • Use @ResponseCodeMapper for custom status handling
  • Use Either<T, E> when a non-successful code is a normal outcome
  • Log the code and body for debugging
  • Distinguish between client (4xx) and server (5xx) errors

Request Encoder Exception

Thrown when an error occurs during request body serialization: the HttpClientRequestMapper of the body threw.

Causes:

  • JSON/binary serialization error
  • Invalid data in the request object
  • The mapper itself failed on the value

Recommendations:

  • Validate data before sending
  • Check that the body type carries @Json and has a JsonWriter
  • Log the original exception in cause

Response Decoder Exception

Thrown when an error occurs during response body deserialization: the HttpClientResponseMapper of the method threw.

Causes:

  • Invalid JSON in the server response
  • Schema mismatch (server returned unexpected fields)
  • The stream was closed or truncated

Recommendations:

  • Check API version compatibility
  • Log the response body for debugging
  • Use @ResponseCodeMapper to handle differently shaped error payloads

Unknown Exception

Thrown when an error occurs that doesn't fit other categories, including any checked exception escaping the transport.

Available data:

  • getCause() — original exception

Recommendations:

  • Always log cause for diagnostics
  • Check HTTP client logs at DEBUG/TRACE level
  • Report a bug if the exception is reproducible

Resilience

The recommendations above (retry, circuit breaker, timeout, fallback) are provided by the Resilient module rather than the HTTP client itself. Its annotations apply directly to declarative @HttpClient methods, so you can add fault tolerance without changing the call sites:

  • @Retry — retry the call on failure
  • @CircuitBreaker — stop calling a failing dependency and fail fast until it recovers
  • @Timeout — bound the total call time
  • @Fallback — return a fallback result when the call fails
@HttpClient
public interface SomeClient {

    @Retry("someClient.hello") //(1)!
    @CircuitBreaker("someClient.hello") //(2)!
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    HttpResponseEntity<String> hello();
}
  1. Retry configuration path
  2. Circuit breaker configuration path
@HttpClient
interface SomeClient {

    @Retry("someClient.hello") //(1)!
    @CircuitBreaker("someClient.hello") //(2)!
    @HttpRoute(method = HttpMethod.GET, path = "/hello/world")
    fun hello(): HttpResponseEntity<String>
}
  1. Retry configuration path
  2. Circuit breaker configuration path

Note the difference from the transport requestTimeout (Client Configuration): requestTimeout bounds a single HTTP attempt, while @Timeout bounds the whole method call including retries. See the Resilient module for the configuration and semantics of each annotation.

Client imperative

The base client represents the HttpClient interface and is available for injection from any transport module:

public interface HttpClient {

    HttpClientResponse execute(HttpClientRequest request) throws HttpClientException; //(1)!

    default HttpClient with(HttpClientInterceptor interceptor); //(2)!
}
  1. Executes the request and returns the response; the response must be closed
  2. Returns a new HttpClient view with an extra interceptor applied on top

The response holds an open body stream, so it must be closed — use it inside a try-with-resources block:

var request = HttpClientRequest.post("http://localhost:8090/pets/{petId}")
        .pathParam("petId", "1")
        .queryParam("page", 1)
        .header("token", "12345")
        .body(HttpBody.plaintext("refresh"))
        .build();

try (var response = httpClient.execute(request)) {
    var code = response.code();
    var body = new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
val request = HttpClientRequest.post("http://localhost:8090/pets/{petId}")
    .pathParam("petId", "1")
    .queryParam("page", 1)
    .header("token", "12345")
    .body(HttpBody.plaintext("refresh"))
    .build()

httpClient.execute(request).use { response ->
    val code = response.code()
    val body = String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8)
}

HttpClientRequestBuilder

HttpClientRequestBuilder allows building HTTP requests manually and is obtained via HttpClientRequest.of(method, uri). A builder is obtained from one of the HttpClientRequest factory methods — get, head, post, put, delete, connect, options, trace, patch, or of(method, uriTemplate) — and an existing request can be turned back into a builder with request.toBuilder().

Method Description
pathParam(String name, String \| int \| long \| UUID value) Substitutes a {name} placeholder of the URI template
queryParam(String name) Adds a query parameter without a value
queryParam(String name, String \| int \| long \| boolean \| UUID \| Collection<?> value) Adds a query parameter value
queryParamRemove(String name) Removes all values of a query parameter
header(String name, String \| List<String> value) Sets a request header
headerRemove(String name) Removes a request header
requestTimeout(Duration \| int millis) Overrides the request timeout for this request
body(HttpBodyOutput body) Sets the request body
build() Builds the immutable HttpClientRequest
HttpClientRequest request = HttpClientRequest.of("POST", "http://localhost:8090/pets/{petId}")
        .pathParam("petId", "1")
        .queryParam("page", 1)
        .header("token", "12345")
        .requestTimeout(Duration.ofSeconds(5))
        .body(HttpBody.plaintext("refresh"))
        .build();
val request = HttpClientRequest.of("POST", "http://localhost:8090/pets/{petId}")
    .pathParam("petId", "1")
    .queryParam("page", 1)
    .header("token", "12345")
    .requestTimeout(Duration.ofSeconds(5))
    .body(HttpBody.plaintext("refresh"))
    .build()

The built HttpClientRequest exposes method(), uri(), uriTemplate(), headers(), body() and requestTimeout(). uriTemplate() is what telemetry uses as the operation name, which is why templated paths keep metrics and spans low-cardinality.

UriQueryBuilder

UriQueryBuilder is the low-level helper that the generated declarative clients use to assemble a query string. It appends parameters in order and takes care of the ? and & separators:

var query = new UriQueryBuilder(true, false); //(1)!
query.add("page", "1"); //(2)!
query.add("sort", "name age"); //(3)!
query.add("debug"); //(4)!

String uri = "/api/users" + query.build();
// /api/users?page=1&sort=name+age&debug
  1. First argument: start the string with ?; second: start it with & because the base path already ends with a query parameter
  2. Adds a name=value pair, both parts are URL-encoded
  3. Values are URL-encoded, so the space becomes +
  4. Adds a parameter without a value
val query = UriQueryBuilder(true, false) //(1)!
query.add("page", "1") //(2)!
query.add("sort", "name age") //(3)!
query.add("debug") //(4)!

val uri = "/api/users" + query.build()
// /api/users?page=1&sort=name+age&debug
  1. First argument: start the string with ?; second: start it with & because the base path already ends with a query parameter
  2. Adds a name=value pair, both parts are URL-encoded
  3. Values are URL-encoded, so the space becomes +
  4. Adds a parameter without a value

unsafeAdd variants append already-encoded values as-is. A null value is skipped entirely.

HttpBodyInput

HttpBodyInput describes the response body. It extends HttpBody and is Closeable.

Method Returns Description
asInputStream() InputStream Reads the body as a stream
getFullContentIfAvailable() ByteBuffer Returns the whole body if it is already buffered, otherwise null
contentLength() long Body length, -1 when unknown
contentType() String Value of the Content-Type header, may be null
close() void Releases the underlying connection resources

The outgoing counterpart is HttpBodyOutput, built with HttpBody.plaintext(...), HttpBody.json(...), HttpBody.octetStream(...), HttpBody.of(contentType, content), or HttpBodyOutput.of(contentType, inputStream) for streaming a body that is not fully in memory.

HttpClientResponse

HttpClientResponse is an interface that represents the HTTP response from the server. It is Closeable.

Method Returns Description
code() int HTTP status code (200, 404, 500, etc.)
headers() HttpHeaders Response headers
body() HttpBodyInput Response body
close() void Closes the response and releases the connection

HttpHeaders

HttpHeaders provides access to request and response headers. Header names are lower-cased and lookups are case-insensitive.

Method Returns Description
getFirst(String name) String First value of the header or null
getAll(String name) List<String> All values of the header or null
has(String name) boolean Whether the header is present
names() Set<String> All header names
size() int Number of headers
isEmpty() boolean Whether there are no headers
toMutable() MutableHttpHeaders Mutable copy

Reading headers:

var request = HttpClientRequest.get("http://localhost:8090/api/data").build();

try (var response = httpClient.execute(request)) {
    HttpHeaders headers = response.headers();
    String contentType = headers.getFirst("content-type");
    List<String> allValues = headers.getAll("x-custom-header");
    boolean hasHeader = headers.has("authorization");
}
val request = HttpClientRequest.get("http://localhost:8090/api/data").build()

httpClient.execute(request).use { response ->
    val headers = response.headers()
    val contentType = headers.getFirst("content-type")
    val allValues = headers.getAll("x-custom-header")
    val hasHeader = headers.has("authorization")
}

Building headers:

HttpHeaders.of(...) returns a MutableHttpHeaders which supports set, add and remove:

MutableHttpHeaders headers = HttpHeaders.of();
headers.add("authorization", "Bearer token123");
headers.add("x-custom-header", "value");
headers.set("content-type", "application/json");

var request = HttpClientRequest.post("http://localhost:8090/api/data")
        .header("authorization", headers.getFirst("authorization"))
        .body(HttpBody.json("{}"))
        .build();
val headers = HttpHeaders.of()
headers.add("authorization", "Bearer token123")
headers.add("x-custom-header", "value")
headers.set("content-type", "application/json")

val request = HttpClientRequest.post("http://localhost:8090/api/data")
    .header("authorization", headers.getFirst("authorization")!!)
    .body(HttpBody.json("{}"))
    .build()

A ready HttpHeaders object can also be passed straight to a declarative method with @Header, see Header.

Cookies

Cookies are ordinary headers: an outgoing cookie is a Cookie header, an incoming one is a Set-Cookie header. Cookie describes a single cookie and Cookies is a utility class that parses and renders them.

Sending a cookie:

var request = HttpClientRequest.get("http://localhost:8090/api/profile")
        .header("Cookie", Cookie.of("SESSIONID", "12345").toValue())
        .build();
val request = HttpClientRequest.get("http://localhost:8090/api/profile")
    .header("Cookie", Cookie.of("SESSIONID", "12345").toValue())
    .build()

Reading cookies from a response:

try (var response = httpClient.execute(request)) {
    var setCookies = response.headers().getAll("set-cookie");
    if (setCookies != null) {
        for (var header : setCookies) {
            Cookie cookie = Cookies.parseSetCookieHeader(header);
            String name = cookie.name();
            String value = cookie.value();
            String domain = cookie.domain();
            String path = cookie.path();
        }
    }
}
  1. Header names are matched case-insensitively
httpClient.execute(request).use { response ->
    val setCookies = response.headers().getAll("set-cookie")
    if (setCookies != null) {
        for (header in setCookies) {
            val cookie = Cookies.parseSetCookieHeader(header)
            val name = cookie.name()
            val value = cookie.value()
            val domain = cookie.domain()
            val path = cookie.path()
        }
    }
}

For a declarative client, use the @Cookie parameter annotation instead of building the header by hand.

Telemetry

HTTP Client telemetry is installed as an interceptor: DeclarativeHttpClientConfig asks the HttpClientTelemetryFactory for an HttpClientTelemetry per client method and wraps the transport in a TelemetryInterceptor. Extension points live in io.koraframework.http.client.common.telemetry.

For each HTTP request HttpClientTelemetry.observe(request) creates an HttpClientObservation, which sees the request via observeRequest, the response via observeResponse, a failure via observeError, and is always closed with end().

The default factory DefaultHttpClientTelemetryFactory combines three optional pieces, each replaceable by declaring a component of the corresponding type:

  • DefaultHttpClientLoggerFactory builds the request/response loggers;
  • DefaultHttpClientMetricsFactory builds the metrics recorder;
  • DefaultHttpClientBodyConverter turns a captured body into the string that is written to the log.

When logging, metrics and tracing are all disabled for a client, the factory returns a no-op telemetry and no wrapper is installed at all.

Logging. Two loggers are created per client method, named after the client class, the method, and the direction: com.example.SomeClient.hello.request and com.example.SomeClient.hello.response. Their level decides how much is written: INFO logs the operation only, DEBUG adds query parameters and headers, TRACE adds the body. Masked query parameters and headers are replaced with the configured mask, and a body larger than maxRequestBodyLogSize / maxResponseBodyLogSize is skipped with a warning. See Logging for the logger configuration itself.

Metrics. The default recorder writes the http.client.request.duration timer with the SLO buckets from telemetry.metrics.slo, tagged with the HTTP method, status code, route, server address and error type. See Metrics Reference.

Tracing. A span named <METHOD> <path template> is created per request with the OpenTelemetry HTTP semantic attributes; telemetry.tracing.pathFull decides between the url.full and url.path attributes. See Tracing.

Logging

Client logging is written through SLF4J under two loggers named after the client: <clientName>.request and <clientName>.response (where <clientName> is derived from the @HttpClient interface). Enabling logging in the configuration (telemetry.logging.enabled = true) turns the telemetry on, but what is written is governed by the log level of those loggers, so you tune verbosity from your logging framework (logback, etc.):

Log level What is logged
INFO Request start and response end line: method, path template, response status, result code, and duration
DEBUG Additionally request and response headers
TRACE Additionally request and response bodies, and the full (non-templated) path

The configuration fields shape the output (see Configuration for the full list):

  • pathTemplate — when true (default), the low-cardinality route template (/users/{id}) is logged and used as the metric/trace label instead of the resolved path (/users/42); at TRACE the resolved path is logged
  • maskHeaders — header names whose values are replaced with mask (default masks authorization, cookie, set-cookie)
  • maskQueries — query parameter names whose values are replaced with mask
  • mask — the replacement string (default ***)

For a client whose interface produces the name someClient, enable full body logging with:

<logger name="someClient.request" level="TRACE"/>
<logger name="someClient.response" level="TRACE"/>

Custom logger

To fully control the log format or destination, provide your own HttpClientLoggerFactory (or HttpClientLogger) component — it replaces the default Sl4fjHttpClientLoggerFactory. The same applies to metrics (HttpClientMetricsFactory) and tracing (HttpClientTracerFactory): supplying any of these components overrides the corresponding default, while the others keep their default implementation.

@Component
public final class MyHttpClientLoggerFactory implements HttpClientLoggerFactory {

    @Override
    public HttpClientLogger get(TelemetryConfig.LogConfig logging, String clientName) {
        return new MyHttpClientLogger(clientName); //(1)!
    }
}
  1. Your HttpClientLogger implementation controlling exactly what and how to log
@Component
class MyHttpClientLoggerFactory : HttpClientLoggerFactory {

    override fun get(logging: TelemetryConfig.LogConfig, clientName: String): HttpClientLogger {
        return MyHttpClientLogger(clientName) //(1)!
    }
}
  1. Your HttpClientLogger implementation controlling exactly what and how to log