mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(config): read [retry] jitter/Retry-After keys and [tui].force_http1
#6700 remainder after #6711: `From<RetryPolicy> for RetryConfig` dropped jitter, jitter_factor and respect_retry_after to compiled-in defaults, and CODEWHALE_FORCE_HTTP1 had no config form. - [retry].jitter / jitter_factor (clamped 0.0..=1.0) / respect_retry_after are parsed, resolved in Config::retry_policy, and carried through both RetryPolicy <-> RetryConfig conversions. - [tui].force_http1 pins the model HTTP client to HTTP/1.1; the env var still works and either one pins (Config::force_http1). - Documented in docs/CONFIGURATION.md and config.example.toml. Refs #6700 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This commit is contained in:
1 parent
b254c1c302
commit
61ecea887f
10 files changed
+142
-7
No files matched your search
@@ -1178,6 +1178,7 @@ stream_max_duration_secs = 1800 # per-step cap on one stream's duration (0 = def
|
||||
# stream_max_errors = 5 # recoverable errors tolerated per stream (0 = default, 1-50)
|
||||
# stream_open_timeout_secs = 45 # response-header wait (0 = env/default, 5-300)
|
||||
# connect_timeout_secs = 30 # TCP/TLS connect timeout (0 = default, 1-300)
|
||||
# force_http1 = false # pin HTTP/1.1 (same as CODEWHALE_FORCE_HTTP1=1)
|
||||
osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender
|
||||
# What the bottom chrome shows. Each key is one thing on screen: `mode` is the
|
||||
# posture bar's plan/act/operate chip, everything else is a segment of the
|
||||
@@ -1290,6 +1291,9 @@ max_retries = 3
|
||||
initial_delay = 1.0
|
||||
max_delay = 60.0
|
||||
exponential_base = 2.0
|
||||
# jitter = true # randomize each backoff delay
|
||||
# jitter_factor = 0.1 # ±10% spread (0.0-1.0)
|
||||
# respect_retry_after = true # honor a server Retry-After header
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Goal loop (`[goal]`) — operate-mode persistent goals
|
||||
|
||||
@@ -1591,6 +1591,12 @@ impl CodewhaleClient {
|
||||
let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs());
|
||||
let stream_open_timeout = config.stream_open_timeout();
|
||||
let connect_timeout = config.connect_timeout();
|
||||
let force_http1 = config.force_http1();
|
||||
if force_http1 {
|
||||
logging::info(
|
||||
"HTTP/1.1 pinned ([tui].force_http1 or CODEWHALE_FORCE_HTTP1) — HTTP/2 disabled",
|
||||
);
|
||||
}
|
||||
let http_headers = config.http_headers();
|
||||
let auth_disabled =
|
||||
auth_mode_disables_api_key(config.auth_mode_for_provider(api_provider).as_deref());
|
||||
@@ -1645,7 +1651,7 @@ impl CodewhaleClient {
|
||||
&base_url,
|
||||
wire_format,
|
||||
auth_disabled,
|
||||
false,
|
||||
force_http1,
|
||||
connect_timeout,
|
||||
)?
|
||||
.build()?;
|
||||
@@ -1656,7 +1662,7 @@ impl CodewhaleClient {
|
||||
&base_url,
|
||||
wire_format,
|
||||
auth_disabled,
|
||||
false,
|
||||
force_http1,
|
||||
connect_timeout,
|
||||
)?
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
@@ -2042,11 +2048,7 @@ impl CodewhaleClient {
|
||||
.http2_keep_alive_interval(Some(Duration::from_secs(15)))
|
||||
.http2_keep_alive_timeout(Duration::from_secs(20))
|
||||
.min_tls_version(reqwest::tls::Version::TLS_1_2);
|
||||
let pin_http1 = force_http1 || force_http1_from_env();
|
||||
if pin_http1 {
|
||||
if force_http1_from_env() && !force_http1 {
|
||||
logging::info("CODEWHALE_FORCE_HTTP1=1 — pinning HTTP client to HTTP/1.1");
|
||||
}
|
||||
if force_http1 || force_http1_from_env() {
|
||||
builder = builder.http1_only();
|
||||
}
|
||||
if let Ok(cert_path) = std::env::var("SSL_CERT_FILE")
|
||||
|
||||
@@ -82,6 +82,9 @@ fn test_codex_config(server: &MockServer) -> Config {
|
||||
initial_delay: Some(0.0),
|
||||
max_delay: Some(0.0),
|
||||
exponential_base: Some(1.0),
|
||||
jitter: None,
|
||||
jitter_factor: None,
|
||||
respect_retry_after: None,
|
||||
}),
|
||||
providers: Some(ProvidersConfig {
|
||||
openai_codex: ProviderConfig {
|
||||
|
||||
@@ -1756,6 +1756,18 @@ pub struct RetryConfig {
|
||||
pub initial_delay: Option<f64>,
|
||||
pub max_delay: Option<f64>,
|
||||
pub exponential_base: Option<f64>,
|
||||
/// #6700: randomize each backoff delay by `jitter_factor`. Default `true`.
|
||||
#[serde(default)]
|
||||
pub jitter: Option<bool>,
|
||||
/// #6700: jitter spread as a fraction of the delay (`0.1` = ±10%).
|
||||
/// Default `0.1`; values clamp to `0.0..=1.0`, non-finite values use the
|
||||
/// default.
|
||||
#[serde(default)]
|
||||
pub jitter_factor: Option<f64>,
|
||||
/// #6700: honor a server `Retry-After` header instead of the computed
|
||||
/// backoff. Default `true`.
|
||||
#[serde(default)]
|
||||
pub respect_retry_after: Option<bool>,
|
||||
}
|
||||
|
||||
/// Deserialize `status_items` tolerantly: skip keys unknown to this build
|
||||
@@ -1831,6 +1843,10 @@ pub struct TuiConfig {
|
||||
/// #6700: TCP/TLS connect timeout for the model HTTP client, in seconds.
|
||||
/// Omitted or `0` resolve to the default (30); values clamp to `1..=300`.
|
||||
pub connect_timeout_secs: Option<u64>,
|
||||
/// #6700: pin the model HTTP client to HTTP/1.1 (config form of
|
||||
/// `CODEWHALE_FORCE_HTTP1`). Omitted or `false` leaves HTTP/2 on unless
|
||||
/// the env var is truthy; either one pins.
|
||||
pub force_http1: Option<bool>,
|
||||
/// Ordered list of footer items the user wants visible. `None` (the field
|
||||
/// missing from `config.toml`) means "use the built-in default order"; an
|
||||
/// empty `Some(vec![])` means "show nothing in the footer".
|
||||
@@ -2350,6 +2366,9 @@ pub struct RetryPolicy {
|
||||
pub initial_delay: f64,
|
||||
pub max_delay: f64,
|
||||
pub exponential_base: f64,
|
||||
pub jitter: bool,
|
||||
pub jitter_factor: f64,
|
||||
pub respect_retry_after: bool,
|
||||
}
|
||||
|
||||
/// Context management configuration.
|
||||
@@ -8101,6 +8120,17 @@ impl Config {
|
||||
)
|
||||
}
|
||||
|
||||
/// #6700: whether the model HTTP client is pinned to HTTP/1.1 —
|
||||
/// `[tui].force_http1 = true` or a truthy `CODEWHALE_FORCE_HTTP1`.
|
||||
#[must_use]
|
||||
pub fn force_http1(&self) -> bool {
|
||||
self.tui
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.force_http1)
|
||||
.unwrap_or(false)
|
||||
|| crate::client::force_http1_from_env()
|
||||
}
|
||||
|
||||
/// #6700: resolved TCP/TLS connect timeout for the model HTTP client.
|
||||
#[must_use]
|
||||
pub fn connect_timeout(&self) -> std::time::Duration {
|
||||
@@ -8359,6 +8389,9 @@ impl Config {
|
||||
initial_delay: 1.0,
|
||||
max_delay: 60.0,
|
||||
exponential_base: 2.0,
|
||||
jitter: true,
|
||||
jitter_factor: 0.1,
|
||||
respect_retry_after: true,
|
||||
};
|
||||
|
||||
let Some(cfg) = &self.retry else {
|
||||
@@ -8371,6 +8404,14 @@ impl Config {
|
||||
initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),
|
||||
max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),
|
||||
exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),
|
||||
jitter: cfg.jitter.unwrap_or(defaults.jitter),
|
||||
jitter_factor: cfg
|
||||
.jitter_factor
|
||||
.filter(|factor| factor.is_finite())
|
||||
.map_or(defaults.jitter_factor, |factor| factor.clamp(0.0, 1.0)),
|
||||
respect_retry_after: cfg
|
||||
.respect_retry_after
|
||||
.unwrap_or(defaults.respect_retry_after),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15618,3 +15618,40 @@ fn missing_profile_diagnostic_does_not_show_the_requested_name() {
|
||||
// The local error keeps the typed name for the person at the terminal.
|
||||
assert!(error.to_string().contains("sk-pasted-token"), "{error}");
|
||||
}
|
||||
|
||||
/// #6700: `[retry].jitter`, `jitter_factor` and `respect_retry_after` reach
|
||||
/// the resolved policy (and the client's `RetryConfig`) instead of being
|
||||
/// dropped for compiled-in defaults.
|
||||
#[test]
|
||||
fn retry_policy_reads_jitter_and_retry_after_keys() {
|
||||
let parse = |body: &str| toml::from_str::<Config>(body).expect("config parses");
|
||||
|
||||
let defaults = parse("[retry]\nmax_retries = 2\n").retry_policy();
|
||||
assert!(defaults.jitter);
|
||||
assert!((defaults.jitter_factor - 0.1).abs() < f64::EPSILON);
|
||||
assert!(defaults.respect_retry_after);
|
||||
|
||||
let tuned =
|
||||
parse("[retry]\njitter = false\njitter_factor = 0.3\nrespect_retry_after = false\n")
|
||||
.retry_policy();
|
||||
assert!(!tuned.jitter);
|
||||
assert!((tuned.jitter_factor - 0.3).abs() < f64::EPSILON);
|
||||
assert!(!tuned.respect_retry_after);
|
||||
let client: crate::llm_client::RetryConfig = tuned.into();
|
||||
assert!(!client.jitter);
|
||||
assert!((client.jitter_factor - 0.3).abs() < f64::EPSILON);
|
||||
assert!(!client.respect_retry_after);
|
||||
|
||||
let high = parse("[retry]\njitter_factor = 7.5\n").retry_policy();
|
||||
assert!((high.jitter_factor - 1.0).abs() < f64::EPSILON);
|
||||
let negative = parse("[retry]\njitter_factor = -0.5\n").retry_policy();
|
||||
assert!(negative.jitter_factor.abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
/// #6700: `[tui].force_http1 = true` pins HTTP/1.1 without the env var.
|
||||
/// (The env-only path is covered by `force_http1_scenario` under its lock.)
|
||||
#[test]
|
||||
fn tui_force_http1_key_pins_http1() {
|
||||
let config: Config = toml::from_str("[tui]\nforce_http1 = true\n").expect("config parses");
|
||||
assert!(config.force_http1());
|
||||
}
|
||||
@@ -19616,6 +19616,7 @@ api_key = "test-only-key"
|
||||
stream_max_errors: None,
|
||||
stream_open_timeout_secs: None,
|
||||
connect_timeout_secs: None,
|
||||
force_http1: None,
|
||||
status_items: None,
|
||||
posture_bar: None,
|
||||
metrics_line: None,
|
||||
@@ -19721,6 +19722,7 @@ api_key = "test-only-key"
|
||||
stream_max_errors: None,
|
||||
stream_open_timeout_secs: None,
|
||||
connect_timeout_secs: None,
|
||||
force_http1: None,
|
||||
status_items: None,
|
||||
posture_bar: None,
|
||||
metrics_line: None,
|
||||
@@ -19764,6 +19766,7 @@ api_key = "test-only-key"
|
||||
stream_max_errors: None,
|
||||
stream_open_timeout_secs: None,
|
||||
connect_timeout_secs: None,
|
||||
force_http1: None,
|
||||
status_items: None,
|
||||
posture_bar: None,
|
||||
metrics_line: None,
|
||||
@@ -19861,6 +19864,7 @@ api_key = "test-only-key"
|
||||
stream_max_errors: None,
|
||||
stream_open_timeout_secs: None,
|
||||
connect_timeout_secs: None,
|
||||
force_http1: None,
|
||||
status_items: None,
|
||||
posture_bar: None,
|
||||
metrics_line: None,
|
||||
|
||||
@@ -1138,6 +1138,9 @@ impl From<RetryPolicy> for RetryConfig {
|
||||
initial_delay: policy.initial_delay,
|
||||
max_delay: policy.max_delay,
|
||||
exponential_base: policy.exponential_base,
|
||||
jitter: policy.jitter,
|
||||
jitter_factor: policy.jitter_factor,
|
||||
respect_retry_after: policy.respect_retry_after,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1155,9 @@ impl From<RetryConfig> for RetryPolicy {
|
||||
initial_delay: config.initial_delay,
|
||||
max_delay: config.max_delay,
|
||||
exponential_base: config.exponential_base,
|
||||
jitter: config.jitter,
|
||||
jitter_factor: config.jitter_factor,
|
||||
respect_retry_after: config.respect_retry_after,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1755,6 +1761,9 @@ mod tests {
|
||||
initial_delay: 2.0,
|
||||
max_delay: 30.0,
|
||||
exponential_base: 3.0,
|
||||
jitter: false,
|
||||
jitter_factor: 0.25,
|
||||
respect_retry_after: false,
|
||||
};
|
||||
|
||||
let config: RetryConfig = policy.clone().into();
|
||||
@@ -1764,10 +1773,19 @@ mod tests {
|
||||
assert_f64_eq(config.max_delay, policy.max_delay);
|
||||
assert_f64_eq(config.exponential_base, policy.exponential_base);
|
||||
|
||||
// #6700: the jitter and Retry-After knobs survive the conversion
|
||||
// instead of silently resetting to `RetryConfig::default()`.
|
||||
assert!(!config.jitter);
|
||||
assert_f64_eq(config.jitter_factor, 0.25);
|
||||
assert!(!config.respect_retry_after);
|
||||
|
||||
// Convert back
|
||||
let policy2: RetryPolicy = config.into();
|
||||
assert_eq!(policy2.enabled, policy.enabled);
|
||||
assert_eq!(policy2.max_retries, policy.max_retries);
|
||||
assert_eq!(policy2.jitter, policy.jitter);
|
||||
assert_f64_eq(policy2.jitter_factor, policy.jitter_factor);
|
||||
assert_eq!(policy2.respect_retry_after, policy.respect_retry_after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -118,6 +118,9 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
|
||||
initial_delay: Some(0.0),
|
||||
max_delay: Some(0.0),
|
||||
exponential_base: Some(1.0),
|
||||
jitter: None,
|
||||
jitter_factor: None,
|
||||
respect_retry_after: None,
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
@@ -2936,6 +2936,9 @@ async fn always_rate_limited_chat_client() -> (CodewhaleClient, Arc<AtomicUsize>
|
||||
initial_delay: Some(0.0),
|
||||
max_delay: Some(0.0),
|
||||
exponential_base: Some(1.0),
|
||||
jitter: None,
|
||||
jitter_factor: None,
|
||||
respect_retry_after: None,
|
||||
}),
|
||||
..crate::config::Config::default()
|
||||
}
|
||||
@@ -2988,6 +2991,9 @@ async fn always_invalid_request_chat_client() -> (CodewhaleClient, Arc<AtomicUsi
|
||||
initial_delay: Some(0.0),
|
||||
max_delay: Some(0.0),
|
||||
exponential_base: Some(1.0),
|
||||
jitter: None,
|
||||
jitter_factor: None,
|
||||
respect_retry_after: None,
|
||||
}),
|
||||
..crate::config::Config::default()
|
||||
}
|
||||
@@ -16413,6 +16419,9 @@ async fn tool_call_then_invalid_request_chat_client() -> (CodewhaleClient, Arc<A
|
||||
initial_delay: Some(0.0),
|
||||
max_delay: Some(0.0),
|
||||
exponential_base: Some(1.0),
|
||||
jitter: None,
|
||||
jitter_factor: None,
|
||||
respect_retry_after: None,
|
||||
}),
|
||||
..crate::config::Config::default()
|
||||
}
|
||||
@@ -16673,6 +16682,9 @@ async fn denied_call_then_report_chat_client() -> (CodewhaleClient, Arc<AtomicUs
|
||||
initial_delay: Some(0.0),
|
||||
max_delay: Some(0.0),
|
||||
exponential_base: Some(1.0),
|
||||
jitter: None,
|
||||
jitter_factor: None,
|
||||
respect_retry_after: None,
|
||||
}),
|
||||
..crate::config::Config::default()
|
||||
}
|
||||
@@ -25147,6 +25159,9 @@ mod readonly_shell_6015 {
|
||||
initial_delay: Some(0.0),
|
||||
max_delay: Some(0.0),
|
||||
exponential_base: Some(1.0),
|
||||
jitter: None,
|
||||
jitter_factor: None,
|
||||
respect_retry_after: None,
|
||||
}),
|
||||
..crate::config::Config::default()
|
||||
}
|
||||
@@ -25490,6 +25505,9 @@ async fn late_launch_permit_still_gets_the_full_work_budget() {
|
||||
initial_delay: Some(0.0),
|
||||
max_delay: Some(0.0),
|
||||
exponential_base: Some(1.0),
|
||||
jitter: None,
|
||||
jitter_factor: None,
|
||||
respect_retry_after: None,
|
||||
}),
|
||||
..crate::config::Config::default()
|
||||
}
|
||||
|
||||
@@ -2507,6 +2507,10 @@ reasoning contract, and all four membership ids omit generic sampling fields.
|
||||
- `[retry].initial_delay` (float seconds, default `1.0`)
|
||||
- `[retry].max_delay` (float seconds, default `60.0`)
|
||||
- `[retry].exponential_base` (float, default `2.0`)
|
||||
- `[retry].jitter` (bool, default `true`): randomize each backoff delay
|
||||
- `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)
|
||||
- `[retry].respect_retry_after` (bool, default `true`): wait for a server
|
||||
`Retry-After` header instead of the computed backoff
|
||||
|
||||
`[retry]` schedules HTTP-request retries inside the client. The stream-level
|
||||
budgets that sit above it — how often a turn re-issues a request whose
|
||||
@@ -2566,6 +2570,7 @@ reasoning contract, and all four membership ids omit generic sampling fields.
|
||||
- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.
|
||||
- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.
|
||||
- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.
|
||||
- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.
|
||||
- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.
|
||||
- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.
|
||||
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
|
||||
|
||||
Reference in new issue
Block a user