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, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
119#[serde(rename_all = "kebab-case")]
120pub enum ReasoningGuardRetryExhaustedAction {
121    /// Forward the final matching upstream response after the guard retry budget is used.
122    #[default]
123    Pass,
124    /// Convert the final matching upstream response to a local guard error.
125    Block,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
129#[serde(deny_unknown_fields)]
130pub struct ReasoningGuardConfig {
131    #[serde(default)]
132    pub enabled: Option<bool>,
133    #[serde(default)]
134    pub reasoning_equals: Option<Vec<i64>>,
135    #[serde(default)]
136    pub boundary_sequence_max_n: Option<u32>,
137    #[serde(default)]
138    pub paths: Option<Vec<String>>,
139    #[serde(default)]
140    pub action: Option<ReasoningGuardAction>,
141    #[serde(default)]
142    pub stream_mode: Option<ReasoningGuardStreamMode>,
143    #[serde(default)]
144    pub max_guard_retries: Option<u32>,
145    #[serde(default)]
146    pub on_retry_exhausted: Option<ReasoningGuardRetryExhaustedAction>,
147    #[serde(default)]
148    pub log_matches: Option<bool>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct ResolvedReasoningGuardConfig {
153    pub enabled: bool,
154    pub reasoning_equals: Vec<i64>,
155    #[serde(default = "default_reasoning_guard_boundary_sequence_max_n")]
156    pub boundary_sequence_max_n: u32,
157    pub paths: Vec<String>,
158    pub action: ReasoningGuardAction,
159    pub stream_mode: ReasoningGuardStreamMode,
160    pub max_guard_retries: u32,
161    #[serde(default)]
162    pub on_retry_exhausted: ReasoningGuardRetryExhaustedAction,
163    pub log_matches: bool,
164}
165
166fn default_reasoning_guard_boundary_sequence_max_n() -> u32 {
167    4
168}
169
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
171#[serde(rename_all = "snake_case")]
172pub enum RetryStrategy {
173    /// Prefer switching to another upstream on retry (default).
174    #[default]
175    Failover,
176    /// Prefer retrying the same upstream (opt-in).
177    SameUpstream,
178}
179
180impl Default for RetryConfig {
181    fn default() -> Self {
182        Self {
183            profile: Some(RetryProfileName::Balanced),
184            upstream: None,
185            provider: None,
186            reasoning_guard: None,
187            allow_cross_station_before_first_output: None,
188            never_on_status: None,
189            never_on_class: None,
190            cloudflare_challenge_cooldown_secs: None,
191            cloudflare_timeout_cooldown_secs: None,
192            transport_cooldown_secs: None,
193            cooldown_backoff_factor: None,
194            cooldown_backoff_max_secs: None,
195        }
196    }
197}
198
199impl RetryProfileName {
200    pub fn defaults(self) -> ResolvedRetryConfig {
201        match self {
202            RetryProfileName::Balanced => ResolvedRetryConfig {
203                upstream: ResolvedRetryLayerConfig {
204                    max_attempts: 2,
205                    backoff_ms: 200,
206                    backoff_max_ms: 2_000,
207                    jitter_ms: 100,
208                    on_status: "429,500-502,504-528,530-599".to_string(),
209                    on_class: vec![
210                        "upstream_transport_error".to_string(),
211                        "cloudflare_timeout".to_string(),
212                        "cloudflare_challenge".to_string(),
213                        "upstream_rate_limited".to_string(),
214                        "upstream_overloaded".to_string(),
215                    ],
216                    strategy: RetryStrategy::SameUpstream,
217                },
218                route: ResolvedRetryLayerConfig {
219                    max_attempts: 2,
220                    backoff_ms: 0,
221                    backoff_max_ms: 0,
222                    jitter_ms: 0,
223                    on_status: "401,403,404,408,429,500-599,524".to_string(),
224                    on_class: vec![
225                        "upstream_transport_error".to_string(),
226                        "routing_mismatch_capability".to_string(),
227                        "image_generation_missing_result".to_string(),
228                        "upstream_rate_limited".to_string(),
229                        "upstream_overloaded".to_string(),
230                    ],
231                    strategy: RetryStrategy::Failover,
232                },
233                reasoning_guard: ReasoningGuardConfig::default_resolved(),
234                allow_cross_station_before_first_output: false,
235                never_on_status: "413,415,422".to_string(),
236                never_on_class: vec!["client_error_non_retryable".to_string()],
237                cloudflare_challenge_cooldown_secs: 300,
238                cloudflare_timeout_cooldown_secs: 60,
239                transport_cooldown_secs: 30,
240                cooldown_backoff_factor: 1,
241                cooldown_backoff_max_secs: 600,
242            },
243            RetryProfileName::SameUpstream => ResolvedRetryConfig {
244                upstream: ResolvedRetryLayerConfig {
245                    max_attempts: 3,
246                    ..RetryProfileName::Balanced.defaults().upstream
247                },
248                route: ResolvedRetryLayerConfig {
249                    max_attempts: 1,
250                    ..RetryProfileName::Balanced.defaults().route
251                },
252                ..RetryProfileName::Balanced.defaults()
253            },
254            RetryProfileName::AggressiveFailover => ResolvedRetryConfig {
255                upstream: ResolvedRetryLayerConfig {
256                    max_attempts: 2,
257                    backoff_ms: 200,
258                    backoff_max_ms: 2_500,
259                    jitter_ms: 150,
260                    on_status: "429,500-502,504-528,530-599".to_string(),
261                    on_class: vec![
262                        "upstream_transport_error".to_string(),
263                        "cloudflare_timeout".to_string(),
264                        "cloudflare_challenge".to_string(),
265                        "upstream_rate_limited".to_string(),
266                        "upstream_overloaded".to_string(),
267                    ],
268                    strategy: RetryStrategy::SameUpstream,
269                },
270                route: ResolvedRetryLayerConfig {
271                    max_attempts: 3,
272                    backoff_ms: 0,
273                    backoff_max_ms: 0,
274                    jitter_ms: 0,
275                    on_status: "401,403,404,408,429,500-599,524".to_string(),
276                    on_class: vec![
277                        "upstream_transport_error".to_string(),
278                        "routing_mismatch_capability".to_string(),
279                        "upstream_rate_limited".to_string(),
280                        "upstream_overloaded".to_string(),
281                    ],
282                    strategy: RetryStrategy::Failover,
283                },
284                allow_cross_station_before_first_output: true,
285                ..RetryProfileName::Balanced.defaults()
286            },
287            RetryProfileName::CostPrimary => ResolvedRetryConfig {
288                route: ResolvedRetryLayerConfig {
289                    max_attempts: 2,
290                    ..RetryProfileName::Balanced.defaults().route
291                },
292                allow_cross_station_before_first_output: true,
293                transport_cooldown_secs: 30,
294                cooldown_backoff_factor: 2,
295                cooldown_backoff_max_secs: 900,
296                ..RetryProfileName::Balanced.defaults()
297            },
298        }
299    }
300}
301
302impl ReasoningGuardConfig {
303    pub fn default_resolved() -> ResolvedReasoningGuardConfig {
304        ResolvedReasoningGuardConfig {
305            enabled: false,
306            reasoning_equals: vec![516, 1034, 1552],
307            boundary_sequence_max_n: 4,
308            paths: vec![
309                "/responses".to_string(),
310                "/v1/responses".to_string(),
311                "/chat/completions".to_string(),
312                "/v1/chat/completions".to_string(),
313            ],
314            action: ReasoningGuardAction::Retry,
315            stream_mode: ReasoningGuardStreamMode::StrictBuffer,
316            max_guard_retries: 1,
317            on_retry_exhausted: ReasoningGuardRetryExhaustedAction::Pass,
318            log_matches: true,
319        }
320    }
321
322    pub fn resolve(&self) -> ResolvedReasoningGuardConfig {
323        let mut out = Self::default_resolved();
324        if let Some(v) = self.enabled {
325            out.enabled = v;
326        }
327        if let Some(v) = self.reasoning_equals.as_ref() {
328            out.reasoning_equals = v.clone();
329        }
330        if let Some(v) = self.boundary_sequence_max_n {
331            out.boundary_sequence_max_n = v.min(16);
332        }
333        if let Some(v) = self.paths.as_ref() {
334            out.paths = v
335                .iter()
336                .map(|path| normalize_reasoning_guard_path(path))
337                .filter(|path| !path.is_empty())
338                .collect();
339        }
340        if let Some(v) = self.action {
341            out.action = v;
342        }
343        if let Some(v) = self.stream_mode {
344            out.stream_mode = v;
345        }
346        if let Some(v) = self.max_guard_retries {
347            out.max_guard_retries = v.min(8);
348        }
349        if let Some(v) = self.on_retry_exhausted {
350            out.on_retry_exhausted = v;
351        }
352        if let Some(v) = self.log_matches {
353            out.log_matches = v;
354        }
355        out
356    }
357}
358
359fn normalize_reasoning_guard_path(path: &str) -> String {
360    let trimmed = path.trim();
361    if trimmed.is_empty() {
362        return String::new();
363    }
364    let mut normalized = if trimmed.starts_with('/') {
365        trimmed.to_string()
366    } else {
367        format!("/{trimmed}")
368    };
369    while normalized.len() > 1 && normalized.ends_with('/') {
370        normalized.pop();
371    }
372    normalized
373}
374
375impl RetryConfig {
376    pub fn resolve(&self) -> ResolvedRetryConfig {
377        let mut out = self
378            .profile
379            .unwrap_or(RetryProfileName::Balanced)
380            .defaults();
381
382        if let Some(layer) = self.upstream.as_ref() {
383            if let Some(v) = layer.max_attempts {
384                out.upstream.max_attempts = v;
385            }
386            if let Some(v) = layer.backoff_ms {
387                out.upstream.backoff_ms = v;
388            }
389            if let Some(v) = layer.backoff_max_ms {
390                out.upstream.backoff_max_ms = v;
391            }
392            if let Some(v) = layer.jitter_ms {
393                out.upstream.jitter_ms = v;
394            }
395            if let Some(v) = layer.on_status.as_deref() {
396                out.upstream.on_status = v.to_string();
397            }
398            if let Some(v) = layer.on_class.as_ref() {
399                out.upstream.on_class = v.clone();
400            }
401            if let Some(v) = layer.strategy {
402                out.upstream.strategy = v;
403            }
404        }
405        if let Some(layer) = self.provider.as_ref() {
406            if let Some(v) = layer.max_attempts {
407                out.route.max_attempts = v;
408            }
409            if let Some(v) = layer.backoff_ms {
410                out.route.backoff_ms = v;
411            }
412            if let Some(v) = layer.backoff_max_ms {
413                out.route.backoff_max_ms = v;
414            }
415            if let Some(v) = layer.jitter_ms {
416                out.route.jitter_ms = v;
417            }
418            if let Some(v) = layer.on_status.as_deref() {
419                out.route.on_status = v.to_string();
420            }
421            if let Some(v) = layer.on_class.as_ref() {
422                out.route.on_class = v.clone();
423            }
424            if let Some(v) = layer.strategy {
425                out.route.strategy = v;
426            }
427        }
428        if let Some(v) = self.allow_cross_station_before_first_output {
429            out.allow_cross_station_before_first_output = v;
430        }
431        if let Some(v) = self.never_on_status.as_deref() {
432            out.never_on_status = v.to_string();
433        }
434        if let Some(v) = self.never_on_class.as_ref() {
435            out.never_on_class = v.clone();
436        }
437        if let Some(v) = self.reasoning_guard.as_ref() {
438            out.reasoning_guard = v.resolve();
439        }
440        if let Some(v) = self.cloudflare_challenge_cooldown_secs {
441            out.cloudflare_challenge_cooldown_secs = v;
442        }
443        if let Some(v) = self.cloudflare_timeout_cooldown_secs {
444            out.cloudflare_timeout_cooldown_secs = v;
445        }
446        if let Some(v) = self.transport_cooldown_secs {
447            out.transport_cooldown_secs = v;
448        }
449        if let Some(v) = self.cooldown_backoff_factor {
450            out.cooldown_backoff_factor = v;
451        }
452        if let Some(v) = self.cooldown_backoff_max_secs {
453            out.cooldown_backoff_max_secs = v;
454        }
455
456        out
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn reasoning_guard_defaults_are_disabled() {
466        let resolved = RetryConfig::default().resolve();
467
468        assert!(!resolved.reasoning_guard.enabled);
469        assert_eq!(
470            resolved.reasoning_guard.reasoning_equals,
471            vec![516, 1034, 1552]
472        );
473        assert_eq!(resolved.reasoning_guard.boundary_sequence_max_n, 4);
474        assert_eq!(
475            resolved.reasoning_guard.stream_mode,
476            ReasoningGuardStreamMode::StrictBuffer
477        );
478        assert_eq!(resolved.reasoning_guard.action, ReasoningGuardAction::Retry);
479        assert_eq!(resolved.reasoning_guard.max_guard_retries, 1);
480        assert_eq!(
481            resolved.reasoning_guard.on_retry_exhausted,
482            ReasoningGuardRetryExhaustedAction::Pass
483        );
484    }
485
486    #[test]
487    fn resolved_retry_deserializes_legacy_payload_without_reasoning_guard() {
488        let resolved: ResolvedRetryConfig = serde_json::from_value(serde_json::json!({
489            "upstream": {
490                "max_attempts": 2,
491                "backoff_ms": 200,
492                "backoff_max_ms": 2000,
493                "jitter_ms": 100,
494                "on_status": "429,500-599,524",
495                "on_class": ["upstream_transport_error"],
496                "strategy": "same_upstream"
497            },
498            "route": {
499                "max_attempts": 2,
500                "backoff_ms": 0,
501                "backoff_max_ms": 0,
502                "jitter_ms": 0,
503                "on_status": "401,403,404,408,429,500-599,524",
504                "on_class": ["upstream_transport_error"],
505                "strategy": "failover"
506            },
507            "allow_cross_station_before_first_output": true,
508            "never_on_status": "413,415,422",
509            "never_on_class": ["client_error_non_retryable"],
510            "cloudflare_challenge_cooldown_secs": 300,
511            "cloudflare_timeout_cooldown_secs": 12,
512            "transport_cooldown_secs": 45,
513            "cooldown_backoff_factor": 3,
514            "cooldown_backoff_max_secs": 180
515        }))
516        .expect("legacy resolved retry payload should deserialize");
517
518        assert_eq!(
519            resolved.reasoning_guard,
520            ReasoningGuardConfig::default_resolved()
521        );
522    }
523
524    #[test]
525    fn resolved_retry_deserializes_legacy_reasoning_guard_without_boundary_sequence() {
526        let resolved: ResolvedRetryConfig = serde_json::from_value(serde_json::json!({
527            "upstream": {
528                "max_attempts": 2,
529                "backoff_ms": 200,
530                "backoff_max_ms": 2000,
531                "jitter_ms": 100,
532                "on_status": "429,500-599,524",
533                "on_class": ["upstream_transport_error"],
534                "strategy": "same_upstream"
535            },
536            "route": {
537                "max_attempts": 2,
538                "backoff_ms": 0,
539                "backoff_max_ms": 0,
540                "jitter_ms": 0,
541                "on_status": "401,403,404,408,429,500-599,524",
542                "on_class": ["upstream_transport_error"],
543                "strategy": "failover"
544            },
545            "reasoning_guard": {
546                "enabled": true,
547                "reasoning_equals": [516, 1034, 1552],
548                "paths": ["/responses", "/v1/responses"],
549                "action": "retry",
550                "stream_mode": "strict-buffer",
551                "max_guard_retries": 1,
552                "log_matches": true
553            },
554            "allow_cross_station_before_first_output": true,
555            "never_on_status": "413,415,422",
556            "never_on_class": ["client_error_non_retryable"],
557            "cloudflare_challenge_cooldown_secs": 300,
558            "cloudflare_timeout_cooldown_secs": 12,
559            "transport_cooldown_secs": 45,
560            "cooldown_backoff_factor": 3,
561            "cooldown_backoff_max_secs": 180
562        }))
563        .expect("legacy resolved retry reasoning guard should deserialize");
564
565        assert!(resolved.reasoning_guard.enabled);
566        assert_eq!(resolved.reasoning_guard.boundary_sequence_max_n, 4);
567    }
568
569    #[test]
570    fn reasoning_guard_toml_overrides_resolve() {
571        let cfg: RetryConfig = toml::from_str(
572            r#"
573profile = "balanced"
574
575[reasoning_guard]
576enabled = true
577reasoning_equals = [516, 777]
578boundary_sequence_max_n = 0
579paths = ["responses", "/v1/chat/completions/"]
580action = "block"
581stream_mode = "off"
582max_guard_retries = 3
583log_matches = false
584"#,
585        )
586        .expect("parse retry config");
587
588        let resolved = cfg.resolve();
589        assert!(resolved.reasoning_guard.enabled);
590        assert_eq!(resolved.reasoning_guard.reasoning_equals, vec![516, 777]);
591        assert_eq!(resolved.reasoning_guard.boundary_sequence_max_n, 0);
592        assert_eq!(
593            resolved.reasoning_guard.paths,
594            vec!["/responses".to_string(), "/v1/chat/completions".to_string()]
595        );
596        assert_eq!(resolved.reasoning_guard.action, ReasoningGuardAction::Block);
597        assert_eq!(
598            resolved.reasoning_guard.stream_mode,
599            ReasoningGuardStreamMode::Off
600        );
601        assert_eq!(resolved.reasoning_guard.max_guard_retries, 3);
602        assert!(!resolved.reasoning_guard.log_matches);
603    }
604}