Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions ktor-client/ktor-client-plugins/ktor-client-circuit-breaker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# ktor-client-circuit-breaker

A Ktor HTTP client plugin implementing the [Circuit Breaker](https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker) pattern to prevent cascading failures in distributed systems.

## Motivation

The Ktor client ships with `HttpRequestRetry` and `HttpTimeout`, but has no built-in protection against repeatedly calling a failing downstream service. Without a circuit breaker, a single unhealthy dependency can exhaust connection pools, saturate thread pools, and cascade failures through the call graph. This plugin fills that gap.

## How it works

Each named circuit breaker is a state machine with three states:

```
failures >= threshold
┌────────┐ ┌──────┐
│ CLOSED ├─────────────────►│ OPEN │
└───┬────┘ └──┬───┘
│ │ resetTimeout elapsed
│ all trial requests │
│ succeed ▼
│ ┌───────────┐
└────────────────────┤ HALF-OPEN │
└─────┬─────┘
│ any trial request fails
┌──────┐
│ OPEN │
└──────┘
```
Comment on lines +13 to +30
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language to the state-diagram fence.

This block triggers markdownlint MD040 because the fence has no language. Mark it as text so docs lint stays clean.

✏️ Suggested fix
-```
+```text
          failures >= threshold
   ┌────────┐                  ┌──────┐
   │ CLOSED ├─────────────────►│ OPEN │
   └───┬────┘                  └──┬───┘
       │                          │ resetTimeout elapsed
       │  all trial requests      │
       │  succeed                 ▼
       │                    ┌───────────┐
       └────────────────────┤ HALF-OPEN │
                            └─────┬─────┘
                                  │ any trial request fails
                                  │
                                  ▼
                             ┌──────┐
                             │ OPEN │
                             └──────┘
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 13-13: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ktor-client/ktor-client-plugins/ktor-client-circuit-breaker/README.md` around
lines 13 - 30, The fenced diagram in the README.md lacks a language tag which
triggers markdownlint MD040; update the fence that contains the circuit-breaker
state diagram so the opening triple-backtick includes the language identifier
text (i.e., change the fence from ``` to ```text) so the markdown linter
recognizes it as a plain text block and the lint error is resolved.


- **Closed** -- Normal operation. Requests pass through. Consecutive failures are counted. A successful response resets the counter. When the counter reaches `failureThreshold`, the circuit trips to Open.
- **Open** -- The circuit is tripped. All requests are immediately rejected with `CircuitBreakerOpenException` without hitting the network. After `resetTimeout` elapses, the circuit transitions to Half-Open.
- **Half-Open** -- The circuit allows up to `halfOpenRequests` trial requests through. If all succeed, the circuit closes. If any fails, the circuit re-opens.

## Installation

Add the dependency (published alongside `ktor-client-core`):

```kotlin
dependencies {
implementation("io.ktor:ktor-client-circuit-breaker:$ktor_version")
}
```

## Usage

### Basic configuration

```kotlin
val client = HttpClient(CIO) {
install(CircuitBreaker) {
register("payment-service") {
failureThreshold = 5 // open after 5 consecutive failures
resetTimeout = 30.seconds // wait 30s before probing
halfOpenRequests = 3 // allow 3 trial requests in half-open
}
}
}
```

Tag each request with the circuit breaker it belongs to:

```kotlin
val response = client.get("https://payment.example.com/api/charge") {
circuitBreaker("payment-service")
}
```

### Multiple services

```kotlin
install(CircuitBreaker) {
register("payment-service") {
failureThreshold = 5
resetTimeout = 30.seconds
}
register("inventory-service") {
failureThreshold = 10
resetTimeout = 1.minutes
}
}
```

Each circuit is independent -- tripping `payment-service` does not affect `inventory-service`.

### Automatic routing by host

Instead of tagging every request manually, route by host:

```kotlin
install(CircuitBreaker) {
routeRequests { request -> request.url.host }
global {
failureThreshold = 5
resetTimeout = 30.seconds
}
}
```

An explicit `circuitBreaker("name")` attribute on a request always takes priority over the router.

### Custom failure detection

By default, responses with status code >= 500 are treated as failures. Customize this per circuit:

```kotlin
register("strict-service") {
failureThreshold = 3
resetTimeout = 10.seconds
isFailure { response ->
response.status.value >= 400
}
}
```

Exceptions thrown during the request (network errors, timeouts) always count as failures regardless of this predicate.

### Handling rejections

When the circuit is open, requests throw `CircuitBreakerOpenException`:

```kotlin
try {
client.get("https://payment.example.com/api/charge") {
circuitBreaker("payment-service")
}
} catch (e: CircuitBreakerOpenException) {
// Circuit is open -- return a fallback or cached response
println("${e.circuitBreakerName} is unavailable (reset in ${e.resetTimeout})")
}
```

## Configuration reference

| Property | Default | Description |
|---|---|---|
| `failureThreshold` | `5` | Consecutive failures required to trip the circuit |
| `resetTimeout` | `60s` | Duration the circuit stays open before transitioning to half-open |
| `halfOpenRequests` | `3` | Number of trial requests in the half-open state |
| `isFailure { }` | `status >= 500` | Predicate to classify a response as a failure |

## Interaction with other plugins

- **HttpRequestRetry** -- Install `HttpRequestRetry` *before* `CircuitBreaker` so the circuit breaker wraps the retry logic. This way the circuit sees the final outcome after all retries, and `CircuitBreakerOpenException` is not retried.
- **HttpTimeout** -- Timeout exceptions are counted as circuit breaker failures.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
public final class io/ktor/client/plugins/circuitbreaker/CircuitBreakerConfig {
public fun <init> ()V
public final fun global (Lkotlin/jvm/functions/Function1;)V
public final fun register (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public final fun routeRequests (Lkotlin/jvm/functions/Function1;)V
}

public final class io/ktor/client/plugins/circuitbreaker/CircuitBreakerKt {
public static final fun circuitBreaker (Lio/ktor/client/request/HttpRequestBuilder;Ljava/lang/String;)V
public static final fun getCircuitBreaker ()Lio/ktor/client/plugins/api/ClientPlugin;
}

public final class io/ktor/client/plugins/circuitbreaker/CircuitBreakerOpenException : java/lang/IllegalStateException {
public synthetic fun <init> (Ljava/lang/String;JLkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCircuitBreakerName ()Ljava/lang/String;
public final fun getResetTimeout-UwyO8pc ()J
}

public final class io/ktor/client/plugins/circuitbreaker/ServiceCircuitBreakerConfig {
public fun <init> ()V
public final fun getFailureThreshold ()I
public final fun getHalfOpenRequests ()I
public final fun getResetTimeout-UwyO8pc ()J
public final fun isFailure (Lkotlin/jvm/functions/Function1;)V
public final fun setFailureThreshold (I)V
public final fun setHalfOpenRequests (I)V
public final fun setResetTimeout-LRDsOJo (J)V
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Klib ABI Dump
// Targets: [androidNativeArm32, androidNativeArm64, androidNativeX64, androidNativeX86, iosArm64, iosSimulatorArm64, iosX64, js, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, tvosArm64, tvosSimulatorArm64, tvosX64, wasmJs, watchosArm32, watchosArm64, watchosDeviceArm64, watchosSimulatorArm64, watchosX64]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true

// Library unique name: <io.ktor:ktor-client-circuit-breaker>
final class io.ktor.client.plugins.circuitbreaker/CircuitBreakerConfig { // io.ktor.client.plugins.circuitbreaker/CircuitBreakerConfig|null[0]
constructor <init>() // io.ktor.client.plugins.circuitbreaker/CircuitBreakerConfig.<init>|<init>(){}[0]

final fun global(kotlin/Function1<io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig, kotlin/Unit>) // io.ktor.client.plugins.circuitbreaker/CircuitBreakerConfig.global|global(kotlin.Function1<io.ktor.client.plugins.circuitbreaker.ServiceCircuitBreakerConfig,kotlin.Unit>){}[0]
final fun register(kotlin/String, kotlin/Function1<io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig, kotlin/Unit>) // io.ktor.client.plugins.circuitbreaker/CircuitBreakerConfig.register|register(kotlin.String;kotlin.Function1<io.ktor.client.plugins.circuitbreaker.ServiceCircuitBreakerConfig,kotlin.Unit>){}[0]
final fun routeRequests(kotlin/Function1<io.ktor.client.request/HttpRequestBuilder, kotlin/String?>) // io.ktor.client.plugins.circuitbreaker/CircuitBreakerConfig.routeRequests|routeRequests(kotlin.Function1<io.ktor.client.request.HttpRequestBuilder,kotlin.String?>){}[0]
}

final class io.ktor.client.plugins.circuitbreaker/CircuitBreakerOpenException : kotlin/IllegalStateException { // io.ktor.client.plugins.circuitbreaker/CircuitBreakerOpenException|null[0]
constructor <init>(kotlin/String, kotlin.time/Duration) // io.ktor.client.plugins.circuitbreaker/CircuitBreakerOpenException.<init>|<init>(kotlin.String;kotlin.time.Duration){}[0]

final val circuitBreakerName // io.ktor.client.plugins.circuitbreaker/CircuitBreakerOpenException.circuitBreakerName|{}circuitBreakerName[0]
final fun <get-circuitBreakerName>(): kotlin/String // io.ktor.client.plugins.circuitbreaker/CircuitBreakerOpenException.circuitBreakerName.<get-circuitBreakerName>|<get-circuitBreakerName>(){}[0]
final val resetTimeout // io.ktor.client.plugins.circuitbreaker/CircuitBreakerOpenException.resetTimeout|{}resetTimeout[0]
final fun <get-resetTimeout>(): kotlin.time/Duration // io.ktor.client.plugins.circuitbreaker/CircuitBreakerOpenException.resetTimeout.<get-resetTimeout>|<get-resetTimeout>(){}[0]
}

final class io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig { // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig|null[0]
constructor <init>() // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.<init>|<init>(){}[0]

final var failureThreshold // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.failureThreshold|{}failureThreshold[0]
final fun <get-failureThreshold>(): kotlin/Int // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.failureThreshold.<get-failureThreshold>|<get-failureThreshold>(){}[0]
final fun <set-failureThreshold>(kotlin/Int) // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.failureThreshold.<set-failureThreshold>|<set-failureThreshold>(kotlin.Int){}[0]
final var halfOpenRequests // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.halfOpenRequests|{}halfOpenRequests[0]
final fun <get-halfOpenRequests>(): kotlin/Int // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.halfOpenRequests.<get-halfOpenRequests>|<get-halfOpenRequests>(){}[0]
final fun <set-halfOpenRequests>(kotlin/Int) // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.halfOpenRequests.<set-halfOpenRequests>|<set-halfOpenRequests>(kotlin.Int){}[0]
final var resetTimeout // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.resetTimeout|{}resetTimeout[0]
final fun <get-resetTimeout>(): kotlin.time/Duration // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.resetTimeout.<get-resetTimeout>|<get-resetTimeout>(){}[0]
final fun <set-resetTimeout>(kotlin.time/Duration) // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.resetTimeout.<set-resetTimeout>|<set-resetTimeout>(kotlin.time.Duration){}[0]

final fun isFailure(kotlin/Function1<io.ktor.client.statement/HttpResponse, kotlin/Boolean>) // io.ktor.client.plugins.circuitbreaker/ServiceCircuitBreakerConfig.isFailure|isFailure(kotlin.Function1<io.ktor.client.statement.HttpResponse,kotlin.Boolean>){}[0]
}

final val io.ktor.client.plugins.circuitbreaker/CircuitBreaker // io.ktor.client.plugins.circuitbreaker/CircuitBreaker|{}CircuitBreaker[0]
final fun <get-CircuitBreaker>(): io.ktor.client.plugins.api/ClientPlugin<io.ktor.client.plugins.circuitbreaker/CircuitBreakerConfig> // io.ktor.client.plugins.circuitbreaker/CircuitBreaker.<get-CircuitBreaker>|<get-CircuitBreaker>(){}[0]

final fun (io.ktor.client.request/HttpRequestBuilder).io.ktor.client.plugins.circuitbreaker/circuitBreaker(kotlin/String) // io.ktor.client.plugins.circuitbreaker/circuitBreaker|circuitBreaker@io.ktor.client.request.HttpRequestBuilder(kotlin.String){}[0]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

description = "Ktor client Circuit Breaker support"

plugins {
id("ktorbuild.project.client-plugin")
}

kotlin {
sourceSets {
commonTest.dependencies {
implementation(projects.ktorClientMock)
implementation(projects.ktorTestDispatcher)
}
}
}
Loading