返回 CodeWhale
stream_entry.rs
根目录 / crates / tui / src / client / stream_entry.rs
1 //! Shared stream entry seam for Chat Completions / Anthropic Messages / Responses.
2 //!
3 //! Scoped consolidation for v0.9.1: wire-protocol adapters stay at the edge
4 //! (`chat.rs`, `anthropic.rs`, `responses.rs`); this module owns the common
5 //! open path, HTTP/1.1 fallback policy, and idle-timeout envelope so providers
6 //! do not re-implement transport differently.
7 //!
8 //! Full piagent-style provider collapse is deferred — see
9 //! `docs/notes/post-0.9.1-thin-tui-and-stream.md`.
10
11 use std::future::Future;
12 use std::time::Duration;
13
14 use anyhow::Result;
15 use reqwest::Client;
16
17 use crate::llm_client::LlmError;
18
19 /// Default bounded wait for SSE response headers. Intentionally shorter than
20 /// the per-chunk idle timeout: it covers connection setup and upstream header
21 /// return only, never model thinking time after streaming has started.
22 pub(crate) const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(45);
23
24 /// Env override (`CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, legacy
25 /// `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS`) for the response-header wait,
26 /// shared by every streaming adapter.
27 pub(crate) fn stream_open_timeout() -> Duration {
28 stream_open_timeout_from_env(
29 std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
30 .or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
31 .ok()
32 .as_deref(),
33 )
34 }
35
36 pub(crate) fn stream_open_timeout_from_env(value: Option<&str>) -> Duration {
37 let secs = value
38 .and_then(|v| v.parse::<u64>().ok())
39 .unwrap_or(DEFAULT_STREAM_OPEN_TIMEOUT.as_secs())
40 .clamp(5, 300);
41 Duration::from_secs(secs)
42 }
43
44 /// How the shared stream open path should pin HTTP version.
45 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
46 pub enum StreamHttpPolicy {
47 /// Prefer the dual client (H2 primary, H1 twin for fallback).
48 DualWithH1Fallback,
49 /// Force HTTP/1.1 only (env pin or prior H2 stall).
50 Http1Only,
51 }
52
53 /// Inputs shared by every streaming provider adapter at open time.
54 #[derive(Debug, Clone)]
55 pub struct StreamOpenRequest {
56 pub policy: StreamHttpPolicy,
57 pub open_timeout: Duration,
58 pub idle_timeout: Duration,
59 }
60
61 impl StreamOpenRequest {
62 #[must_use]
63 pub fn new(open_timeout: Duration, idle_timeout: Duration) -> Self {
64 Self {
65 policy: if super::force_http1_from_env() {
66 StreamHttpPolicy::Http1Only
67 } else {
68 StreamHttpPolicy::DualWithH1Fallback
69 },
70 open_timeout,
71 idle_timeout,
72 }
73 }
74
75 /// After an H2 stall, retry on the HTTP/1.1 twin.
76 #[must_use]
77 pub fn with_h1_only(mut self) -> Self {
78 self.policy = StreamHttpPolicy::Http1Only;
79 self
80 }
81 }
82
83 /// Select the HTTP client for a stream open attempt.
84 #[must_use]
85 pub fn client_for_policy<'a>(
86 primary: &'a Client,
87 http1_fallback: &'a Client,
88 policy: StreamHttpPolicy,
89 ) -> &'a Client {
90 match policy {
91 StreamHttpPolicy::DualWithH1Fallback => primary,
92 StreamHttpPolicy::Http1Only => http1_fallback,
93 }
94 }
95
96 /// Whether a transport error should trigger H1 fallback retry.
97 #[must_use]
98 pub fn should_retry_with_h1(policy: StreamHttpPolicy, err_text: &str) -> bool {
99 if policy != StreamHttpPolicy::DualWithH1Fallback {
100 return false;
101 }
102 let lower = err_text.to_ascii_lowercase();
103 lower.contains("http2")
104 || lower.contains("h2 ")
105 || lower.contains("stream closed")
106 || lower.contains("connection reset")
107 || lower.contains("protocol error")
108 || lower.contains("frame size")
109 }
110
111 /// Whether an error raised before response headers should retry through the
112 /// HTTP/1.1 twin. Prefer typed transport errors, then retain the narrow text
113 /// classifier for lower-level H2 errors that reqwest exposes only as prose.
114 #[must_use]
115 fn should_retry_error_with_h1(policy: StreamHttpPolicy, err: &anyhow::Error) -> bool {
116 if policy != StreamHttpPolicy::DualWithH1Fallback {
117 return false;
118 }
119
120 if let Some(llm_error) = err.downcast_ref::<LlmError>() {
121 return matches!(llm_error, LlmError::NetworkError(_) | LlmError::Timeout(_));
122 }
123
124 if let Some(reqwest_error) = err.downcast_ref::<reqwest::Error>() {
125 return reqwest_error.is_connect()
126 || reqwest_error.is_timeout()
127 || reqwest_error.is_request();
128 }
129
130 should_retry_with_h1(policy, &format!("{err:#}"))
131 }
132
133 /// Preserve provider-semantic failures returned by the H1 attempt. Only a
134 /// transport failure should be normalized into the shared retryable network
135 /// error; otherwise an auth or invalid-request failure could be retried as if
136 /// switching protocols had failed.
137 fn h1_fallback_error(err: anyhow::Error) -> anyhow::Error {
138 if let Some(llm_error) = err.downcast_ref::<LlmError>()
139 && !matches!(llm_error, LlmError::NetworkError(_) | LlmError::Timeout(_))
140 {
141 return err;
142 }
143
144 anyhow::Error::new(LlmError::NetworkError(format!(
145 "SSE stream request failed after HTTP/1.1 fallback: {err}. \
146 `codewhale doctor` can still pass when non-streaming requests work; \
147 on Windows or proxy networks, try `CODEWHALE_FORCE_HTTP1=1` and rerun `codewhale`."
148 )))
149 }
150
151 /// Open an SSE response through the shared transport policy.
152 ///
153 /// `attempt` builds and sends one wire-specific request on the client
154 /// selected for the given policy (via [`client_for_policy`]); everything
155 /// transport-shared lives here:
156 ///
157 /// - the response-header wait is bounded by `open_req.open_timeout`;
158 /// - a classified transport failure or header stall on the dual client
159 /// retries exactly once on the HTTP/1.1 twin;
160 /// - a failure on an already H1-pinned request never retries;
161 /// - once response headers have been received the seam never retries —
162 /// body/stream errors belong to the adapter's decode loop.
163 pub(crate) async fn open_sse_response<F, Fut>(
164 open_req: &StreamOpenRequest,
165 attempt: F,
166 ) -> Result<reqwest::Response>
167 where
168 F: Fn(StreamHttpPolicy) -> Fut,
169 Fut: Future<Output = Result<reqwest::Response>>,
170 {
171 let fallback_reason = match tokio::time::timeout(
172 open_req.open_timeout,
173 attempt(open_req.policy),
174 )
175 .await
176 {
177 Ok(Ok(response)) => return Ok(response),
178 Ok(Err(err)) => {
179 if !should_retry_error_with_h1(open_req.policy, &err) {
180 return Err(err);
181 }
182 "transport error before response headers"
183 }
184 Err(_elapsed) => {
185 if open_req.policy == StreamHttpPolicy::Http1Only {
186 return Err(anyhow::Error::new(LlmError::NetworkError(format!(
187 "SSE stream request did not receive response headers after {}s. \
188 `codewhale doctor` can still pass when non-streaming requests work; \
189 on Windows or proxy networks, try `CODEWHALE_FORCE_HTTP1=1` and rerun `codewhale`.",
190 open_req.open_timeout.as_secs()
191 ))));
192 }
193 "response-header timeout"
194 }
195 };
196
197 // No response body exists yet, so switching protocols and replaying the
198 // request is safe. The policy guard above keeps this to exactly one retry.
199 let h1_req = open_req.clone().with_h1_only();
200 crate::logging::warn(format!(
201 "SSE stream {fallback_reason}; retrying once with HTTP/1.1"
202 ));
203 match tokio::time::timeout(h1_req.open_timeout, attempt(h1_req.policy)).await {
204 Ok(Ok(response)) => Ok(response),
205 Ok(Err(err)) => Err(h1_fallback_error(err)),
206 // Typed, not a bare string: a header stall is a transport
207 // failure, and `LlmError::NetworkError` is what the shared
208 // retry layer recognizes as retryable. As an untyped anyhow
209 // error this killed the whole turn outright.
210 Err(_elapsed) => Err(anyhow::Error::new(LlmError::NetworkError(format!(
211 "SSE stream request did not receive response headers after {}s \
212 (HTTP/2 and HTTP/1.1). `codewhale doctor` can still pass when \
213 non-streaming requests work; try `CODEWHALE_FORCE_HTTP1=1` and \
214 rerun `codewhale`.",
215 open_req.open_timeout.as_secs()
216 )))),
217 }
218 }
219
220 /// Format a stable idle-timeout message shared across adapters.
221 #[must_use]
222 pub fn idle_timeout_message(
223 idle: Duration,
224 bytes_received: usize,
225 stream_age: Duration,
226 since_last_chunk: Duration,
227 ) -> String {
228 format!(
229 "SSE stream idle timeout after {}s — no data received \
230 (bytes_received={}, stream_age_ms={}, ms_since_last_chunk={})",
231 idle.as_secs(),
232 bytes_received,
233 stream_age.as_millis(),
234 since_last_chunk.as_millis(),
235 )
236 }
237
238 #[cfg(test)]
239 mod tests {
240 use std::sync::Arc;
241 use std::sync::atomic::{AtomicUsize, Ordering};
242
243 use wiremock::matchers::method;
244 use wiremock::{Mock, MockServer, ResponseTemplate};
245
246 use super::*;
247
248 fn open_req(policy: StreamHttpPolicy, open_timeout: Duration) -> StreamOpenRequest {
249 StreamOpenRequest {
250 policy,
251 open_timeout,
252 idle_timeout: Duration::from_secs(30),
253 }
254 }
255
256 async fn ok_server() -> MockServer {
257 let server = MockServer::start().await;
258 Mock::given(method("POST"))
259 .respond_with(ResponseTemplate::new(200))
260 .mount(&server)
261 .await;
262 server
263 }
264
265 #[tokio::test]
266 async fn open_returns_first_attempt_response_on_dual_policy() {
267 let server = ok_server().await;
268 let client = crate::tls::reqwest_client();
269 let attempts = Arc::new(AtomicUsize::new(0));
270 let response = open_sse_response(
271 &open_req(StreamHttpPolicy::DualWithH1Fallback, Duration::from_secs(5)),
272 |policy| {
273 assert_eq!(policy, StreamHttpPolicy::DualWithH1Fallback);
274 let attempts = Arc::clone(&attempts);
275 let client = client.clone();
276 let url = server.uri();
277 async move {
278 attempts.fetch_add(1, Ordering::SeqCst);
279 Ok(client.post(url).send().await?)
280 }
281 },
282 )
283 .await
284 .expect("first attempt succeeds");
285 assert_eq!(response.status(), 200);
286 assert_eq!(attempts.load(Ordering::SeqCst), 1);
287 }
288
289 #[tokio::test]
290 async fn header_stall_on_dual_policy_retries_exactly_once_on_h1() {
291 let attempts = Arc::new(AtomicUsize::new(0));
292 let response = open_sse_response(
293 &open_req(
294 StreamHttpPolicy::DualWithH1Fallback,
295 Duration::from_millis(150),
296 ),
297 |policy| {
298 let attempts = Arc::clone(&attempts);
299 async move {
300 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
301 if attempt == 0 {
302 // First attempt stalls before response headers.
303 assert_eq!(policy, StreamHttpPolicy::DualWithH1Fallback);
304 std::future::pending::<()>().await;
305 }
306 assert_eq!(policy, StreamHttpPolicy::Http1Only);
307 // This test exercises retry policy, not loopback scheduling.
308 // A ready response keeps the 150 ms first-attempt timeout
309 // from also imposing a network deadline under suite load.
310 Ok(reqwest::Response::from(
311 axum::http::Response::builder().status(200).body("")?,
312 ))
313 }
314 },
315 )
316 .await
317 .expect("H1 fallback retry succeeds");
318 assert_eq!(response.status(), 200);
319 assert_eq!(
320 attempts.load(Ordering::SeqCst),
321 2,
322 "exactly one fallback retry"
323 );
324 }
325
326 #[tokio::test]
327 async fn transport_error_before_headers_retries_exactly_once_on_h1() {
328 let server = ok_server().await;
329 let client = crate::tls::reqwest_client();
330 let attempts = Arc::new(AtomicUsize::new(0));
331 let response = open_sse_response(
332 &open_req(StreamHttpPolicy::DualWithH1Fallback, Duration::from_secs(5)),
333 |policy| {
334 let attempts = Arc::clone(&attempts);
335 let client = client.clone();
336 let url = server.uri();
337 async move {
338 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
339 if attempt == 0 {
340 assert_eq!(policy, StreamHttpPolicy::DualWithH1Fallback);
341 return Err(anyhow::Error::new(LlmError::NetworkError(
342 "connection reset before response headers".to_string(),
343 ))
344 .context("Chat API request failed"));
345 }
346 assert_eq!(policy, StreamHttpPolicy::Http1Only);
347 Ok(client.post(url).send().await?)
348 }
349 },
350 )
351 .await
352 .expect("H1 fallback retry succeeds after a transport error");
353 assert_eq!(response.status(), 200);
354 assert_eq!(
355 attempts.load(Ordering::SeqCst),
356 2,
357 "exactly one fallback retry"
358 );
359 }
360
361 #[tokio::test]
362 async fn header_stall_when_h1_pinned_never_retries_and_reports_timeout_text() {
363 let attempts = Arc::new(AtomicUsize::new(0));
364 let err = open_sse_response(
365 &open_req(StreamHttpPolicy::Http1Only, Duration::from_millis(100)),
366 |_| {
367 let attempts = Arc::clone(&attempts);
368 async move {
369 attempts.fetch_add(1, Ordering::SeqCst);
370 std::future::pending::<()>().await;
371 unreachable!("stalled attempt never resolves")
372 }
373 },
374 )
375 .await
376 .expect_err("H1-pinned stall fails without retry");
377 assert_eq!(attempts.load(Ordering::SeqCst), 1, "no retry when pinned");
378 let text = err.to_string();
379 assert!(text.contains("did not receive response headers"), "{text}");
380 // The whole point of the typed error: a header stall must reach the
381 // shared retry layer as retryable. As an untyped anyhow error it
382 // killed the turn outright, so a long root run lost all its work while
383 // a sub-agent — which text-matches the same message in its own
384 // classifier — would have retried and continued.
385 let classified = err
386 .downcast_ref::<crate::llm_client::LlmError>()
387 .expect("header stall must be a typed LlmError");
388 assert!(
389 classified.is_retryable(),
390 "header stall must be retryable: {classified:?}"
391 );
392 assert!(text.contains("CODEWHALE_FORCE_HTTP1=1"), "{text}");
393 assert!(
394 !text.contains("HTTP/2 and HTTP/1.1"),
395 "single-protocol stall must not claim a dual-protocol attempt: {text}"
396 );
397 }
398
399 #[tokio::test]
400 async fn transport_error_when_h1_pinned_never_retries() {
401 let attempts = Arc::new(AtomicUsize::new(0));
402 let err = open_sse_response(
403 &open_req(StreamHttpPolicy::Http1Only, Duration::from_secs(5)),
404 |_| {
405 let attempts = Arc::clone(&attempts);
406 async move {
407 attempts.fetch_add(1, Ordering::SeqCst);
408 Err(anyhow::Error::new(LlmError::NetworkError(
409 "connection reset before response headers".to_string(),
410 )))
411 }
412 },
413 )
414 .await
415 .expect_err("an H1-pinned transport error must not retry");
416 assert_eq!(attempts.load(Ordering::SeqCst), 1, "no retry when pinned");
417 assert!(err.to_string().contains("connection reset"), "{err}");
418 }
419
420 #[tokio::test]
421 async fn provider_error_from_h1_fallback_keeps_its_semantic_type() {
422 let attempts = Arc::new(AtomicUsize::new(0));
423 let err = open_sse_response(
424 &open_req(StreamHttpPolicy::DualWithH1Fallback, Duration::from_secs(5)),
425 |policy| {
426 let attempts = Arc::clone(&attempts);
427 async move {
428 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
429 if attempt == 0 {
430 assert_eq!(policy, StreamHttpPolicy::DualWithH1Fallback);
431 return Err(anyhow::Error::new(LlmError::NetworkError(
432 "connection reset before response headers".to_string(),
433 )));
434 }
435 assert_eq!(policy, StreamHttpPolicy::Http1Only);
436 Err(anyhow::Error::new(LlmError::InvalidRequest {
437 status: 400,
438 message: "invalid request".to_string(),
439 }))
440 }
441 },
442 )
443 .await
444 .expect_err("the H1 provider error must be returned");
445 assert_eq!(attempts.load(Ordering::SeqCst), 2, "one fallback attempt");
446 assert!(
447 matches!(
448 err.downcast_ref::<LlmError>(),
449 Some(LlmError::InvalidRequest { status: 400, .. })
450 ),
451 "provider error was reclassified: {err:#}"
452 );
453 }
454
455 #[tokio::test]
456 async fn attempt_error_before_headers_is_not_h1_retried() {
457 let attempts = Arc::new(AtomicUsize::new(0));
458 let err = open_sse_response(
459 &open_req(StreamHttpPolicy::DualWithH1Fallback, Duration::from_secs(5)),
460 |_| {
461 let attempts = Arc::clone(&attempts);
462 async move {
463 attempts.fetch_add(1, Ordering::SeqCst);
464 Err(anyhow::anyhow!("HTTP 401: invalid api key"))
465 }
466 },
467 )
468 .await
469 .expect_err("provider error propagates");
470 assert_eq!(
471 attempts.load(Ordering::SeqCst),
472 1,
473 "non-stall errors are never H1-retried"
474 );
475 assert!(err.to_string().contains("HTTP 401"), "{err}");
476 }
477
478 #[tokio::test]
479 async fn double_stall_reports_both_protocols_in_timeout_text() {
480 let attempts = Arc::new(AtomicUsize::new(0));
481 let err = open_sse_response(
482 &open_req(
483 StreamHttpPolicy::DualWithH1Fallback,
484 Duration::from_millis(100),
485 ),
486 |_| {
487 let attempts = Arc::clone(&attempts);
488 async move {
489 attempts.fetch_add(1, Ordering::SeqCst);
490 std::future::pending::<()>().await;
491 unreachable!("stalled attempt never resolves")
492 }
493 },
494 )
495 .await
496 .expect_err("double stall fails");
497 assert_eq!(attempts.load(Ordering::SeqCst), 2, "one fallback, no more");
498 let text = err.to_string();
499 assert!(text.contains("HTTP/2 and HTTP/1.1"), "{text}");
500 }
501
502 #[test]
503 fn h1_retry_only_on_dual_policy() {
504 assert!(should_retry_with_h1(
505 StreamHttpPolicy::DualWithH1Fallback,
506 "http2 protocol error"
507 ));
508 assert!(!should_retry_with_h1(
509 StreamHttpPolicy::Http1Only,
510 "http2 protocol error"
511 ));
512 }
513
514 #[test]
515 fn stream_open_timeout_defaults_and_clamps_env_values() {
516 assert_eq!(stream_open_timeout_from_env(None), Duration::from_secs(45));
517 assert_eq!(
518 stream_open_timeout_from_env(Some("not-a-number")),
519 Duration::from_secs(45)
520 );
521 assert_eq!(
522 stream_open_timeout_from_env(Some("1")),
523 Duration::from_secs(5)
524 );
525 assert_eq!(
526 stream_open_timeout_from_env(Some("120")),
527 Duration::from_secs(120)
528 );
529 assert_eq!(
530 stream_open_timeout_from_env(Some("999")),
531 Duration::from_secs(300)
532 );
533 }
534
535 #[test]
536 fn idle_message_is_stable() {
537 let msg = idle_timeout_message(
538 Duration::from_secs(30),
539 0,
540 Duration::from_secs(30),
541 Duration::from_secs(30),
542 );
543 assert!(msg.contains("idle timeout"));
544 assert!(msg.contains("bytes_received=0"));
545 }
546 }
547
547 lines RUST