feat(core): add configurable RetryConfig for API requests

- Add RetryConfig struct with configurable:
  - max_retries, base_delay_ms, max_delay_ms
  - retry_on_network_error, retry_on_rate_limit
  - Builder pattern methods

- Add delay_for_attempt() for exponential backoff
- Export RetryConfig from lib.rs
- Add 7 tests for retry configuration
  - Default values, builder, no retries
  - Delay calculation with exponential backoff
  - Delay capping at max
  - Error types validation
  - Tool definition serialization

Closes #23

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2025-11-22 18:29:54 +09:00
parent 4755023c43
commit 84f57e1431
2 changed files with 151 additions and 0 deletions

View file

@ -23,6 +23,70 @@ const MAX_RETRIES: u32 = 3;
/// Base delay for exponential backoff (in milliseconds)
const RETRY_BASE_DELAY_MS: u64 = 1000;
/// Retry configuration for API requests
#[derive(Debug, Clone)]
pub struct RetryConfig {
/// Maximum number of retry attempts
pub max_retries: u32,
/// Base delay for exponential backoff (milliseconds)
pub base_delay_ms: u64,
/// Maximum delay cap (milliseconds)
pub max_delay_ms: u64,
/// Whether to retry on network errors
pub retry_on_network_error: bool,
/// Whether to retry on rate limits
pub retry_on_rate_limit: bool,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: MAX_RETRIES,
base_delay_ms: RETRY_BASE_DELAY_MS,
max_delay_ms: 60_000, // 1 minute max
retry_on_network_error: true,
retry_on_rate_limit: true,
}
}
}
impl RetryConfig {
/// Create a new retry config
pub fn new() -> Self {
Self::default()
}
/// Set maximum retries
pub fn with_max_retries(mut self, max: u32) -> Self {
self.max_retries = max;
self
}
/// Set base delay
pub fn with_base_delay_ms(mut self, delay: u64) -> Self {
self.base_delay_ms = delay;
self
}
/// Set maximum delay cap
pub fn with_max_delay_ms(mut self, delay: u64) -> Self {
self.max_delay_ms = delay;
self
}
/// Disable retries
pub fn no_retries(mut self) -> Self {
self.max_retries = 0;
self
}
/// Calculate delay for attempt (exponential backoff)
pub fn delay_for_attempt(&self, attempt: u32) -> u64 {
let delay = self.base_delay_ms * 2u64.pow(attempt);
delay.min(self.max_delay_ms)
}
}
/// Anthropic API errors
#[derive(Error, Debug)]
pub enum AnthropicError {
@ -556,4 +620,90 @@ mod tests {
assert!(json.contains("\"type\":\"text\""));
assert!(json.contains("\"text\":\"Hello\""));
}
#[test]
fn test_retry_config_default() {
let config = RetryConfig::default();
assert_eq!(config.max_retries, 3);
assert_eq!(config.base_delay_ms, 1000);
assert_eq!(config.max_delay_ms, 60_000);
assert!(config.retry_on_network_error);
assert!(config.retry_on_rate_limit);
}
#[test]
fn test_retry_config_builder() {
let config = RetryConfig::new()
.with_max_retries(5)
.with_base_delay_ms(500);
assert_eq!(config.max_retries, 5);
assert_eq!(config.base_delay_ms, 500);
}
#[test]
fn test_retry_config_no_retries() {
let config = RetryConfig::new().no_retries();
assert_eq!(config.max_retries, 0);
}
#[test]
fn test_retry_delay_calculation() {
let config = RetryConfig::new().with_base_delay_ms(1000);
// Exponential backoff: 1000, 2000, 4000, 8000...
assert_eq!(config.delay_for_attempt(0), 1000);
assert_eq!(config.delay_for_attempt(1), 2000);
assert_eq!(config.delay_for_attempt(2), 4000);
assert_eq!(config.delay_for_attempt(3), 8000);
}
#[test]
fn test_retry_delay_capped_at_max() {
let config = RetryConfig::new()
.with_base_delay_ms(1000)
.with_max_delay_ms(5000);
// Should be capped at 5000ms
assert_eq!(config.delay_for_attempt(0), 1000);
assert_eq!(config.delay_for_attempt(1), 2000);
assert_eq!(config.delay_for_attempt(2), 4000);
assert_eq!(config.delay_for_attempt(3), 5000); // capped
assert_eq!(config.delay_for_attempt(10), 5000); // still capped
}
#[test]
fn test_error_types() {
let auth_err = AnthropicError::AuthError("invalid key".to_string());
assert!(auth_err.to_string().contains("invalid key"));
let rate_err = AnthropicError::RateLimited { retry_after_ms: 5000 };
assert!(rate_err.to_string().contains("5000"));
let api_err = AnthropicError::ApiError {
status: 500,
message: "Internal error".to_string(),
};
assert!(api_err.to_string().contains("500"));
assert!(api_err.to_string().contains("Internal error"));
}
#[test]
fn test_tool_definition() {
let tool = Tool {
name: "read".to_string(),
description: "Read a file".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string"}
},
"required": ["path"]
}),
};
let json = serde_json::to_string(&tool).unwrap();
assert!(json.contains("\"name\":\"read\""));
assert!(json.contains("\"description\":\"Read a file\""));
}
}

View file

@ -16,6 +16,7 @@ pub use anthropic::{
AnthropicClient, AnthropicError, Message, Role, ContentBlock,
MessagesRequest, MessagesResponse, StreamEvent, StopReason, Usage,
Tool as ApiTool, // Anthropic API tool definition format
RetryConfig, // Retry configuration for API requests
};
pub use tool::{
Tool as ToolTrait, ToolRegistry, ToolError, ToolOutput, ToolResult, ParameterDef,