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
113 changes: 106 additions & 7 deletions docs/docs/prompts/handling-failures.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,105 @@ val config = RetryConfig(
```
<!--- KNIT example-handling-failures-05.kt -->

### Header-aware retry-after hints

Many providers return rate-limit metadata in HTTP response headers rather than (or in addition
to) the error body. OpenAI in particular uses `retry-after`, `x-ratelimit-reset-requests`, and
`x-ratelimit-reset-tokens` to signal when a client should back off.

When the underlying HTTP client raises a `KoogHttpClientException`, the exception exposes the
response headers on its `headers: Map<String, List<String>>` property. The exception constructor
normalizes keys to lowercase, so extractors can look them up without re-casing no matter which
HTTP client implementation produced the error.

Out of the box, `RetryConfig.retryAfterExtractor` is a `CompositeRetryAfterExtractor` that
consults `StandardHeaderRetryAfterExtractor` first and falls back to `DefaultRetryAfterExtractor`.
So a 429 carrying `retry-after: 5` is honored without any extra configuration.

`StandardHeaderRetryAfterExtractor` understands:

- `retry-after` as either delta-seconds or an IMF-fixdate (RFC 9110 §10.2.3);
- `x-ratelimit-reset-requests` / `x-ratelimit-reset-tokens` as Go-style durations like
`1s`, `6m0s`, `100ms` (the format OpenAI uses).

`retry-after` is authoritative: when it carries a usable delay it wins outright. Only when it is
absent or unusable are the reset headers consulted, taking the smallest strictly-positive value
so the client retries as soon as the first rate-limit bucket refills. Every value of a repeated
header is considered, not just the first. A literal `0`, a negative or expired value, and
anything that fails to parse are all treated as "no hint" rather than "retry immediately": the
caller falls back to exponential backoff or to the next extractor in the composite. Whatever the
hint's source, `RetryingLLMClient` caps the resulting delay at `RetryConfig.maxDelay`, so a
misbehaving server cannot stall the retry loop beyond the configured bound.

Retry eligibility follows the same principle: `RetryingLLMClient` matches its retryable patterns
against both the thrown error's message and the message of any `KoogHttpClientException` found in
its cause chain, so a provider wrapper whose own message omits the status code still retries when
the underlying HTTP error is transient.

Provider-specific header names do not require a custom extractor: pass them to
`StandardHeaderRetryAfterExtractor` directly, as shown below. For full control, implement
`RetryAfterExtractor` and override `extract(error: KoogHttpClientException)`. Existing single-arg
SAM lambdas that only inspect the error message continue to work unchanged.

=== "Kotlin"

<!--- INCLUDE
import ai.koog.prompt.executor.clients.retry.CompositeRetryAfterExtractor
import ai.koog.prompt.executor.clients.retry.DefaultRetryAfterExtractor
import ai.koog.prompt.executor.clients.retry.RetryConfig
import ai.koog.prompt.executor.clients.retry.StandardHeaderRetryAfterExtractor
-->
```kotlin
// Provider-specific header names first, message-based fallback second.
val config = RetryConfig(
retryAfterExtractor = CompositeRetryAfterExtractor(
StandardHeaderRetryAfterExtractor(
retryAfterHeaders = listOf("x-my-retry-after"),
resetDurationHeaders = listOf("x-my-ratelimit-reset"),
),
DefaultRetryAfterExtractor,
)
)
```
<!--- KNIT example-handling-failures-06.kt -->

=== "Java"

<!--- INCLUDE
/**
-->
<!--- SUFFIX
**/
-->
```java
RetryAfterExtractor myExtractor = new RetryAfterExtractor() {
@Override
public Duration extract(String message) {
return null;
}

@Override
public Duration extract(KoogHttpClientException error) {
List<String> values = error.getHeaders().get("x-my-retry-after");
if (values == null || values.isEmpty()) return null;
try {
long seconds = Long.parseLong(values.get(0));
return DurationKt.toDuration(seconds, DurationUnit.SECONDS);
} catch (NumberFormatException ex) {
return null;
}
}
};

RetryConfig config = new RetryConfigBuilder()
.retryAfterExtractor(new CompositeRetryAfterExtractor(
myExtractor,
DefaultRetryAfterExtractor.INSTANCE
))
.build();
```
<!--- KNIT example-handling-failures-java-03.java -->

### Streaming with retry

Streaming operations can optionally be retried. This feature is disabled by default.
Expand Down Expand Up @@ -250,7 +349,7 @@ val config = RetryConfig(
val client = RetryingLLMClient(baseClient, config)
val stream = client.executeStreaming(prompt, OpenAIModels.Chat.GPT4o)
```
<!--- KNIT example-handling-failures-06.kt -->
<!--- KNIT example-handling-failures-07.kt -->

!!!note
Streaming retries only apply to connection failures that occur before the first token is received.
Expand Down Expand Up @@ -302,7 +401,7 @@ To learn more about prompt executors, see [Prompt executors](prompt-executors.md
),
)
```
<!--- KNIT example-handling-failures-07.kt -->
<!--- KNIT example-handling-failures-08.kt -->

=== "Java"

Expand Down Expand Up @@ -339,7 +438,7 @@ To learn more about prompt executors, see [Prompt executors](prompt-executors.md

MultiLLMPromptExecutor multiExecutor = new MultiLLMPromptExecutor(clients);
```
<!--- KNIT example-handling-failures-java-03.java -->
<!--- KNIT example-handling-failures-java-04.java -->

## Timeout configuration

Expand Down Expand Up @@ -377,7 +476,7 @@ You can customize these values for your specific needs. For example:
)
)
```
<!--- KNIT example-handling-failures-08.kt -->
<!--- KNIT example-handling-failures-09.kt -->

=== "Java"

Expand Down Expand Up @@ -405,7 +504,7 @@ You can customize these values for your specific needs. For example:
);
OpenAILLMClient client = openAIClient(apiKey, settings);
```
<!--- KNIT example-handling-failures-java-04.java -->
<!--- KNIT example-handling-failures-java-05.java -->

!!! tip
For long-running or streaming calls, set higher values for `requestTimeoutMillis` and `socketTimeoutMillis`.
Expand Down Expand Up @@ -474,7 +573,7 @@ Here is an example of error handling in Kotlin and Java:
}
}
```
<!--- KNIT example-handling-failures-09.kt -->
<!--- KNIT example-handling-failures-10.kt -->

=== "Java"

Expand Down Expand Up @@ -529,4 +628,4 @@ Here is an example of error handling in Kotlin and Java:
}
}
```
<!--- KNIT example-handling-failures-java-05.java -->
<!--- KNIT example-handling-failures-java-06.java -->
12 changes: 11 additions & 1 deletion http-client/http-client-core/api/android/http-client-core.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
public final class ai/koog/http/client/HeadersKt {
public static final fun lowercaseHeaderKeys (Ljava/util/Map;)Ljava/util/Map;
}

public final class ai/koog/http/client/HttpClientFactoryResolver {
public static final field INSTANCE Lai/koog/http/client/HttpClientFactoryResolver;
public final fun resolve ()Lai/koog/http/client/KoogHttpClient$Factory;
Expand Down Expand Up @@ -44,10 +48,16 @@ public final class ai/koog/http/client/KoogHttpClient$Factory$DefaultImpls {

public final class ai/koog/http/client/KoogHttpClientException : java/lang/Exception {
public fun <init> ()V
public fun <init> (Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;Ljava/util/Map;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getClientName ()Ljava/lang/String;
public final fun getErrorBody ()Ljava/lang/String;
public final fun getHeaders ()Ljava/util/Map;
public final fun getStatusCode ()Ljava/lang/Integer;
}

Expand Down
5 changes: 4 additions & 1 deletion http-client/http-client-core/api/http-client-core.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,19 @@ abstract interface ai.koog.http.client/KoogHttpClient : kotlin/AutoCloseable { /
}

final class ai.koog.http.client/KoogHttpClientException : kotlin/Exception { // ai.koog.http.client/KoogHttpClientException|null[0]
constructor <init>(kotlin/String? = ..., kotlin/Int? = ..., kotlin/String? = ..., kotlin/String? = ..., kotlin/Throwable? = ...) // ai.koog.http.client/KoogHttpClientException.<init>|<init>(kotlin.String?;kotlin.Int?;kotlin.String?;kotlin.String?;kotlin.Throwable?){}[0]
constructor <init>(kotlin/String? = ..., kotlin/Int? = ..., kotlin/String? = ..., kotlin/String? = ..., kotlin/Throwable? = ..., kotlin.collections/Map<kotlin/String, kotlin.collections/List<kotlin/String>> = ...) // ai.koog.http.client/KoogHttpClientException.<init>|<init>(kotlin.String?;kotlin.Int?;kotlin.String?;kotlin.String?;kotlin.Throwable?;kotlin.collections.Map<kotlin.String,kotlin.collections.List<kotlin.String>>){}[0]

final val clientName // ai.koog.http.client/KoogHttpClientException.clientName|{}clientName[0]
final fun <get-clientName>(): kotlin/String? // ai.koog.http.client/KoogHttpClientException.clientName.<get-clientName>|<get-clientName>(){}[0]
final val errorBody // ai.koog.http.client/KoogHttpClientException.errorBody|{}errorBody[0]
final fun <get-errorBody>(): kotlin/String? // ai.koog.http.client/KoogHttpClientException.errorBody.<get-errorBody>|<get-errorBody>(){}[0]
final val headers // ai.koog.http.client/KoogHttpClientException.headers|{}headers[0]
final fun <get-headers>(): kotlin.collections/Map<kotlin/String, kotlin.collections/List<kotlin/String>> // ai.koog.http.client/KoogHttpClientException.headers.<get-headers>|<get-headers>(){}[0]
final val statusCode // ai.koog.http.client/KoogHttpClientException.statusCode|{}statusCode[0]
final fun <get-statusCode>(): kotlin/Int? // ai.koog.http.client/KoogHttpClientException.statusCode.<get-statusCode>|<get-statusCode>(){}[0]
}

final fun (kotlin.collections/Map<kotlin/String, kotlin.collections/List<kotlin/String>>).ai.koog.http.client/lowercaseHeaderKeys(): kotlin.collections/Map<kotlin/String, kotlin.collections/List<kotlin/String>> // ai.koog.http.client/lowercaseHeaderKeys|lowercaseHeaderKeys@kotlin.collections.Map<kotlin.String,kotlin.collections.List<kotlin.String>>(){}[0]
final fun ai.koog.http.client/mergeHeaders(kotlin/Array<out kotlin.collections/Map<kotlin/String, kotlin/String>>...): kotlin.collections/Map<kotlin/String, kotlin/String> // ai.koog.http.client/mergeHeaders|mergeHeaders(kotlin.Array<out|kotlin.collections.Map<kotlin.String,kotlin.String>>...){}[0]
final inline fun <#A: reified kotlin/Any, #B: reified kotlin/Any, #C: kotlin/Any> (ai.koog.http.client/KoogHttpClient).ai.koog.http.client/sse(kotlin/String, #A, noinline kotlin/Function1<kotlin/String?, kotlin/Boolean> = ..., noinline kotlin/Function1<kotlin/String, #B>, noinline kotlin/Function1<#B, #C?>, kotlin.collections/Map<kotlin/String, kotlin/String> = ..., kotlin.collections/Map<kotlin/String, kotlin/String> = ...): kotlinx.coroutines.flow/Flow<#C> // ai.koog.http.client/sse|sse@ai.koog.http.client.KoogHttpClient(kotlin.String;0:0;kotlin.Function1<kotlin.String?,kotlin.Boolean>;kotlin.Function1<kotlin.String,0:1>;kotlin.Function1<0:1,0:2?>;kotlin.collections.Map<kotlin.String,kotlin.String>;kotlin.collections.Map<kotlin.String,kotlin.String>){0§<kotlin.Any>;1§<kotlin.Any>;2§<kotlin.Any>}[0]
final inline fun <#A: reified kotlin/Any> (ai.koog.http.client/KoogHttpClient).ai.koog.http.client/lines(kotlin/String, #A, kotlin.collections/Map<kotlin/String, kotlin/String> = ..., kotlin.collections/Map<kotlin/String, kotlin/String> = ...): kotlinx.coroutines.flow/Flow<kotlin/String> // ai.koog.http.client/lines|lines@ai.koog.http.client.KoogHttpClient(kotlin.String;0:0;kotlin.collections.Map<kotlin.String,kotlin.String>;kotlin.collections.Map<kotlin.String,kotlin.String>){0§<kotlin.Any>}[0]
Expand Down
12 changes: 11 additions & 1 deletion http-client/http-client-core/api/jvm/http-client-core.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
public final class ai/koog/http/client/HeadersKt {
public static final fun lowercaseHeaderKeys (Ljava/util/Map;)Ljava/util/Map;
}

public final class ai/koog/http/client/HttpClientFactoryResolver {
public static final field INSTANCE Lai/koog/http/client/HttpClientFactoryResolver;
public final fun resolve ()Lai/koog/http/client/KoogHttpClient$Factory;
Expand Down Expand Up @@ -44,10 +48,16 @@ public final class ai/koog/http/client/KoogHttpClient$Factory$DefaultImpls {

public final class ai/koog/http/client/KoogHttpClientException : java/lang/Exception {
public fun <init> ()V
public fun <init> (Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;Ljava/util/Map;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getClientName ()Ljava/lang/String;
public final fun getErrorBody ()Ljava/lang/String;
public final fun getHeaders ()Ljava/util/Map;
public final fun getStatusCode ()Ljava/lang/Integer;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
package ai.koog.http.client

import kotlin.jvm.JvmOverloads

/**
* Base exception class for HTTP clients in koog
* Base exception class for HTTP clients in koog.
*
* @property clientName Name of the HTTP client that produced the error, for log attribution.
* @property statusCode HTTP status code returned by the server, or `null` if no response was received.
* @property errorBody Raw body of the failed response, or `null` if it could not be read.
* @param headers HTTP response headers captured from the failed response, in whatever casing
* the producer has them. Defaults to an empty map when no response was available
* (e.g., connection errors).
*/
public class KoogHttpClientException(
public class KoogHttpClientException @JvmOverloads constructor(
public val clientName: String? = null,
public val statusCode: Int? = null,
public val errorBody: String? = null,
message: String? = null,
cause: Throwable? = null
cause: Throwable? = null,
headers: Map<String, List<String>> = emptyMap()
) : Exception(
buildString {
appendLine("Error from client: ${clientName ?: "unknown client"}")
Expand All @@ -20,4 +30,14 @@ public class KoogHttpClientException(
}
},
cause
)
) {
/**
* HTTP response headers captured from the failed response.
*
* Keys are normalized to lowercase here, in the constructor, so consumers can look headers
* up without re-casing no matter how the producing client cased them (see
* [lowercaseHeaderKeys]); values preserve the order and formatting from the server.
* Empty when no response was available (e.g., connection errors).
*/
public val headers: Map<String, List<String>> = headers.lowercaseHeaderKeys()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package ai.koog.http.client

/**
* Returns a copy of this multi-valued header map with all keys converted to lowercase.
*
* Values for keys that differ only in case are concatenated into a single list so that
* a response containing e.g. both `Set-Cookie` and `set-cookie` becomes a single lowercase
* entry with both values. Empty maps are returned as [emptyMap] to avoid allocation.
*
* [KoogHttpClientException] applies this normalization in its constructor, so HTTP client
* implementations can pass their native header maps through as-is; the helper stays public
* for consumers that need the same normalization elsewhere.
*/
public fun Map<String, List<String>>.lowercaseHeaderKeys(): Map<String, List<String>> {
if (isEmpty()) return emptyMap()
val normalized = LinkedHashMap<String, MutableList<String>>(size)
for ((key, values) in this) {
normalized.getOrPut(key.lowercase()) { mutableListOf() }.addAll(values)
}
return normalized
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import ai.koog.http.client.KoogHttpClientException
import ai.koog.http.client.lowercaseHeaderKeys
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class KoogHttpClientExceptionTest {

@Test
fun testConstructorNormalizesHeaderKeysToLowercase() {
val exception = KoogHttpClientException(
clientName = "TestClient",
statusCode = 429,
headers = mapOf(
"Retry-After" to listOf("5"),
"X-RateLimit-Reset-Tokens" to listOf("6m0s")
)
)

assertEquals(listOf("5"), exception.headers["retry-after"])
assertEquals(listOf("6m0s"), exception.headers["x-ratelimit-reset-tokens"])
assertEquals(setOf("retry-after", "x-ratelimit-reset-tokens"), exception.headers.keys)
}

@Test
fun testConstructorMergesKeysDifferingOnlyInCase() {
val exception = KoogHttpClientException(
headers = mapOf(
"Set-Cookie" to listOf("a=1"),
"set-cookie" to listOf("b=2")
)
)

assertEquals(listOf("a=1", "b=2"), exception.headers["set-cookie"])
}

@Test
fun testHeadersDefaultToEmpty() {
assertTrue(KoogHttpClientException(clientName = "TestClient").headers.isEmpty())
}

@Test
fun testLowercaseHeaderKeysOnEmptyMapAvoidsAllocation() {
val empty: Map<String, List<String>> = emptyMap()
assertTrue(empty.lowercaseHeaderKeys().isEmpty())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public class JavaKoogHttpClient internal constructor(
clientName = clientName,
statusCode = response.statusCode(),
errorBody = response.body(),
headers = response.headers().map(),
)
}

Expand Down Expand Up @@ -196,6 +197,7 @@ public class JavaKoogHttpClient internal constructor(
KoogHttpClientException(
clientName = clientName,
statusCode = response.statusCode(),
headers = response.headers().map(),
)
)
return@callbackFlow
Expand Down Expand Up @@ -285,6 +287,7 @@ public class JavaKoogHttpClient internal constructor(
KoogHttpClientException(
clientName = clientName,
statusCode = response.statusCode(),
headers = response.headers().map(),
)
)
return@launch
Expand Down
Loading