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
committed by Independent Security Research
parent c345ce6d02
commit ade8539846
8 changed files with 260 additions and 79 deletions

View File

@@ -60,6 +60,9 @@ pub enum ApiError {
retryable: bool, retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413) /// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>, suggested_action: Option<String>,
/// Parsed Retry-After header value (seconds) for 429 responses.
/// When present, overrides the exponential backoff delay.
retry_after: Option<Duration>,
}, },
RetriesExhausted { RetriesExhausted {
attempts: u32, attempts: u32,
@@ -128,6 +131,18 @@ impl ApiError {
} }
#[must_use] #[must_use]
/// 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.
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
_ => None,
}
}
pub fn is_retryable(&self) -> bool { pub fn is_retryable(&self) -> bool {
match self { match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(), Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
@@ -496,6 +511,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
assert!(error.is_generic_fatal_wrapper()); assert!(error.is_generic_fatal_wrapper());
@@ -519,6 +535,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}), }),
}; };
@@ -540,6 +557,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
assert!(error.is_context_window_failure()); assert!(error.is_context_window_failure());

View File

@@ -1,9 +1,69 @@
use std::time::Duration;
use crate::error::ApiError; use crate::error::ApiError;
const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"]; const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"];
const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"]; const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"];
const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"]; const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"];
/// Timeout configuration for outbound HTTP requests.
///
/// When set, the `reqwest::Client` will abort requests that take longer
/// than the configured duration and return a timeout error (which is
/// retryable by the existing exponential backoff logic).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeoutConfig {
/// Maximum time to wait for a connection to be established.
/// Defaults to 30 seconds.
pub connect_timeout: Duration,
/// Maximum time for the entire request (including reading the response
/// body). For streaming responses this is the timeout for the initial
/// handshake only; the stream itself is governed by SSE parsing.
/// Defaults to 5 minutes (300 seconds).
pub request_timeout: Duration,
}
impl Default for TimeoutConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(30),
request_timeout: Duration::from_secs(300),
}
}
}
impl TimeoutConfig {
/// Read timeout settings from the process environment.
/// - `CLAW_API_CONNECT_TIMEOUT` — connect timeout in seconds
/// - `CLAW_API_REQUEST_TIMEOUT` — overall request timeout in seconds
#[must_use]
pub fn from_env() -> Self {
let connect_timeout = std::env::var("CLAW_API_CONNECT_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(30));
let request_timeout = std::env::var("CLAW_API_REQUEST_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(300));
Self {
connect_timeout,
request_timeout,
}
}
/// Create from explicit second values (used by config file parsing).
#[must_use]
pub fn from_seconds(connect_secs: u64, request_secs: u64) -> Self {
Self {
connect_timeout: Duration::from_secs(connect_secs),
request_timeout: Duration::from_secs(request_secs),
}
}
}
/// Snapshot of the proxy-related environment variables that influence the /// Snapshot of the proxy-related environment variables that influence the
/// outbound HTTP client. Captured up front so callers can inspect, log, and /// outbound HTTP client. Captured up front so callers can inspect, log, and
/// test the resolved configuration without re-reading the process environment. /// test the resolved configuration without re-reading the process environment.
@@ -61,7 +121,7 @@ impl ProxyConfig {
/// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is /// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is
/// configured the client behaves identically to `reqwest::Client::new()`. /// configured the client behaves identically to `reqwest::Client::new()`.
pub fn build_http_client() -> Result<reqwest::Client, ApiError> { pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
build_http_client_with(&ProxyConfig::from_env()) build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
} }
/// Infallible counterpart to [`build_http_client`] for constructors that /// Infallible counterpart to [`build_http_client`] for constructors that
@@ -71,7 +131,8 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
/// first outbound request instead of at construction time. /// first outbound request instead of at construction time.
#[must_use] #[must_use]
pub fn build_http_client_or_default() -> reqwest::Client { pub fn build_http_client_or_default() -> reqwest::Client {
build_http_client().unwrap_or_else(|_| { build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
.unwrap_or_else(|_| {
reqwest::Client::builder() reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1") .user_agent("clawd-rust-tools/0.1")
.build() .build()
@@ -86,9 +147,20 @@ pub fn build_http_client_or_default() -> reqwest::Client {
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS /// and `https_proxy` fields and is registered as both an HTTP and HTTPS
/// proxy so a single value can route every outbound request. /// proxy so a single value can route every outbound request.
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> { pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(config, &TimeoutConfig::from_env())
}
/// Build a `reqwest::Client` from explicit [`ProxyConfig`] and [`TimeoutConfig`].
/// Used by callers that want to control both proxy routing and request timing.
pub fn build_http_client_with_opts(
config: &ProxyConfig,
timeout: &TimeoutConfig,
) -> Result<reqwest::Client, ApiError> {
let mut builder = reqwest::Client::builder() let mut builder = reqwest::Client::builder()
.no_proxy() .no_proxy()
.user_agent("clawd-rust-tools/0.1"); .user_agent("clawd-rust-tools/0.1")
.connect_timeout(timeout.connect_timeout)
.timeout(timeout.request_timeout);
let no_proxy = config let no_proxy = config
.no_proxy .no_proxy
@@ -131,7 +203,7 @@ where
mod tests { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use super::{build_http_client_with, ProxyConfig}; use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig};
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig { fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
let map: HashMap<String, String> = pairs let map: HashMap<String, String> = pairs
@@ -143,30 +215,19 @@ mod tests {
#[test] #[test]
fn proxy_config_is_empty_when_no_env_vars_are_set() { fn proxy_config_is_empty_when_no_env_vars_are_set() {
// given
let config = config_from_map(&[]); let config = config_from_map(&[]);
assert!(config.is_empty());
// when
let empty = config.is_empty();
// then
assert!(empty);
assert_eq!(config, ProxyConfig::default()); assert_eq!(config, ProxyConfig::default());
} }
#[test] #[test]
fn proxy_config_reads_uppercase_http_https_and_no_proxy() { fn proxy_config_reads_uppercase_http_https_and_no_proxy() {
// given
let pairs = [ let pairs = [
("HTTP_PROXY", "http://proxy.internal:3128"), ("HTTP_PROXY", "http://proxy.internal:3128"),
("HTTPS_PROXY", "http://secure.internal:3129"), ("HTTPS_PROXY", "http://secure.internal:3129"),
("NO_PROXY", "localhost,127.0.0.1,.corp"), ("NO_PROXY", "localhost,127.0.0.1,.corp"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://proxy.internal:3128") Some("http://proxy.internal:3128")
@@ -184,17 +245,12 @@ mod tests {
#[test] #[test]
fn proxy_config_falls_back_to_lowercase_keys() { fn proxy_config_falls_back_to_lowercase_keys() {
// given
let pairs = [ let pairs = [
("http_proxy", "http://lower.internal:3128"), ("http_proxy", "http://lower.internal:3128"),
("https_proxy", "http://lower-secure.internal:3129"), ("https_proxy", "http://lower-secure.internal:3129"),
("no_proxy", ".lower"), ("no_proxy", ".lower"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://lower.internal:3128") Some("http://lower.internal:3128")
@@ -208,16 +264,11 @@ mod tests {
#[test] #[test]
fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() { fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() {
// given
let pairs = [ let pairs = [
("HTTP_PROXY", "http://upper.internal:3128"), ("HTTP_PROXY", "http://upper.internal:3128"),
("http_proxy", "http://lower.internal:3128"), ("http_proxy", "http://lower.internal:3128"),
]; ];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert_eq!( assert_eq!(
config.http_proxy.as_deref(), config.http_proxy.as_deref(),
Some("http://upper.internal:3128") Some("http://upper.internal:3128")
@@ -226,59 +277,39 @@ mod tests {
#[test] #[test]
fn proxy_config_treats_empty_strings_as_unset() { fn proxy_config_treats_empty_strings_as_unset() {
// given
let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")]; let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")];
// when
let config = config_from_map(&pairs); let config = config_from_map(&pairs);
// then
assert!(config.http_proxy.is_none()); assert!(config.http_proxy.is_none());
} }
#[test] #[test]
fn build_http_client_succeeds_when_no_proxy_is_configured() { fn build_http_client_succeeds_when_no_proxy_is_configured() {
// given
let config = ProxyConfig::default(); let config = ProxyConfig::default();
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_succeeds_with_valid_http_and_https_proxies() { fn build_http_client_succeeds_with_valid_http_and_https_proxies() {
// given
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: Some("http://proxy.internal:3128".to_string()), http_proxy: Some("http://proxy.internal:3128".to_string()),
https_proxy: Some("http://secure.internal:3129".to_string()), https_proxy: Some("http://secure.internal:3129".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()), no_proxy: Some("localhost,127.0.0.1".to_string()),
proxy_url: None, proxy_url: None,
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_returns_http_error_for_invalid_proxy_url() { fn build_http_client_returns_http_error_for_invalid_proxy_url() {
// given
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: None, http_proxy: None,
https_proxy: Some("not a url".to_string()), https_proxy: Some("not a url".to_string()),
no_proxy: None, no_proxy: None,
proxy_url: None, proxy_url: None,
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
let error = result.expect_err("invalid proxy URL must be reported as a build failure"); let error = result.expect_err("invalid proxy URL must be reported as a build failure");
assert!( assert!(
matches!(error, crate::error::ApiError::Http(_)), matches!(error, crate::error::ApiError::Http(_)),
@@ -288,10 +319,7 @@ mod tests {
#[test] #[test]
fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() { fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() {
// given / when
let config = ProxyConfig::from_proxy_url("http://unified.internal:3128"); let config = ProxyConfig::from_proxy_url("http://unified.internal:3128");
// then
assert_eq!( assert_eq!(
config.proxy_url.as_deref(), config.proxy_url.as_deref(),
Some("http://unified.internal:3128") Some("http://unified.internal:3128")
@@ -303,49 +331,56 @@ mod tests {
#[test] #[test]
fn build_http_client_succeeds_with_unified_proxy_url() { fn build_http_client_succeeds_with_unified_proxy_url() {
// given
let config = ProxyConfig { let config = ProxyConfig {
proxy_url: Some("http://unified.internal:3128".to_string()), proxy_url: Some("http://unified.internal:3128".to_string()),
no_proxy: Some("localhost".to_string()), no_proxy: Some("localhost".to_string()),
..ProxyConfig::default() ..ProxyConfig::default()
}; };
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn proxy_url_takes_precedence_over_per_scheme_fields() { fn proxy_url_takes_precedence_over_per_scheme_fields() {
// given both per-scheme and unified are set
let config = ProxyConfig { let config = ProxyConfig {
http_proxy: Some("http://per-scheme.internal:1111".to_string()), http_proxy: Some("http://per-scheme.internal:1111".to_string()),
https_proxy: Some("http://per-scheme.internal:2222".to_string()), https_proxy: Some("http://per-scheme.internal:2222".to_string()),
no_proxy: None, no_proxy: None,
proxy_url: Some("http://unified.internal:3128".to_string()), proxy_url: Some("http://unified.internal:3128".to_string()),
}; };
// when building succeeds (the unified URL is valid)
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn build_http_client_returns_error_for_invalid_unified_proxy_url() { fn build_http_client_returns_error_for_invalid_unified_proxy_url() {
// given
let config = ProxyConfig::from_proxy_url("not a url"); let config = ProxyConfig::from_proxy_url("not a url");
// when
let result = build_http_client_with(&config); let result = build_http_client_with(&config);
// then
assert!( assert!(
matches!(result, Err(crate::error::ApiError::Http(_))), matches!(result, Err(crate::error::ApiError::Http(_))),
"invalid unified proxy URL should fail: {result:?}" "invalid unified proxy URL should fail: {result:?}"
); );
} }
#[test]
fn timeout_config_defaults() {
let config = TimeoutConfig::default();
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(30));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(300));
}
#[test]
fn timeout_config_from_seconds() {
let config = TimeoutConfig::from_seconds(10, 60);
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(10));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(60));
}
#[test]
fn build_http_client_with_custom_timeouts() {
let config = ProxyConfig::default();
let timeout = TimeoutConfig::from_seconds(5, 120);
let result = build_http_client_with_opts(&config, &timeout);
assert!(result.is_ok());
}
} }

View File

@@ -12,7 +12,9 @@ pub use client::{
}; };
pub use error::ApiError; pub use error::ApiError;
pub use http_client::{ pub use http_client::{
build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig, TimeoutConfig,
build_http_client, build_http_client_or_default, build_http_client_with,
build_http_client_with_opts, ProxyConfig,
}; };
pub use prompt_cache::{ pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord, CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,

View File

@@ -211,6 +211,19 @@ impl AnthropicClient {
self self
} }
/// Replace the internal HTTP client with one that respects the given
/// timeout configuration. This controls connect and request-level
/// timeouts for all outbound API calls.
#[must_use]
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
self.http = crate::http_client::build_http_client_with_opts(
&crate::http_client::ProxyConfig::from_env(),
timeout,
)
.unwrap_or_else(|_| reqwest::Client::new());
self
}
#[must_use] #[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self { pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer); self.session_tracer = Some(session_tracer);
@@ -454,7 +467,12 @@ impl AnthropicClient {
break; break;
} }
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await; let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after()) {
retry_after
} else {
self.jittered_backoff_for_attempt(attempts)?
};
tokio::time::sleep(delay).await;
} }
Err(ApiError::RetriesExhausted { Err(ApiError::RetriesExhausted {
@@ -868,10 +886,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response); return Ok(response);
} }
let request_id = request_id_from_headers(response.headers()); let headers = response.headers().clone();
let request_id = request_id_from_headers(&headers);
let body = response.text().await.unwrap_or_else(|_| String::new()); let body = response.text().await.unwrap_or_else(|_| String::new());
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok(); let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status); let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
Err(ApiError::Api { Err(ApiError::Api {
status, status,
@@ -885,9 +905,21 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body, body,
retryable, retryable,
suggested_action: None, suggested_action: None,
retry_after,
}) })
} }
fn parse_retry_after(headers: &reqwest::header::HeaderMap, status: reqwest::StatusCode) -> Option<std::time::Duration> {
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
}
const fn is_retryable_status(status: reqwest::StatusCode) -> bool { const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
} }
@@ -910,6 +942,8 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
..
} = error } = error
else { else {
return error; return error;
@@ -923,6 +957,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}; };
} }
let Some(bearer_token) = auth.bearer_token() else { let Some(bearer_token) = auth.bearer_token() else {
@@ -934,6 +969,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}; };
}; };
if !bearer_token.starts_with("sk-ant-") { if !bearer_token.starts_with("sk-ant-") {
@@ -945,6 +981,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}; };
} }
// Only append the hint when the AuthSource is pure BearerToken. If both // Only append the hint when the AuthSource is pure BearerToken. If both
@@ -960,6 +997,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}; };
} }
let enriched_message = match message { let enriched_message = match message {
@@ -974,6 +1012,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
} }
} }
@@ -1562,6 +1601,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
@@ -1603,6 +1643,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
@@ -1632,6 +1673,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
@@ -1660,6 +1702,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when
@@ -1685,6 +1728,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
// when // when

View File

@@ -165,6 +165,18 @@ impl OpenAiCompatClient {
self self
} }
/// Replace the internal HTTP client with one that respects the given
/// timeout configuration.
#[must_use]
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
self.http = crate::http_client::build_http_client_with_opts(
&crate::http_client::ProxyConfig::from_env(),
timeout,
)
.unwrap_or_else(|_| reqwest::Client::new());
self
}
pub async fn send_message( pub async fn send_message(
&self, &self,
request: &MessageRequest, request: &MessageRequest,
@@ -207,6 +219,7 @@ impl OpenAiCompatClient {
reqwest::StatusCode::from_u16(code.unwrap_or(400)) reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST), .unwrap_or(reqwest::StatusCode::BAD_REQUEST),
), ),
retry_after: None,
}); });
} }
} }
@@ -260,7 +273,12 @@ impl OpenAiCompatClient {
break retryable_error; break retryable_error;
} }
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await; let delay = if let Some(retry_after) = retryable_error.retry_after() {
retry_after
} else {
self.jittered_backoff_for_attempt(attempts)?
};
tokio::time::sleep(delay).await;
}; };
Err(ApiError::RetriesExhausted { Err(ApiError::RetriesExhausted {
@@ -1553,6 +1571,7 @@ fn parse_sse_frame(
body: payload.clone(), body: payload.clone(),
retryable: false, retryable: false,
suggested_action: suggested_action_for_status(status), suggested_action: suggested_action_for_status(status),
retry_after: None,
}); });
} }
} }
@@ -1620,10 +1639,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response); return Ok(response);
} }
let request_id = request_id_from_headers(response.headers()); let headers = response.headers().clone();
let request_id = request_id_from_headers(&headers);
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok(); let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status); let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
let suggested_action = suggested_action_for_status(status); let suggested_action = suggested_action_for_status(status);
@@ -1639,9 +1660,21 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body, body,
retryable, retryable,
suggested_action, suggested_action,
retry_after,
}) })
} }
fn parse_retry_after(headers: &reqwest::header::HeaderMap, status: reqwest::StatusCode) -> Option<std::time::Duration> {
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
}
const fn is_retryable_status(status: reqwest::StatusCode) -> bool { const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504) matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
} }

View File

@@ -51,6 +51,27 @@ pub struct RuntimePluginConfig {
max_output_tokens: Option<u32>, max_output_tokens: Option<u32>,
} }
/// 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,
}
}
}
/// Structured feature configuration consumed by runtime subsystems. /// Structured feature configuration consumed by runtime subsystems.
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig { pub struct RuntimeFeatureConfig {
@@ -65,6 +86,7 @@ pub struct RuntimeFeatureConfig {
sandbox: SandboxConfig, sandbox: SandboxConfig,
provider_fallbacks: ProviderFallbackConfig, provider_fallbacks: ProviderFallbackConfig,
trusted_roots: Vec<String>, trusted_roots: Vec<String>,
api_timeout: ApiTimeoutConfig,
} }
/// Ordered chain of fallback model identifiers used when the primary /// Ordered chain of fallback model identifiers used when the primary
@@ -320,6 +342,7 @@ impl ConfigLoader {
sandbox: parse_optional_sandbox_config(&merged_value)?, sandbox: parse_optional_sandbox_config(&merged_value)?,
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
trusted_roots: parse_optional_trusted_roots(&merged_value)?, trusted_roots: parse_optional_trusted_roots(&merged_value)?,
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
}; };
Ok(RuntimeConfig { Ok(RuntimeConfig {
@@ -1061,6 +1084,28 @@ fn parse_optional_provider_fallbacks(
Ok(ProviderFallbackConfig { primary, fallbacks }) Ok(ProviderFallbackConfig { primary, fallbacks })
} }
fn parse_optional_api_timeout_config(root: &JsonValue) -> Result<ApiTimeoutConfig, ConfigError> {
let Some(timeout_value) = root.as_object().and_then(|obj| obj.get("apiTimeout")) else {
return Ok(ApiTimeoutConfig::default());
};
let Some(obj) = timeout_value.as_object() else {
return Ok(ApiTimeoutConfig::default());
};
let context = "merged settings.apiTimeout";
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 max_retries = optional_u64(obj, "maxRetries", context)?
.map(|v| v as u32)
.unwrap_or(8);
Ok(ApiTimeoutConfig {
connect_timeout_secs,
request_timeout_secs,
max_retries,
})
}
fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigError> { fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigError> {
let Some(object) = root.as_object() else { let Some(object) = root.as_object() else {
return Ok(Vec::new()); return Ok(Vec::new());

View File

@@ -65,7 +65,7 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
}; };
pub use config::{ pub use config::{
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection, ApiTimeoutConfig, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig,

View File

@@ -10547,6 +10547,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
let rendered = format_user_visible_api_error("session-issue-22", &error); let rendered = format_user_visible_api_error("session-issue-22", &error);
@@ -10570,6 +10571,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: true, retryable: true,
suggested_action: None, suggested_action: None,
retry_after: None,
}), }),
}; };
@@ -10634,6 +10636,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}; };
let rendered = format_user_visible_api_error("session-issue-32", &error); let rendered = format_user_visible_api_error("session-issue-32", &error);
@@ -10701,6 +10704,7 @@ mod tests {
body: String::new(), body: String::new(),
retryable: false, retryable: false,
suggested_action: None, suggested_action: None,
retry_after: None,
}), }),
}; };