Skip to main content

codex_helper_core/
config_retry.rs

1use super::*;
2
3#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
4#[serde(rename_all = "kebab-case")]
5pub enum RetryProfileName {
6    Balanced,
7    SameUpstream,
8    AggressiveFailover,
9    CostPrimary,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct ResolvedRetryLayerConfig {
14    pub max_attempts: u32,
15    pub backoff_ms: u64,
16    pub backoff_max_ms: u64,
17    pub jitter_ms: u64,
18    pub on_status: String,
19    pub on_class: Vec<String>,
20    pub strategy: RetryStrategy,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct ResolvedRetryConfig {
25    pub upstream: ResolvedRetryLayerConfig,
26    pub route: ResolvedRetryLayerConfig,
27    #[serde(default = "ReasoningGuardConfig::default_resolved")]
28    pub reasoning_guard: ResolvedReasoningGuardConfig,
29    /// Guarded cross-station failover before any upstream output is committed to the client.
30    pub allow_cross_station_before_first_output: bool,
31    pub never_on_status: String,
32    pub never_on_class: Vec<String>,
33    pub cloudflare_challenge_cooldown_secs: u64,
34    pub cloudflare_timeout_cooldown_secs: u64,
35    pub transport_cooldown_secs: u64,
36    pub cooldown_backoff_factor: u64,
37    pub cooldown_backoff_max_secs: u64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41#[serde(deny_unknown_fields)]
42pub struct RetryLayerConfig {
43    #[serde(default)]
44    pub max_attempts: Option<u32>,
45    #[serde(default)]
46    pub backoff_ms: Option<u64>,
47    #[serde(default)]
48    pub backoff_max_ms: Option<u64>,
49    #[serde(default)]
50    pub jitter_ms: Option<u64>,
51    #[serde(default)]
52    pub on_status: Option<String>,
53    #[serde(default)]
54    pub on_class: Option<Vec<String>>,
55    #[serde(default)]
56    pub strategy: Option<RetryStrategy>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct RetryConfig {
62    /// Curated retry policy preset. When set, codex-helper starts from the profile defaults,
63    /// then applies any explicitly configured fields below as overrides.
64    #[serde(default)]
65    pub profile: Option<RetryProfileName>,
66    #[serde(default)]
67    pub upstream: Option<RetryLayerConfig>,
68    #[serde(default)]
69    pub provider: Option<RetryLayerConfig>,
70    #[serde(default)]
71    pub reasoning_guard: Option<ReasoningGuardConfig>,
72    /// Allow automatic failover to another station, but only before any output has been
73    /// committed to the client. Session-pinned routes remain sticky regardless of this setting.
74    #[serde(default)]
75    pub allow_cross_station_before_first_output: Option<bool>,
76    #[serde(default)]
77    pub never_on_status: Option<String>,
78    #[serde(default)]
79    pub never_on_class: Option<Vec<String>>,
80    #[serde(default)]
81    pub cloudflare_challenge_cooldown_secs: Option<u64>,
82    #[serde(default)]
83    pub cloudflare_timeout_cooldown_secs: Option<u64>,
84    #[serde(default)]
85    pub transport_cooldown_secs: Option<u64>,
86    /// Optional exponential backoff for cooldown penalties.
87    /// When factor > 1, repeated penalties will increase cooldown up to max_secs.
88    #[serde(default)]
89    pub cooldown_backoff_factor: Option<u64>,
90    #[serde(default)]
91    pub cooldown_backoff_max_secs: Option<u64>,
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
95#[serde(rename_all = "kebab-case")]
96pub enum ReasoningGuardAction {
97    /// Forward the matching response and only emit diagnostics.
98    Observe,
99    /// Convert the matching response to a local 502 without attempting a retry.
100    Block,
101    /// Convert the matching response to a retryable local 502 until the guard retry budget is used.
102    #[default]
103    Retry,
104}
105
106#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
107#[serde(rename_all = "kebab-case")]
108pub enum ReasoningGuardStreamMode {
109    /// Do not inspect streaming responses.
110    Off,
111    /// Inspect buffered streaming responses when another path already buffered them.
112    Observe,
113    /// Buffer the full stream before forwarding so the terminal usage block can be inspected.
114    #[default]
115    StrictBuffer,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
119#[serde(deny_unknown_fields)]
120pub struct ReasoningGuardConfig {
121    #[serde(default)]
122    pub enabled: Option<bool>,
123    #[serde(default)]
124    pub reasoning_equals: Option<Vec<i64>>,
125    #[serde(default)]
126    pub paths: Option<Vec<String>>,
127    #[serde(default)]
128    pub action: Option<ReasoningGuardAction>,
129    #[serde(default)]
130    pub stream_mode: Option<ReasoningGuardStreamMode>,
131    #[serde(default)]
132    pub max_guard_retries: Option<u32>,
133    #[serde(default)]
134    pub log_matches: Option<bool>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct ResolvedReasoningGuardConfig {
139    pub enabled: bool,
140    pub reasoning_equals: Vec<i64>,
141    pub paths: Vec<String>,
142    pub action: ReasoningGuardAction,
143    pub stream_mode: ReasoningGuardStreamMode,
144    pub max_guard_retries: u32,
145    pub log_matches: bool,
146}
147
148#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
149#[serde(rename_all = "snake_case")]
150pub enum RetryStrategy {
151    /// Prefer switching to another upstream on retry (default).
152    #[default]
153    Failover,
154    /// Prefer retrying the same upstream (opt-in).
155    SameUpstream,
156}
157
158impl Default for RetryConfig {
159    fn default() -> Self {
160        Self {
161            profile: Some(RetryProfileName::Balanced),
162            upstream: None,
163            provider: None,
164            reasoning_guard: None,
165            allow_cross_station_before_first_output: None,
166            never_on_status: None,
167            never_on_class: None,
168            cloudflare_challenge_cooldown_secs: None,
169            cloudflare_timeout_cooldown_secs: None,
170            transport_cooldown_secs: None,
171            cooldown_backoff_factor: None,
172            cooldown_backoff_max_secs: None,
173        }
174    }
175}
176
177impl RetryProfileName {
178    pub fn defaults(self) -> ResolvedRetryConfig {
179        match self {
180            RetryProfileName::Balanced => ResolvedRetryConfig {
181                upstream: ResolvedRetryLayerConfig {
182                    max_attempts: 2,
183                    backoff_ms: 200,
184                    backoff_max_ms: 2_000,
185                    jitter_ms: 100,
186                    on_status: "429,500-502,504-528,530-599".to_string(),
187                    on_class: vec![
188                        "upstream_transport_error".to_string(),
189                        "cloudflare_timeout".to_string(),
190                        "cloudflare_challenge".to_string(),
191                        "upstream_rate_limited".to_string(),
192                        "upstream_overloaded".to_string(),
193                    ],
194                    strategy: RetryStrategy::SameUpstream,
195                },
196                route: ResolvedRetryLayerConfig {
197                    max_attempts: 2,
198                    backoff_ms: 0,
199                    backoff_max_ms: 0,
200                    jitter_ms: 0,
201                    on_status: "401,403,404,408,429,500-599,524".to_string(),
202                    on_class: vec![
203                        "upstream_transport_error".to_string(),
204                        "routing_mismatch_capability".to_string(),
205                        "image_generation_missing_result".to_string(),
206                        "upstream_rate_limited".to_string(),
207                        "upstream_overloaded".to_string(),
208                    ],
209                    strategy: RetryStrategy::Failover,
210                },
211                reasoning_guard: ReasoningGuardConfig::default_resolved(),
212                allow_cross_station_before_first_output: false,
213                never_on_status: "413,415,422".to_string(),
214                never_on_class: vec!["client_error_non_retryable".to_string()],
215                cloudflare_challenge_cooldown_secs: 300,
216                cloudflare_timeout_cooldown_secs: 60,
217                transport_cooldown_secs: 30,
218                cooldown_backoff_factor: 1,
219                cooldown_backoff_max_secs: 600,
220            },
221            RetryProfileName::SameUpstream => ResolvedRetryConfig {
222                upstream: ResolvedRetryLayerConfig {
223                    max_attempts: 3,
224                    ..RetryProfileName::Balanced.defaults().upstream
225                },
226                route: ResolvedRetryLayerConfig {
227                    max_attempts: 1,
228                    ..RetryProfileName::Balanced.defaults().route
229                },
230                ..RetryProfileName::Balanced.defaults()
231            },
232            RetryProfileName::AggressiveFailover => ResolvedRetryConfig {
233                upstream: ResolvedRetryLayerConfig {
234                    max_attempts: 2,
235                    backoff_ms: 200,
236                    backoff_max_ms: 2_500,
237                    jitter_ms: 150,
238                    on_status: "429,500-502,504-528,530-599".to_string(),
239                    on_class: vec![
240                        "upstream_transport_error".to_string(),
241                        "cloudflare_timeout".to_string(),
242                        "cloudflare_challenge".to_string(),
243                        "upstream_rate_limited".to_string(),
244                        "upstream_overloaded".to_string(),
245                    ],
246                    strategy: RetryStrategy::SameUpstream,
247                },
248                route: ResolvedRetryLayerConfig {
249                    max_attempts: 3,
250                    backoff_ms: 0,
251                    backoff_max_ms: 0,
252                    jitter_ms: 0,
253                    on_status: "401,403,404,408,429,500-599,524".to_string(),
254                    on_class: vec![
255                        "upstream_transport_error".to_string(),
256                        "routing_mismatch_capability".to_string(),
257                        "upstream_rate_limited".to_string(),
258                        "upstream_overloaded".to_string(),
259                    ],
260                    strategy: RetryStrategy::Failover,
261                },
262                allow_cross_station_before_first_output: true,
263                ..RetryProfileName::Balanced.defaults()
264            },
265            RetryProfileName::CostPrimary => ResolvedRetryConfig {
266                route: ResolvedRetryLayerConfig {
267                    max_attempts: 2,
268                    ..RetryProfileName::Balanced.defaults().route
269                },
270                allow_cross_station_before_first_output: true,
271                transport_cooldown_secs: 30,
272                cooldown_backoff_factor: 2,
273                cooldown_backoff_max_secs: 900,
274                ..RetryProfileName::Balanced.defaults()
275            },
276        }
277    }
278}
279
280impl ReasoningGuardConfig {
281    pub fn default_resolved() -> ResolvedReasoningGuardConfig {
282        ResolvedReasoningGuardConfig {
283            enabled: false,
284            reasoning_equals: vec![516, 1034, 1552],
285            paths: vec![
286                "/responses".to_string(),
287                "/v1/responses".to_string(),
288                "/chat/completions".to_string(),
289                "/v1/chat/completions".to_string(),
290            ],
291            action: ReasoningGuardAction::Retry,
292            stream_mode: ReasoningGuardStreamMode::StrictBuffer,
293            max_guard_retries: 1,
294            log_matches: true,
295        }
296    }
297
298    pub fn resolve(&self) -> ResolvedReasoningGuardConfig {
299        let mut out = Self::default_resolved();
300        if let Some(v) = self.enabled {
301            out.enabled = v;
302        }
303        if let Some(v) = self.reasoning_equals.as_ref() {
304            out.reasoning_equals = v.clone();
305        }
306        if let Some(v) = self.paths.as_ref() {
307            out.paths = v
308                .iter()
309                .map(|path| normalize_reasoning_guard_path(path))
310                .filter(|path| !path.is_empty())
311                .collect();
312        }
313        if let Some(v) = self.action {
314            out.action = v;
315        }
316        if let Some(v) = self.stream_mode {
317            out.stream_mode = v;
318        }
319        if let Some(v) = self.max_guard_retries {
320            out.max_guard_retries = v.min(8);
321        }
322        if let Some(v) = self.log_matches {
323            out.log_matches = v;
324        }
325        out
326    }
327}
328
329fn normalize_reasoning_guard_path(path: &str) -> String {
330    let trimmed = path.trim();
331    if trimmed.is_empty() {
332        return String::new();
333    }
334    let mut normalized = if trimmed.starts_with('/') {
335        trimmed.to_string()
336    } else {
337        format!("/{trimmed}")
338    };
339    while normalized.len() > 1 && normalized.ends_with('/') {
340        normalized.pop();
341    }
342    normalized
343}
344
345impl RetryConfig {
346    pub fn resolve(&self) -> ResolvedRetryConfig {
347        let mut out = self
348            .profile
349            .unwrap_or(RetryProfileName::Balanced)
350            .defaults();
351
352        if let Some(layer) = self.upstream.as_ref() {
353            if let Some(v) = layer.max_attempts {
354                out.upstream.max_attempts = v;
355            }
356            if let Some(v) = layer.backoff_ms {
357                out.upstream.backoff_ms = v;
358            }
359            if let Some(v) = layer.backoff_max_ms {
360                out.upstream.backoff_max_ms = v;
361            }
362            if let Some(v) = layer.jitter_ms {
363                out.upstream.jitter_ms = v;
364            }
365            if let Some(v) = layer.on_status.as_deref() {
366                out.upstream.on_status = v.to_string();
367            }
368            if let Some(v) = layer.on_class.as_ref() {
369                out.upstream.on_class = v.clone();
370            }
371            if let Some(v) = layer.strategy {
372                out.upstream.strategy = v;
373            }
374        }
375        if let Some(layer) = self.provider.as_ref() {
376            if let Some(v) = layer.max_attempts {
377                out.route.max_attempts = v;
378            }
379            if let Some(v) = layer.backoff_ms {
380                out.route.backoff_ms = v;
381            }
382            if let Some(v) = layer.backoff_max_ms {
383                out.route.backoff_max_ms = v;
384            }
385            if let Some(v) = layer.jitter_ms {
386                out.route.jitter_ms = v;
387            }
388            if let Some(v) = layer.on_status.as_deref() {
389                out.route.on_status = v.to_string();
390            }
391            if let Some(v) = layer.on_class.as_ref() {
392                out.route.on_class = v.clone();
393            }
394            if let Some(v) = layer.strategy {
395                out.route.strategy = v;
396            }
397        }
398        if let Some(v) = self.allow_cross_station_before_first_output {
399            out.allow_cross_station_before_first_output = v;
400        }
401        if let Some(v) = self.never_on_status.as_deref() {
402            out.never_on_status = v.to_string();
403        }
404        if let Some(v) = self.never_on_class.as_ref() {
405            out.never_on_class = v.clone();
406        }
407        if let Some(v) = self.reasoning_guard.as_ref() {
408            out.reasoning_guard = v.resolve();
409        }
410        if let Some(v) = self.cloudflare_challenge_cooldown_secs {
411            out.cloudflare_challenge_cooldown_secs = v;
412        }
413        if let Some(v) = self.cloudflare_timeout_cooldown_secs {
414            out.cloudflare_timeout_cooldown_secs = v;
415        }
416        if let Some(v) = self.transport_cooldown_secs {
417            out.transport_cooldown_secs = v;
418        }
419        if let Some(v) = self.cooldown_backoff_factor {
420            out.cooldown_backoff_factor = v;
421        }
422        if let Some(v) = self.cooldown_backoff_max_secs {
423            out.cooldown_backoff_max_secs = v;
424        }
425
426        out
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn reasoning_guard_defaults_are_disabled() {
436        let resolved = RetryConfig::default().resolve();
437
438        assert!(!resolved.reasoning_guard.enabled);
439        assert_eq!(
440            resolved.reasoning_guard.reasoning_equals,
441            vec![516, 1034, 1552]
442        );
443        assert_eq!(
444            resolved.reasoning_guard.stream_mode,
445            ReasoningGuardStreamMode::StrictBuffer
446        );
447        assert_eq!(resolved.reasoning_guard.action, ReasoningGuardAction::Retry);
448        assert_eq!(resolved.reasoning_guard.max_guard_retries, 1);
449    }
450
451    #[test]
452    fn resolved_retry_deserializes_legacy_payload_without_reasoning_guard() {
453        let resolved: ResolvedRetryConfig = serde_json::from_value(serde_json::json!({
454            "upstream": {
455                "max_attempts": 2,
456                "backoff_ms": 200,
457                "backoff_max_ms": 2000,
458                "jitter_ms": 100,
459                "on_status": "429,500-599,524",
460                "on_class": ["upstream_transport_error"],
461                "strategy": "same_upstream"
462            },
463            "route": {
464                "max_attempts": 2,
465                "backoff_ms": 0,
466                "backoff_max_ms": 0,
467                "jitter_ms": 0,
468                "on_status": "401,403,404,408,429,500-599,524",
469                "on_class": ["upstream_transport_error"],
470                "strategy": "failover"
471            },
472            "allow_cross_station_before_first_output": true,
473            "never_on_status": "413,415,422",
474            "never_on_class": ["client_error_non_retryable"],
475            "cloudflare_challenge_cooldown_secs": 300,
476            "cloudflare_timeout_cooldown_secs": 12,
477            "transport_cooldown_secs": 45,
478            "cooldown_backoff_factor": 3,
479            "cooldown_backoff_max_secs": 180
480        }))
481        .expect("legacy resolved retry payload should deserialize");
482
483        assert_eq!(
484            resolved.reasoning_guard,
485            ReasoningGuardConfig::default_resolved()
486        );
487    }
488
489    #[test]
490    fn reasoning_guard_toml_overrides_resolve() {
491        let cfg: RetryConfig = toml::from_str(
492            r#"
493profile = "balanced"
494
495[reasoning_guard]
496enabled = true
497reasoning_equals = [516, 777]
498paths = ["responses", "/v1/chat/completions/"]
499action = "block"
500stream_mode = "off"
501max_guard_retries = 3
502log_matches = false
503"#,
504        )
505        .expect("parse retry config");
506
507        let resolved = cfg.resolve();
508        assert!(resolved.reasoning_guard.enabled);
509        assert_eq!(resolved.reasoning_guard.reasoning_equals, vec![516, 777]);
510        assert_eq!(
511            resolved.reasoning_guard.paths,
512            vec!["/responses".to_string(), "/v1/chat/completions".to_string()]
513        );
514        assert_eq!(resolved.reasoning_guard.action, ReasoningGuardAction::Block);
515        assert_eq!(
516            resolved.reasoning_guard.stream_mode,
517            ReasoningGuardStreamMode::Off
518        );
519        assert_eq!(resolved.reasoning_guard.max_guard_retries, 3);
520        assert!(!resolved.reasoning_guard.log_matches);
521    }
522}