Skip to content

Connecting

Everything in the library takes a jetcd Client. This page is about getting one.

connectToEtcd

The ClientUtils facade (common/ClientExtensions.kt) offers four overloads: a URL list or an EtcdConnectionConfig, each in a plain form and a scoped block form.

// `use` closes the client; the recipes never take ownership of it for you.
connectToEtcd(["http://localhost:2379"]).use { client ->
  client.putValue("/greeting", "hello")
  logger.info { client.getValue("/greeting", "<absent>") }
}
// The Kotlin extensions are reached through their @JvmName facades:
// ClientUtils.connectToEtcd, KVUtils.putValue, and so on.
try (Client client = connectToEtcd(List.of("http://localhost:2379"))) {
  putValue(client, "/greeting", "hello");
  System.out.println(getValue(client, "/greeting", "<absent>"));
}

The scoped form closes the client for you when the block returns — including on a throw:

// The block form closes the client when the block returns, even on a throw.
val greeting =
  connectToEtcd(["http://localhost:2379"]) { client ->
    client.getValue("/greeting", "<absent>")
  }
logger.info { greeting }

You own the client's lifetime

A recipe never closes the Client you hand it — closing a recipe closes the recipe, not your connection. That is deliberate: one client is normally shared by many recipes. The exception is the Ktor plugin, which closes a client it created itself but never one you injected.

Under the hood both forms apply ClientBuilder.withRecipeDefaults(), the settings the recipes assume.

EtcdConnectionConfig

For anything beyond a URL list — credentials, TLS, a namespace — use the config object:

val config =
  EtcdConnectionConfig(
    endpoints = ["https://etcd-1:2379", "https://etcd-2:2379"],
    user = "app",
    password = System.getenv("ETCD_PASSWORD"),
    // Every key this client touches is transparently prefixed with /prod.
    namespace = "/prod",
    // Note: java.time.Duration here, not kotlin.time.Duration.
    connectTimeout = Duration.ofSeconds(5),
    retryMaxDuration = Duration.ofSeconds(30),
    tls =
      EtcdTlsConfig(
        caCertPath = "/etc/etcd/ca.crt",
        clientCertPath = "/etc/etcd/client.crt",
        clientKeyPath = "/etc/etcd/client.key",
      ),
  )

connectToEtcd(config).use { client ->
  logger.info { "Reachable: ${client.ping()}" }
}
EtcdTlsConfig tls =
  new EtcdTlsConfig("/etc/etcd/ca.crt", "/etc/etcd/client.crt", "/etc/etcd/client.key");

EtcdConnectionConfig config =
  new EtcdConnectionConfig(
    List.of("https://etcd-1:2379", "https://etcd-2:2379"),
    "app",                              // user
    System.getenv("ETCD_PASSWORD"),     // password
    "/prod",                            // namespace
    Duration.ofSeconds(5),              // connectTimeout (java.time.Duration)
    Duration.ofSeconds(30),             // retryMaxDuration
    tls);

try (Client client = connectToEtcd(config)) {
  System.out.println("Reachable: " + ping(client));
}
Field Default Notes
endpoints required
user / password null etcd RBAC
namespace null transparent key prefix
connectTimeout 5s java.time.Duration
retryMaxDuration 30s java.time.Duration
tls null EtcdTlsConfig

These are java.time.Duration, not kotlin.time.Duration

EtcdConnectionConfig is the one place in the library that uses java.time.Duration — it is the type the underlying jetcd builder wants. The recipes themselves take kotlin.time.Duration (with (long, TimeUnit) overloads for Java). If Kotlin tells you 5.seconds is the wrong type here, that is why: you want Duration.ofSeconds(5).

Namespaces

Setting namespace = "/prod" transparently prefixes every key this client touches. A recipe at /locks/orders really lives at /prod/locks/orders, and nothing in your code changes. It is the cleanest way to share one etcd cluster between environments or tenants.

Tip

The namespace is a property of the client, so it applies uniformly to every recipe built on it. Two clients with different namespaces will never see each other's keys — which is exactly what you want for isolation, and exactly the trap if you accidentally point a reader and a writer at different namespaces.

TLS and authentication

EtcdTlsConfig(caCertPath, clientCertPath, clientKeyPath) — all three are optional: supply just caCertPath to verify the server against a private CA, or all three for mutual TLS. Username/password auth is orthogonal and set via user/password.

Don't put the password in source

The snippet reads ETCD_PASSWORD from the environment. Do likewise, or pull it from whatever secret manager you already run.

Dropping to the jetcd builder

Anything the config class does not model is reachable through the builder receiver:

// Drop to the jetcd ClientBuilder for anything the config class doesn't cover.
connectToEtcd(
  urls = ["http://localhost:2379"],
  initReceiver = { maxInboundMessageSize(8 * 1024 * 1024) },
).use { client ->
  logger.info { "Connected" }
}

This is the library's general stance: it is a thin layer over jetcd, and jetcd is never hidden from you.

Health checks

Client.ping() performs an active, count-only GET and returns a Boolean:

connectToEtcd(["http://localhost:2379"]).use { client ->
  // An active, count-only GET. Cheap enough for a health endpoint.
  if (!client.ping()) logger.error { "etcd is not reachable" }
}
try (Client client = connectToEtcd(List.of("http://localhost:2379"))) {
  // An active, count-only GET. Cheap enough for a health endpoint.
  if (!ping(client)) {
    System.err.println("etcd is not reachable");
  }
}

It takes an optional RpcResilience so you can decide whether a health check retries. For a liveness endpoint you usually do not want retries — you want a fast, honest answer:

client.ping(RpcResilience.DISABLED)

Recipes expose their own ping() plus a passive isHealthy(). The difference matters: isHealthy() reports what the recipe already knows from its connection state without a round trip, while ping() goes and asks. See Connection state.

The Spring Boot starter wires ping() into an Actuator health indicator for you.

Next