Kora HTTP Client — declarative outbound calls
Kora sub-skill — obey the kora-v2 meta rules on every task: R0 ground the workspace on Kora 2.0 refs before starting (framework source at tag
2.0.0.RC1+kora-examplesatmigration/2.0;kora-docsis 1.x only) · R1 read this sub-skill before writing code · R2 Kora 2.0 APIs only — no Spring/Micronaut/Quarkus, no Kora 1.x APIs, no invented annotations or config keys · R3 journal any incorrect Kora usage. Add comments/Javadoc only if asked.
Version: Kora 2.0 (io.koraframework, 2.0.0.RC1 on Maven Central) | Java: 25 | Kotlin: 2.4 + KSP | Gradle: 9+
Annotate an interface with @HttpClient, declare methods with @HttpRoute, and the annotation
processor (Java) or symbol processor (Kotlin) generates $<Name>_ClientImpl plus a
$<Name>_Config config interface and a $<Name>_Module that binds it. No reflection, no runtime
proxies — inject the client interface like any other component.
Client contracts are synchronous. Every generated method blocks on a virtual thread and
returns the decoded value. There is no CompletionStage, no Mono/Flux, and a Kotlin suspend
client method is a hard KSP error. See execution-model-reference.
Migrating from Kora 1.x
| Kora 1.x | Kora 2.x |
|---|---|
BOM ru.tinkoff.kora:kora-parent |
io.koraframework:kora-bom |
ru.tinkoff.kora.http.client.common.annotation.HttpClient |
io.koraframework.http.client.common.annotation.HttpClient |
@HttpClient(configPath = "httpClient.x") |
@HttpClient("httpClient.x") — the attribute is value(); configPath does not exist |
http.client.common.HttpClientResponseException |
http.client.common.exception.HttpClientResponseException |
http.client.common.HttpClientDecoderException |
http.client.common.exception.HttpClientDecoderException |
e.code() on HttpClientResponseException |
e.getCode() / getHeaders() / getBytes() |
artifact http-client-async (AsyncHttpClientModule) |
removed — move to http-client-ok, http-client-jdk or the new http-client-apache |
artifact json-module |
json-common (io.koraframework.json.common.JsonModule) |
CompletionStage<T> / Mono<T> / suspend fun client methods |
synchronous T |
processRequest(Context, InterceptChain, HttpClientRequest) |
processRequest(InterceptChain, HttpClientRequest) — Context is gone from the framework |
chain.process(ctx, request) |
chain.process(request) |
OkHttpConfigurer |
io.koraframework.common.Configurer<okhttp3.OkHttpClient.Builder> |
HttpClientRequest.of(...).templateParam(...) |
.pathParam(...) |
telemetry key pathTemplate |
pathFull |
resilient.circuitbreaker.<n>.slidingWindowSize |
…countBased.windowSize + a window type (see kora-aop-resilient) |
Most of the table fails the build if you miss it. Three things in this domain do not, and need a test rather than a compiler:
- Telemetry defaults flipped.
telemetry.metrics.enabledandtelemetry.logging.enabledarefalsein 2.0 (onlytracingistrue). A 1.x config that relied on the old defaults comes up green with no client metrics. - A
@Mappingresponse mapper on a method disables the 2xx status check — the mapper is called for every status and non-2xx stops throwingHttpClientResponseException. Same for anEitherreturn type, by design. - A redundant
@Componenton a self-instantiated mapper is latent: it only becomesMultiple components matchonce something resolves thatHttpClientResponseMapper<T>from the graph, which may be a later, unrelated change.
Quick Start
1. Dependencies
gradle.properties:
koraVersion=2.0.0.RC1
Java (build.gradle) — resolve from plain mavenCentral():
configurations {
koraBom
annotationProcessor.extendsFrom(koraBom)
compileOnly.extendsFrom(koraBom)
implementation.extendsFrom(koraBom)
testImplementation.extendsFrom(koraBom)
testAnnotationProcessor.extendsFrom(koraBom)
}
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion")
annotationProcessor "io.koraframework:annotation-processors"
implementation "io.koraframework:http-client-ok" // or http-client-jdk / http-client-apache
implementation "io.koraframework:json-common" // required for @Json bodies
implementation "io.koraframework:config-hocon"
}
Kotlin (build.gradle.kts):
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}")
implementation("io.koraframework:http-client-ok")
implementation("io.koraframework:json-common")
implementation("io.koraframework:config-hocon")
}
json-common is a compileOnly dependency of http-common, so it is not pulled in
transitively — declare it in every module that uses @Json on a client.
2. Plug the transport module into @KoraApp
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
OkHttpClientModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
Exactly one transport module at a time — each one registers HttpClient under the same
httpClient base config path.
3. Declare the client interface
@HttpClient("httpClient.userApi")
public interface UserApiClient {
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
UserResponse getUser(@Path String userId);
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
List<UserResponse> listUsers(@Nullable @Query("page") Integer page,
@Nullable @Query("size") Integer size);
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
HttpResponseEntity<UserResponse> createUser(@Json CreateUserRequest request);
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
void deleteUser(@Path String userId);
}
@HttpClient value is the full config path. Omit it and the client resolves
httpClient.<lowerCamelInterfaceName> — UserApiClient → httpClient.userApiClient.
4. Configuration (HOCON)
httpClient {
userApi {
url = "http://localhost:8080"
url = ${?USER_API_URL}
requestTimeout = 10s
telemetry.logging.enabled = true
telemetry.metrics.enabled = true # false by default in 2.0
getUser { # per-method override, keyed by method name
requestTimeout = 3s
}
}
}
url is the only required key. Transport-wide settings (connectTimeout, readTimeout, proxy)
live at httpClient, transport-specific ones at httpClient.ok / .jdk / .apache.
5. Inject and use
@Component
public final class UserService {
private final UserApiClient client;
public UserService(UserApiClient client) {
this.client = client;
}
public UserResponse getUser(String id) {
return client.getUser(id);
}
}
When to use vs NOT
Use this skill when:
- Building a typed outbound client with
@HttpClient+@HttpRoute. - Mapping parameters with
@Path,@Query,@Header,@Cookie,@Json,@Mapping. - Adding
@InterceptWithinterceptors for auth, logging, or extra headers. - Configuring per-client or per-method timeouts, proxy, HTTP version, or telemetry under
httpClient.*. - Diagnosing
No component found for dependency: HttpClientResponseMapper<…>orMultiple components match.
Do NOT use when:
- You have an OpenAPI contract and want a generated client →
kora-openapi-generator-client. - You need the inbound HTTP server (
@HttpController) →kora-http-server. - You are wiring Basic/Bearer/API-key token flows in depth →
kora-http-client-auth.
Core rules
Transports
| Module interface | Artifact | Transport config section | HTTP versions | Status at 2.0.0.RC1 |
|---|---|---|---|---|
OkHttpClientModule |
io.koraframework:http-client-ok |
httpClient.ok |
HTTP_1_1 (default), HTTP_2, HTTP_3 |
unchanged since RC1 — default choice |
JdkHttpClientModule |
io.koraframework:http-client-jdk |
httpClient.jdk |
HTTP_1_1 (default), HTTP_2 |
established; one header fix landed after RC1 |
ApacheHttpClientModule |
io.koraframework:http-client-apache |
httpClient.apache |
Apache HttpClient 5, no httpVersion key |
new in 2.0, transport integration corrected after RC1 |
Prefer http-client-ok; http-client-jdk is the dependency-free alternative. http-client-apache
is available at RC1 but its integration received fixes on master afterwards — on RC1 it reads
connectTimeout / readTimeout / proxy from httpClient.apache rather than from httpClient,
and it forwards Content-Length / Transfer-Encoding headers that Apache rejects.
http-client-async / AsyncHttpClientModule do not exist in 2.0 and have no drop-in
replacement. Details and every config key: transports-reference.
Which mappers and interceptors need @Component
Decided by how the generated constructor obtains them, not by taste:
| Wired as | @Component |
|---|---|
@InterceptWith(X.class) interceptor |
always required — always a constructor parameter |
@Mapping(X.class) on a body parameter (HttpClientRequestMapper) |
always required — always a constructor parameter |
A response mapper you supply for a type the client resolves from the graph — e.g. HttpClientResponseMapper<Void> behind HttpResponseEntity<Void> |
always required |
@Mapping(X.class) on a method or @ResponseCodeMapper(mapper = X.class) |
only if the class is not instantiable by the generator |
For that last row the generator emits private static final X mapper = new X(); when the class is
final (Kotlin: not open) and has a public no-arg constructor — in Kotlin, exactly one
constructor, taking no arguments. Otherwise — a constructor dependency such as a JsonReader<T>,
or a non-final class — it becomes a constructor parameter and must be in the graph.
- Missing where required →
No component found for dependency: … (no tags). - Added on a self-instantiated mapper it is redundant, and it registers a second
HttpClientResponseMapper<T>in the graph: as soon as anything resolves that type from the graph, that isMultiple components match.
HttpResponseEntity<Void> needs its own payload mapper
HttpClientResponseMapperModule supplies concrete mappers for String, byte[], ByteBuffer
and HttpBodyInput, plus template factories for HttpResponseEntity<T>, Either<T, E> and
their @Json variants. The entity factory needs a mapper for the payload T, so
HttpResponseEntity<Void> fails the graph:
No component found for dependency: HttpClientResponseMapper<java.lang.Void> (no tags)
Declare the payload mapper as a component and do not reference it with @Mapping:
@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);
With @Mapping(VoidResponseMapper.class) the generator calls the mapper directly instead of
wrapping it, so the mapper would have to produce the whole entity:
error: incompatible types: Void cannot be converted to HttpResponseEntity<Void>
A method that returns plain void (Kotlin Unit) needs no mapper at all.
Kotlin nullability
Kora's HTTP contracts are @NullMarked (JSpecify). HttpClientResponseMapper<T>.apply returns
@Nullable T, so a Kotlin mapper that has to return null must declare the nullable type:
@Component
class VoidResponseMapper : HttpClientResponseMapper<Void> {
override fun apply(response: HttpClientResponse): Void? {
response.body().use { it.asInputStream().readAllBytes() }
return null
}
}
JsonReader<T>.read(...) is nullable too — Kotlin needs requireNotNull(...) inside mappers.
An override whose nullability does not match the contract fails with 'apply' overrides nothing,
which never mentions nullability.
What's in references/ and assets/
| File | Purpose |
|---|---|
| declarative-client-reference | @HttpClient, @HttpRoute, parameters, bodies, mappers, @ResponseCodeMapper, per-client/per-method config, the imperative HttpClient |
| execution-model-reference | Synchronous contracts, what CompletionStage/Mono/suspend migrate to, parallel fan-out |
| error-handling-guide | HttpClientException hierarchy, HttpResponseEntity, Either, status-aware decoding, resilience |
| interceptors-reference | HttpClientInterceptor, @InterceptWith, built-in Basic/ApiKey/Bearer interceptors |
| transports-reference | OkHttp / JDK / Apache modules, every config key, Configurer, proxy, telemetry |
assets/UserApiClient.java.template |
CRUD client incl. the HttpResponseEntity<Void> mapper (Java) |
assets/UserApiClient.kt.template |
Same client in Kotlin, with the nullability rules applied |
assets/CustomMapperClient.java.template |
@Mapping request body + @ResponseCodeMapper pair (Java) |
assets/CustomMapperClient.kt.template |
Same in Kotlin, incl. requireNotNull on JsonReader.read |
assets/ApiKeyAuthInterceptor.java.template |
HttpClientInterceptor reading a @ConfigSource key |
assets/ResilientApiClient.java.template |
Client with @Retryable/@CircuitBreakable/@Timeout/@Fallback typed specs |
Common pitfalls
| Symptom | Cause / fix |
|---|---|
cannot find symbol: method configPath() |
2.0 declares String value(); write @HttpClient("httpClient.x") |
@HttpClient(baseUrl = …) does not compile |
There is no baseUrl. The target comes from the url key in the client's config block |
No component found for dependency: HttpClientResponseMapper<java.lang.Void> |
HttpResponseEntity<Void> — add a @Component HttpClientResponseMapper<Void>, without @Mapping |
incompatible types: T cannot be converted to HttpResponseEntity<T> |
A payload mapper was wired through @Mapping; drop @Mapping and let the entity factory wrap it |
No component found for dependency: …RequestMapper / interceptor |
Request mappers and @InterceptWith classes are always injected — add @Component |
Multiple components match on a response mapper |
A final no-arg @Mapping/@ResponseCodeMapper mapper is built by the generated code; @Component on it is a duplicate binding |
Suspend methods are not supported by the HTTP client generator |
Remove suspend; do not replace it with a withContext(Dispatchers.IO) default wrapper |
Method has async signature, this might not work correctly (warning), then a missing mapper |
CompletionStage/Mono return type — make the method synchronous |
ConfigValueException at httpClient.<x>.url, or calls going to the wrong host |
The config path is the @HttpClient value, or httpClient.<lowerCamelInterfaceName> when omitted. An absent block throws; a stale block that still exists is read as-is |
@Json body not serialized |
Add io.koraframework:json-common, extend io.koraframework.json.common.JsonModule, annotate the DTO with @Json |
A mapper failure surfaces as HttpClientUnknownException, not HttpClientDecoderException |
Expected on 2.0.0.RC1 — the generated client only started wrapping decode failures after RC1. Catch HttpClientException (the common supertype) |
IllegalArgumentException: restricted header name on the JDK transport |
RC1 forwards connection/expect/host/upgrade to java.net.http; drop the header or use http-client-ok |
| Interceptor header change ignored | request.toBuilder().header(…).build() — the request is never mutated in place |
No http.client.request.duration metric |
telemetry.metrics.enabled defaults to false; enable it per client |
Phantom ru.tinkoff.kora errors after the rename |
Stale generated sources — ./gradlew clean with --no-build-cache; never edit build/generated |
Related skills
kora-http-client-auth— Basic / Bearer / API-key token flowskora-http-server— inbound@HttpControllerkora-openapi-generator-client— generate a client from an OpenAPI speckora-json—@JsonDTOs,JsonReader/JsonWriterkora-aop-resilient—@Retryable,@CircuitBreakable,@Timeout,@Fallbackkora-telemetry-metrics— Micrometer wiring forhttp.client.*