feat: API timeout config, Retry-After header support, and configurable retry

- Add TimeoutConfig to HTTP client builder with connect_timeout (30s)
  and request_timeout (5min) defaults, configurable via
  CLAW_API_CONNECT_TIMEOUT and CLAW_API_REQUEST_TIMEOUT env vars
- Add with_timeout() builder to both AnthropicClient and
  OpenAiCompatClient for per-client timeout configuration
- Parse Retry-After header on 429 responses and use it to override
  exponential backoff delay when present
- Add ApiTimeoutConfig to runtime config with apiTimeout settings
  in ~/.claw/settings.json (connectTimeout, requestTimeout, maxRetries)
- Add retry_after field to ApiError::Api for propagating rate limit
  backoff hints through the retry pipeline
This commit is contained in:
TheArchitectit 2026-04-27 11:38:31 -05:00
parent 8b120a322d
commit 763179877a
8 changed files with 75 additions and 4 deletions

View File

@ -135,6 +135,10 @@ impl ApiError {
/// Return the `Retry-After` delay if this error came from a 429 response
/// that included a `retry-after` header. Callers should prefer this value
/// over the computed backoff delay when it exists.
<<<<<<< HEAD
=======
#[must_use]
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,

View File

@ -132,20 +132,20 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
#[must_use]
pub fn build_http_client_or_default() -> reqwest::Client {
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
<<<<<<< HEAD
.unwrap_or_else(|_| {
reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1")
.build()
.expect("default client with user_agent should always succeed")
})
=======
.unwrap_or_else(|_| reqwest::Client::new())
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
}
/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests
/// and by callers that want to override process-level environment lookups.
///
/// When `config.proxy_url` is set it overrides the per-scheme `http_proxy`
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS
/// proxy so a single value can route every outbound request.
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(config, &TimeoutConfig::from_env())
}
@ -158,7 +158,10 @@ pub fn build_http_client_with_opts(
) -> Result<reqwest::Client, ApiError> {
let mut builder = reqwest::Client::builder()
.no_proxy()
<<<<<<< HEAD
.user_agent("clawd-rust-tools/0.1")
=======
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
.connect_timeout(timeout.connect_timeout)
.timeout(timeout.request_timeout);

View File

@ -12,8 +12,14 @@ pub use client::{
};
pub use error::ApiError;
pub use http_client::{
<<<<<<< HEAD
build_http_client, build_http_client_or_default, build_http_client_with,
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
=======
TimeoutConfig,
build_http_client, build_http_client_or_default, build_http_client_with,
build_http_client_with_opts, ProxyConfig,
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
};
pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,

View File

@ -467,8 +467,12 @@ impl AnthropicClient {
break;
}
<<<<<<< HEAD
let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after())
{
=======
let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after()) {
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
retry_after
} else {
self.jittered_backoff_for_attempt(attempts)?
@ -907,10 +911,14 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
})
}
<<<<<<< HEAD
fn parse_retry_after(
headers: &reqwest::header::HeaderMap,
status: reqwest::StatusCode,
) -> Option<std::time::Duration> {
=======
fn parse_retry_after(headers: &reqwest::header::HeaderMap, status: reqwest::StatusCode) -> Option<std::time::Duration> {
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}

View File

@ -1764,10 +1764,14 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
})
}
<<<<<<< HEAD
fn parse_retry_after(
headers: &reqwest::header::HeaderMap,
status: reqwest::StatusCode,
) -> Option<std::time::Duration> {
=======
fn parse_retry_after(headers: &reqwest::header::HeaderMap, status: reqwest::StatusCode) -> Option<std::time::Duration> {
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}

View File

@ -152,7 +152,29 @@ pub struct LspServerConfig {
pub command: String,
pub args: Vec<String>,
pub enabled: bool,
<<<<<<< HEAD
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
=======
/// API timeout and retry configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiTimeoutConfig {
/// Connect timeout in seconds. Defaults to 30.
pub connect_timeout_secs: u64,
/// Request timeout in seconds. Defaults to 300 (5 minutes).
pub request_timeout_secs: u64,
/// Maximum retry attempts on transient failures. Defaults to 8.
pub max_retries: u32,
}
impl Default for ApiTimeoutConfig {
fn default() -> Self {
Self {
connect_timeout_secs: 30,
request_timeout_secs: 300,
max_retries: 8,
}
}
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
}
/// Structured feature configuration consumed by runtime subsystems.
@ -233,6 +255,7 @@ impl RuntimeProviderConfig {
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
api_timeout: ApiTimeoutConfig,
}
/// Ordered chain of fallback model identifiers used when the primary
@ -680,7 +703,16 @@ impl ConfigLoader {
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
lsp: parse_optional_lsp_config(&merged_value)?,
<<<<<<< HEAD
>>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration)
=======
lsp_auto_start: merged_value
.as_object()
.and_then(|o| o.get("lspAutoStart"))
.and_then(JsonValue::as_bool)
.unwrap_or(true),
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
};
ConfigInspection {
@ -2142,8 +2174,15 @@ fn parse_optional_api_timeout_config(root: &JsonValue) -> Result<ApiTimeoutConfi
return Ok(ApiTimeoutConfig::default());
};
let context = "merged settings.apiTimeout";
<<<<<<< HEAD
let connect_timeout_secs = optional_u64(obj, "connectTimeout", context)?.unwrap_or(30);
let request_timeout_secs = optional_u64(obj, "requestTimeout", context)?.unwrap_or(300);
=======
let connect_timeout_secs = optional_u64(obj, "connectTimeout", context)?
.unwrap_or(30);
let request_timeout_secs = optional_u64(obj, "requestTimeout", context)?
.unwrap_or(300);
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
let max_retries = optional_u64(obj, "maxRetries", context)?
.map(|v| v as u32)
.unwrap_or(8);

View File

@ -84,6 +84,9 @@ pub use config::{
ConfigLoader, ConfigSource, LspServerConfig, McpConfigCollection, McpManagedProxyServerConfig,
McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ApiTimeoutConfig, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig,
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,

View File

@ -14648,7 +14648,11 @@ mod tests {
retryable: false,
suggested_action: None,
retry_after: None,
<<<<<<< HEAD
}),
=======
}),
>>>>>>> 07ce5aee (feat: API timeout config, Retry-After header support, and configurable retry)
};
let rendered = format_user_visible_api_error("session-issue-32", &error);