Packages
A well-typed, idiomatic Gleam client for Anthropic's Claude API with streaming support and tool use
Current section
Files
Jump to
Current section
Files
src/anthropic/retry.gleam
//// Retry logic with exponential backoff for transient failures
////
//// This module provides automatic retry functionality for API requests
//// that encounter transient errors like rate limits, server overload, or timeouts.
////
//// ## Features
////
//// - Exponential backoff with configurable base delay
//// - Jitter to prevent thundering herd
//// - Configurable max retries
//// - Respects retry-after headers when available
////
//// ## Example
////
//// ```gleam
//// let retry_config = default_retry_config()
//// |> with_max_retries(5)
//// |> with_base_delay_ms(500)
////
//// let result = retry_with_backoff(retry_config, fn() {
//// api.create_message(client, request)
//// })
//// ```
import anthropic/types/error.{type AnthropicError, is_retryable}
import gleam/float
import gleam/int
import gleam/option.{type Option, None, Some}
// =============================================================================
// Configuration Types
// =============================================================================
/// Configuration for retry behavior
pub type RetryConfig {
RetryConfig(
/// Maximum number of retry attempts (0 = no retries)
max_retries: Int,
/// Base delay in milliseconds for exponential backoff
base_delay_ms: Int,
/// Maximum delay in milliseconds (cap for exponential growth)
max_delay_ms: Int,
/// Jitter factor (0.0 to 1.0) - adds randomness to prevent thundering herd
jitter_factor: Float,
/// Multiplier for exponential backoff (typically 2.0)
backoff_multiplier: Float,
)
}
/// Result of a retry operation with metadata
pub type RetryResult(a) {
RetryResult(
/// The final result (success or last error)
result: Result(a, AnthropicError),
/// Number of attempts made
attempts: Int,
/// Total time spent on retries in milliseconds
total_delay_ms: Int,
)
}
// =============================================================================
// Default Configuration
// =============================================================================
/// Create a default retry configuration
///
/// Defaults:
/// - max_retries: 3
/// - base_delay_ms: 1000 (1 second)
/// - max_delay_ms: 60000 (60 seconds)
/// - jitter_factor: 0.25 (25% randomness)
/// - backoff_multiplier: 2.0
pub fn default_retry_config() -> RetryConfig {
RetryConfig(
max_retries: 3,
base_delay_ms: 1000,
max_delay_ms: 60_000,
jitter_factor: 0.25,
backoff_multiplier: 2.0,
)
}
/// Create a retry configuration with no retries
pub fn no_retry_config() -> RetryConfig {
RetryConfig(
max_retries: 0,
base_delay_ms: 0,
max_delay_ms: 0,
jitter_factor: 0.0,
backoff_multiplier: 1.0,
)
}
/// Create an aggressive retry configuration for high availability
pub fn aggressive_retry_config() -> RetryConfig {
RetryConfig(
max_retries: 5,
base_delay_ms: 500,
max_delay_ms: 30_000,
jitter_factor: 0.5,
backoff_multiplier: 2.0,
)
}
// =============================================================================
// Configuration Builders
// =============================================================================
/// Set the maximum number of retries
pub fn with_max_retries(config: RetryConfig, max_retries: Int) -> RetryConfig {
RetryConfig(..config, max_retries: int.max(0, max_retries))
}
/// Set the base delay in milliseconds
pub fn with_base_delay_ms(
config: RetryConfig,
base_delay_ms: Int,
) -> RetryConfig {
RetryConfig(..config, base_delay_ms: int.max(0, base_delay_ms))
}
/// Set the maximum delay in milliseconds
pub fn with_max_delay_ms(config: RetryConfig, max_delay_ms: Int) -> RetryConfig {
RetryConfig(..config, max_delay_ms: int.max(0, max_delay_ms))
}
/// Set the jitter factor (0.0 to 1.0)
pub fn with_jitter_factor(
config: RetryConfig,
jitter_factor: Float,
) -> RetryConfig {
let clamped = float.min(1.0, float.max(0.0, jitter_factor))
RetryConfig(..config, jitter_factor: clamped)
}
/// Set the backoff multiplier
pub fn with_backoff_multiplier(
config: RetryConfig,
multiplier: Float,
) -> RetryConfig {
RetryConfig(..config, backoff_multiplier: float.max(1.0, multiplier))
}
// =============================================================================
// Delay Calculation
// =============================================================================
/// Calculate delay for a given attempt number (0-indexed)
pub fn calculate_delay(config: RetryConfig, attempt: Int) -> Int {
// Exponential backoff: base_delay * (multiplier ^ attempt)
let base = int.to_float(config.base_delay_ms)
let exponential =
base *. pow(config.backoff_multiplier, int.to_float(attempt))
// Apply max delay cap
let capped = float.min(exponential, int.to_float(config.max_delay_ms))
// Apply jitter
let jitter_range = capped *. config.jitter_factor
let jitter = random_float() *. jitter_range *. 2.0 -. jitter_range
let with_jitter = capped +. jitter
// Ensure non-negative and convert to int
float.truncate(float.max(0.0, with_jitter))
}
/// Calculate delay with optional retry-after header value
pub fn calculate_delay_with_retry_after(
config: RetryConfig,
attempt: Int,
retry_after_ms: Option(Int),
) -> Int {
let calculated = calculate_delay(config, attempt)
case retry_after_ms {
Some(retry_after) -> int.max(calculated, retry_after)
None -> calculated
}
}
// =============================================================================
// Retry Execution
// =============================================================================
/// Execute a function with retry logic
///
/// Returns the first successful result, or the last error after all retries
/// are exhausted.
pub fn retry_with_backoff(
config: RetryConfig,
operation: fn() -> Result(a, AnthropicError),
) -> RetryResult(a) {
retry_loop(config, operation, 0, 0)
}
/// Internal retry loop
fn retry_loop(
config: RetryConfig,
operation: fn() -> Result(a, AnthropicError),
attempt: Int,
total_delay: Int,
) -> RetryResult(a) {
let result = operation()
case result {
Ok(_) ->
RetryResult(
result: result,
attempts: attempt + 1,
total_delay_ms: total_delay,
)
Error(err) -> {
// Check if we should retry
let should_retry =
attempt < config.max_retries
&& is_retryable(err)
&& config.max_retries > 0
case should_retry {
True -> {
// Calculate delay and sleep
let delay = calculate_delay(config, attempt)
sleep_ms(delay)
// Retry
retry_loop(config, operation, attempt + 1, total_delay + delay)
}
False ->
// Return final result
RetryResult(
result: result,
attempts: attempt + 1,
total_delay_ms: total_delay,
)
}
}
}
}
/// Execute with retry and return just the result
pub fn retry(
config: RetryConfig,
operation: fn() -> Result(a, AnthropicError),
) -> Result(a, AnthropicError) {
let RetryResult(result: result, ..) = retry_with_backoff(config, operation)
result
}
// =============================================================================
// Retry Predicates
// =============================================================================
/// Check if an error should be retried based on custom logic
pub fn should_retry_error(error: AnthropicError) -> Bool {
is_retryable(error)
}
/// Get suggested retry delay from an error (if available)
pub fn get_retry_after_from_error(_error: AnthropicError) -> Option(Int) {
// In a full implementation, this would parse retry-after headers
// For now, return None to use calculated backoff
None
}
// =============================================================================
// Utilities
// =============================================================================
/// Check if retry configuration allows retries
pub fn retries_enabled(config: RetryConfig) -> Bool {
config.max_retries > 0
}
/// Get the maximum possible delay for a configuration
pub fn max_total_delay(config: RetryConfig) -> Int {
// Sum of delays for all retry attempts
calculate_max_total_delay(config, 0, 0)
}
fn calculate_max_total_delay(
config: RetryConfig,
attempt: Int,
total: Int,
) -> Int {
case attempt >= config.max_retries {
True -> total
False -> {
let delay = calculate_delay(config, attempt)
calculate_max_total_delay(config, attempt + 1, total + delay)
}
}
}
// =============================================================================
// FFI Helpers
// =============================================================================
/// Sleep for the specified number of milliseconds
@external(erlang, "timer", "sleep")
fn sleep_ms(ms: Int) -> Nil
/// Generate a random float between 0.0 and 1.0
@external(erlang, "rand", "uniform")
fn random_float() -> Float
/// Simple power function for exponential backoff
fn pow(base: Float, exponent: Float) -> Float {
case exponent <=. 0.0 {
True -> 1.0
False -> pow_loop(base, float.truncate(exponent), 1.0)
}
}
fn pow_loop(base: Float, n: Int, acc: Float) -> Float {
case n <= 0 {
True -> acc
False -> pow_loop(base, n - 1, acc *. base)
}
}