1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use crate::anthropic::sse::parse_sse_events;
5use crate::config;
6use crate::logging::create_logger;
7use crate::provider::RequestContext;
8use crate::retry::{compute_backoff_delay, should_retry_status, sleep};
9use crate::traffic::TrafficCapture;
10
11use super::auth::constants::{CODEX_API_ENDPOINT, ORIGINATOR, RESPONSES_LITE_ORIGINATOR};
12use super::auth::manager::CodexAuthManager;
13use super::auth::token_store::{DefaultCodexAuthStore, StoredAuth, file_store};
14use super::translate::request::ResponsesRequest;
15
16#[derive(Debug)]
21pub struct CodexError {
22 pub status: u16,
23 pub message: String,
24 pub detail: Option<String>,
25 pub retry_after: Option<String>,
26 pub origin: CodexErrorOrigin,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum CodexErrorOrigin {
31 Http,
32 WebSocket,
33 WebSocketHandshake,
34 Auth,
35 BufferedHttp,
36 BufferedWebSocket,
37}
38
39impl CodexError {
40 pub fn new(status: u16, message: String) -> Self {
41 Self {
42 status,
43 message,
44 detail: None,
45 retry_after: None,
46 origin: CodexErrorOrigin::Http,
47 }
48 }
49}
50
51impl std::fmt::Display for CodexError {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 write!(f, "Codex error {}: {}", self.status, self.message)
54 }
55}
56
57#[derive(Debug)]
58pub struct CodexHeaderTimeoutError {
59 pub timeout_ms: u64,
60}
61
62impl std::fmt::Display for CodexHeaderTimeoutError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 write!(
65 f,
66 "Timed out waiting {}ms for Codex response headers",
67 self.timeout_ms
68 )
69 }
70}
71
72#[derive(Debug)]
73pub struct CodexTransportError {
74 pub message: String,
75}
76
77impl std::fmt::Display for CodexTransportError {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 write!(f, "Codex transport error: {}", self.message)
80 }
81}
82
83pub fn build_codex_headers(
88 auth: &StoredAuth,
89 ctx: &RequestContext,
90 use_responses_lite: bool,
91) -> Result<http::HeaderMap, CodexError> {
92 let mut headers = http::HeaderMap::new();
93 headers.insert(
94 http::header::CONTENT_TYPE,
95 header_value("content-type", "application/json")?,
96 );
97 headers.insert(
98 http::header::ACCEPT,
99 header_value("accept", "text/event-stream")?,
100 );
101 let bearer = format!("Bearer {}", auth.access);
102 headers.insert(
103 http::header::AUTHORIZATION,
104 header_value("authorization", &bearer)?,
105 );
106 let originator = if use_responses_lite {
107 RESPONSES_LITE_ORIGINATOR.to_string()
108 } else {
109 config::codex_originator(ORIGINATOR)
110 };
111 headers.insert("originator", header_value("originator", &originator)?);
112 headers.insert(
113 "openai-beta",
114 header_value("openai-beta", "responses=experimental")?,
115 );
116 if use_responses_lite {
117 headers.insert(
118 "x-openai-internal-codex-responses-lite",
119 header_value("x-openai-internal-codex-responses-lite", "true")?,
120 );
121 }
122 if let Some(ref account_id) = auth.account_id {
123 headers.insert(
124 "ChatGPT-Account-Id",
125 header_value("ChatGPT-Account-Id", account_id)?,
126 );
127 }
128 if let Some(ref session_id) = ctx.session_id {
129 headers.insert("session_id", header_value("session_id", session_id)?);
130 headers.insert(
131 "x-client-request-id",
132 header_value("x-client-request-id", session_id)?,
133 );
134 let window_id = format!("{session_id}:0");
135 headers.insert(
136 "x-codex-window-id",
137 header_value("x-codex-window-id", &window_id)?,
138 );
139 }
140 let user_agent =
141 config::codex_user_agent(&format!("claude-code-proxy/{}", env!("CARGO_PKG_VERSION")));
142 if !user_agent.is_empty() {
143 headers.insert(
144 http::header::USER_AGENT,
145 header_value("user-agent", &user_agent)?,
146 );
147 }
148 Ok(headers)
149}
150
151fn header_value(name: &str, value: &str) -> Result<http::HeaderValue, CodexError> {
152 http::HeaderValue::from_str(value).map_err(|e| CodexError {
153 status: 500,
154 message: format!("Failed to parse {name} header"),
155 detail: Some(e.to_string()),
156 retry_after: None,
157 origin: CodexErrorOrigin::Http,
158 })
159}
160
161pub fn build_websocket_request(
166 body: &ResponsesRequest,
167 continuation: Option<&super::continuation::ContinuationCandidate>,
168) -> serde_json::Value {
169 let mut payload = serde_json::to_value(body).unwrap_or_default();
170 let obj = payload.as_object_mut().expect("request must be an object");
171
172 obj.remove("stream");
174 obj.insert("type".to_string(), serde_json::json!("response.create"));
175
176 if let Some(candidate) = continuation {
178 if let Some(ref prev_id) = candidate.previous_response_id {
179 obj.insert(
180 "previous_response_id".to_string(),
181 serde_json::json!(prev_id),
182 );
183 }
184 if let Some(ref delta) = candidate.input_delta {
185 obj.insert(
186 "input".to_string(),
187 serde_json::to_value(delta).unwrap_or_default(),
188 );
189 }
190 }
191
192 payload
193}
194
195pub struct CodexResponse {
200 pub body: Vec<u8>,
201 pub status: u16,
202 pub headers: Vec<(String, String)>,
203}
204
205const MAX_BUFFERED_TRANSPORT_RETRIES: u32 = 3;
210const MAX_BUFFERED_TRANSPORT_ATTEMPTS: u32 = MAX_BUFFERED_TRANSPORT_RETRIES + 1;
211const HTTP_RESPONSE_BODY_IDLE_TIMEOUT_MS: u64 = 300_000;
212
213pub struct CodexHttpClient {
214 client: reqwest::Client,
215 auth_manager: CodexAuthManager<DefaultCodexAuthStore>,
216 base_url: String,
217 header_timeout_ms: u64,
218 body_idle_timeout_ms: u64,
219 #[allow(dead_code)]
220 header_timeout_retries: u32,
221}
222
223impl Default for CodexHttpClient {
224 fn default() -> Self {
225 Self::new()
226 }
227}
228
229impl CodexHttpClient {
230 pub fn new() -> Self {
231 let timeout_ms = 60_000;
232 Self {
233 client: reqwest::Client::builder()
234 .connect_timeout(Duration::from_secs(15))
235 .build()
236 .expect("failed to create HTTP client"),
237 auth_manager: CodexAuthManager::new(file_store()),
238 base_url: config::codex_base_url(CODEX_API_ENDPOINT),
239 header_timeout_ms: timeout_ms,
240 body_idle_timeout_ms: HTTP_RESPONSE_BODY_IDLE_TIMEOUT_MS,
241 header_timeout_retries: 1,
242 }
243 }
244
245 pub fn new_with_client(
246 client: reqwest::Client,
247 auth_manager: CodexAuthManager<DefaultCodexAuthStore>,
248 base_url: String,
249 ) -> Self {
250 Self {
251 client,
252 auth_manager,
253 base_url,
254 header_timeout_ms: 60_000,
255 body_idle_timeout_ms: HTTP_RESPONSE_BODY_IDLE_TIMEOUT_MS,
256 header_timeout_retries: 1,
257 }
258 }
259
260 #[cfg(test)]
261 pub fn new_for_test(
262 client: reqwest::Client,
263 base_url: String,
264 header_timeout_ms: u64,
265 body_idle_timeout_ms: u64,
266 header_timeout_retries: u32,
267 ) -> Self {
268 Self {
269 client,
270 auth_manager: CodexAuthManager::new(file_store()),
271 base_url,
272 header_timeout_ms,
273 body_idle_timeout_ms,
274 header_timeout_retries,
275 }
276 }
277
278 pub fn auth_manager(&self) -> &CodexAuthManager<DefaultCodexAuthStore> {
279 &self.auth_manager
280 }
281
282 pub async fn post_codex(
283 &self,
284 body: &ResponsesRequest,
285 ctx: &RequestContext,
286 continuation: Option<&super::continuation::ContinuationCandidate>,
287 ) -> Result<CodexResponse, CodexError> {
288 self.post_codex_with_transport(body, ctx, continuation, crate::config::codex_transport())
289 .await
290 }
291
292 async fn post_codex_with_transport(
293 &self,
294 body: &ResponsesRequest,
295 ctx: &RequestContext,
296 continuation: Option<&super::continuation::ContinuationCandidate>,
297 transport: crate::config::CodexTransport,
298 ) -> Result<CodexResponse, CodexError> {
299 use super::continuation::clear_continuation;
300 use crate::config::CodexTransport;
301
302 let mut auth = self.auth_manager.get_auth().await.map_err(|e| CodexError {
303 status: 401,
304 message: "Auth error".to_string(),
305 detail: Some(e.to_string()),
306 retry_after: None,
307 origin: CodexErrorOrigin::Auth,
308 })?;
309
310 let initial_pool_key = websocket_pool_key(ctx, continuation);
311 if should_reset_websocket_pool(continuation)
312 && let Some(key) = initial_pool_key
313 {
314 super::websocket::invalidate_codex_websocket_pool_key(key);
315 }
316
317 let mut active_continuation = continuation;
318 let mut auth_refresh_attempted = false;
319 let mut transport_failures = 0u32;
320 loop {
321 let pool_key = websocket_pool_key(ctx, active_continuation);
322 let result = match transport {
323 CodexTransport::Http => {
324 let body_json = serde_json::to_string(body).map_err(|e| CodexError {
325 status: 500,
326 message: "Failed to serialize request".to_string(),
327 detail: Some(e.to_string()),
328 retry_after: None,
329 origin: CodexErrorOrigin::Http,
330 })?;
331 self.attempt_post_http(&auth, &body_json, ctx, body.client_metadata.is_some())
332 .await
333 }
334 CodexTransport::WebSocket => {
335 let ws_headers =
336 build_codex_headers(&auth, ctx, body.client_metadata.is_some())?;
337 let ws_headers = super::websocket::codex_websocket_headers(&ws_headers);
338 let ws_body = build_websocket_request(body, active_continuation);
339
340 super::websocket::codex_websocket_request(
341 &self.base_url,
342 &ws_headers,
343 &ws_body,
344 ctx,
345 ctx.traffic.as_deref(),
346 pool_key,
347 super::websocket::WEBSOCKET_CONNECT_TIMEOUT_MS,
348 super::websocket::WEBSOCKET_IDLE_TIMEOUT_MS,
349 active_continuation,
350 )
351 .await
352 }
353 CodexTransport::Auto => {
354 let ws_headers =
355 build_codex_headers(&auth, ctx, body.client_metadata.is_some())?;
356 let ws_headers = super::websocket::codex_websocket_headers(&ws_headers);
357 let ws_body = build_websocket_request(body, active_continuation);
358
359 let ws_result = super::websocket::codex_websocket_request(
361 &self.base_url,
362 &ws_headers,
363 &ws_body,
364 ctx,
365 ctx.traffic.as_deref(),
366 pool_key,
367 super::websocket::WEBSOCKET_CONNECT_TIMEOUT_MS,
368 super::websocket::WEBSOCKET_IDLE_TIMEOUT_MS,
369 active_continuation,
370 )
371 .await;
372
373 match ws_result {
374 Ok(response) => Ok(response),
375 Err(err) if should_fallback_to_http(&err) => {
376 let body_json =
378 serde_json::to_string(body).map_err(|e| CodexError {
379 status: 500,
380 message: "Failed to serialize request".to_string(),
381 detail: Some(e.to_string()),
382 retry_after: None,
383 origin: CodexErrorOrigin::Http,
384 })?;
385 self.attempt_post_http(
386 &auth,
387 &body_json,
388 ctx,
389 body.client_metadata.is_some(),
390 )
391 .await
392 }
393 Err(err) => Err(err),
394 }
395 }
396 };
397
398 if should_refresh_after_unauthorized(&result, auth_refresh_attempted, transport) {
399 auth_refresh_attempted = true;
400 match self.auth_manager.force_refresh(&auth.access).await {
401 Ok(new_auth) => {
402 auth = new_auth;
403 if let Some(key) = pool_key {
404 super::websocket::invalidate_codex_websocket_pool_key(key);
405 }
406 continue;
407 }
408 Err(e) => {
409 return Err(CodexError {
410 status: 401,
411 message: "Unauthorized".to_string(),
412 detail: Some(e.to_string()),
413 retry_after: None,
414 origin: CodexErrorOrigin::Http,
415 });
416 }
417 }
418 }
419
420 if let Ok(response) = &result
421 && (200..300).contains(&response.status)
422 && let Some(failure) = super::events::first_retryable_failure(&response.body)
423 {
424 if transport_failures < MAX_BUFFERED_TRANSPORT_RETRIES {
425 let delay =
426 compute_backoff_delay(transport_failures, failure.retry_after.as_deref());
427 if delay.exceeds_budget {
428 return Err(CodexError {
429 status: failure.status,
430 message: failure.message.clone(),
431 detail: Some(failure.message),
432 retry_after: failure.retry_after,
433 origin: buffered_origin(transport),
434 });
435 }
436 log_buffered_retry(
437 ctx,
438 transport,
439 transport_failures + 1,
440 delay.wait_ms,
441 failure.status,
442 "upstream_event",
443 &failure.message,
444 );
445 transport_failures += 1;
446 sleep(delay.wait_ms).await;
447 continue;
448 }
449
450 log_buffered_retry_exhausted(
451 ctx,
452 transport,
453 failure.status,
454 "upstream_event",
455 &failure.message,
456 );
457 return Err(CodexError {
458 status: failure.status,
459 message: failure.message.clone(),
460 detail: Some(failure.message),
461 retry_after: failure.retry_after,
462 origin: CodexErrorOrigin::Http,
463 });
464 }
465
466 match result {
467 Ok(response) if response.status == 401 => {
468 let detail = String::from_utf8_lossy(&response.body).to_string();
469 return Err(CodexError {
470 status: 401,
471 message: "Unauthorized".to_string(),
472 detail: Some(detail),
473 retry_after: None,
474 origin: CodexErrorOrigin::Http,
475 });
476 }
477 Ok(response) if response.status == 403 => {
478 let detail = String::from_utf8_lossy(&response.body).to_string();
479 return Err(CodexError {
480 status: 403,
481 message: "Forbidden".to_string(),
482 detail: Some(detail),
483 retry_after: None,
484 origin: CodexErrorOrigin::Http,
485 });
486 }
487 Ok(response) if response.status == 429 => {
488 let retry_after = response
489 .headers
490 .iter()
491 .find(|(k, _)| k.to_lowercase() == "retry-after")
492 .map(|(_, v)| v.clone());
493 if transport_failures < MAX_BUFFERED_TRANSPORT_RETRIES {
494 let delay =
495 compute_backoff_delay(transport_failures, retry_after.as_deref());
496 if delay.exceeds_budget {
497 let detail = String::from_utf8_lossy(&response.body).to_string();
498 return Err(CodexError {
499 status: 429,
500 message: "Rate limited".to_string(),
501 detail: Some(detail),
502 retry_after,
503 origin: CodexErrorOrigin::Http,
504 });
505 }
506 log_buffered_retry(
507 ctx,
508 transport,
509 transport_failures + 1,
510 delay.wait_ms,
511 response.status,
512 "upstream",
513 "rate limited",
514 );
515 transport_failures += 1;
516 sleep(delay.wait_ms).await;
517 continue;
518 }
519 let detail = String::from_utf8_lossy(&response.body).to_string();
520 log_buffered_retry_exhausted(
521 ctx,
522 transport,
523 response.status,
524 "upstream",
525 "rate limited",
526 );
527 return Err(CodexError {
528 status: 429,
529 message: "Rate limited".to_string(),
530 detail: Some(detail),
531 retry_after,
532 origin: CodexErrorOrigin::Http,
533 });
534 }
535 Ok(response) if should_retry_codex_status(response.status) => {
536 if transport_failures < MAX_BUFFERED_TRANSPORT_RETRIES {
537 let retry_after = response
538 .headers
539 .iter()
540 .find(|(key, _)| key.eq_ignore_ascii_case("retry-after"))
541 .map(|(_, value)| value.as_str());
542 let delay = compute_backoff_delay(transport_failures, retry_after);
543 if delay.exceeds_budget {
544 return Err(codex_status_error(response, transport));
545 }
546 log_buffered_retry(
547 ctx,
548 transport,
549 transport_failures + 1,
550 delay.wait_ms,
551 response.status,
552 "upstream",
553 "retryable upstream status",
554 );
555 transport_failures += 1;
556 sleep(delay.wait_ms).await;
557 continue;
558 }
559 log_buffered_retry_exhausted(
560 ctx,
561 transport,
562 response.status,
563 "upstream",
564 "retryable upstream status",
565 );
566 return Err(codex_status_error(response, transport));
567 }
568 Ok(response) if !(200..300).contains(&response.status) => {
569 return Err(codex_status_error(response, transport));
570 }
571 Ok(response) => return Ok(response),
572 Err(err) if should_retry_without_continuation(&err, active_continuation) => {
573 clear_continuation(ctx.session_id.as_deref());
574 if let Some(key) = pool_key {
575 super::websocket::invalidate_codex_websocket_pool_key(key);
576 }
577 active_continuation = None;
578 continue;
579 }
580 Err(err) => {
581 let retryable = is_retryable_transport_error(&err);
583 if retryable && transport_failures < MAX_BUFFERED_TRANSPORT_RETRIES {
584 let delay =
585 compute_backoff_delay(transport_failures, err.retry_after.as_deref());
586 if delay.exceeds_budget {
587 return Err(err);
588 }
589 log_buffered_retry(
590 ctx,
591 transport,
592 transport_failures + 1,
593 delay.wait_ms,
594 err.status,
595 codex_error_origin_name(err.origin),
596 &err.message,
597 );
598 transport_failures += 1;
599 sleep(delay.wait_ms).await;
600 continue;
601 }
602 if retryable {
603 log_buffered_retry_exhausted(
604 ctx,
605 transport,
606 err.status,
607 codex_error_origin_name(err.origin),
608 &err.message,
609 );
610 }
611 return Err(err);
612 }
613 }
614 }
615 }
616
617 pub async fn stream_codex_websocket_events(
618 self: &Arc<Self>,
619 body: &ResponsesRequest,
620 ctx: &RequestContext,
621 continuation: Option<&super::continuation::ContinuationCandidate>,
622 ) -> Result<super::websocket::CodexWebSocketEventReceiver, CodexError> {
623 let auth = self.auth_manager.get_auth().await.map_err(|e| CodexError {
624 status: 401,
625 message: "Auth error".to_string(),
626 detail: Some(e.to_string()),
627 retry_after: None,
628 origin: CodexErrorOrigin::Auth,
629 })?;
630
631 let pool_key = websocket_pool_key(ctx, continuation).map(str::to_string);
632 if should_reset_websocket_pool(continuation)
633 && let Some(key) = pool_key.as_deref()
634 {
635 super::websocket::invalidate_codex_websocket_pool_key(key);
636 }
637
638 let client = self.clone();
639 let body = body.clone();
640 let ctx = ctx.clone();
641 let continuation = continuation.cloned();
642 let (tx, rx) = tokio::sync::mpsc::channel(64);
643 tokio::spawn(async move {
644 client
645 .coordinate_live_websocket_events(body, ctx, continuation, auth, pool_key, tx)
646 .await;
647 });
648
649 Ok(rx)
650 }
651
652 async fn coordinate_live_websocket_events(
653 &self,
654 body: ResponsesRequest,
655 ctx: RequestContext,
656 mut continuation: Option<super::continuation::ContinuationCandidate>,
657 mut auth: StoredAuth,
658 pool_key: Option<String>,
659 tx: tokio::sync::mpsc::Sender<Result<serde_json::Value, CodexError>>,
660 ) {
661 let mut auth_refresh_attempted = false;
662 let mut continuation_retry_available = continuation
663 .as_ref()
664 .and_then(|candidate| candidate.previous_response_id.as_deref())
665 .is_some();
666 let mut forwarded_any = false;
667
668 'attempt: loop {
669 let ws_headers = match build_codex_headers(&auth, &ctx, body.client_metadata.is_some())
670 {
671 Ok(headers) => super::websocket::codex_websocket_headers(&headers),
672 Err(err) => {
673 let _ = tx.send(Err(err)).await;
674 return;
675 }
676 };
677 let ws_body = build_websocket_request(&body, continuation.as_ref());
678 let start = super::websocket::codex_websocket_event_stream(
679 &self.base_url,
680 &ws_headers,
681 &ws_body,
682 &ctx,
683 ctx.traffic.clone(),
684 pool_key.as_deref(),
685 super::websocket::WEBSOCKET_CONNECT_TIMEOUT_MS,
686 super::websocket::WEBSOCKET_IDLE_TIMEOUT_MS,
687 continuation.as_ref(),
688 );
689 let mut stream = tokio::select! {
690 _ = tx.closed() => {
691 if let Some(key) = pool_key.as_deref() {
692 super::websocket::invalidate_codex_websocket_pool_key(key);
693 }
694 return;
695 }
696 result = start => match result {
697 Ok(stream) => stream,
698 Err(err) if err.status == 401 && !auth_refresh_attempted && !forwarded_any => {
699 auth_refresh_attempted = true;
700 if let Some(key) = pool_key.as_deref() {
701 super::websocket::invalidate_codex_websocket_pool_key(key);
702 }
703 let refresh = self.auth_manager.force_refresh(&auth.access);
704 auth = match refresh.await {
705 Ok(auth) => {
706 if tx.is_closed() {
707 return;
708 }
709 auth
710 },
711 Err(refresh_err) => {
712 let _ = tx.send(Err(auth_refresh_error(refresh_err))).await;
713 return;
714 }
715 };
716 continue 'attempt;
717 }
718 Err(err) if continuation_retry_available && is_continuation_retry_error(&err) => {
719 continuation_retry_available = false;
720 super::continuation::clear_continuation(ctx.session_id.as_deref());
721 if let Some(key) = pool_key.as_deref() {
722 super::websocket::invalidate_codex_websocket_pool_key(key);
723 }
724 continuation = None;
725 continue 'attempt;
726 }
727 Err(err) => {
728 let _ = tx.send(Err(err)).await;
729 return;
730 }
731 }
732 };
733
734 loop {
735 let item = tokio::select! {
736 _ = tx.closed() => {
737 if let Some(key) = pool_key.as_deref() {
738 super::websocket::invalidate_codex_websocket_pool_key(key);
739 }
740 return;
741 }
742 item = stream.recv() => item,
743 };
744 let Some(item) = item else {
745 return;
746 };
747
748 let unauthorized = match &item {
749 Err(err) => err.status == 401,
750 Ok(payload) => super::websocket::event_error_status(payload) == Some(401),
751 };
752 if unauthorized && !auth_refresh_attempted && !forwarded_any {
753 auth_refresh_attempted = true;
754 if let Some(key) = pool_key.as_deref() {
755 super::websocket::invalidate_codex_websocket_pool_key(key);
756 }
757 let refresh = self.auth_manager.force_refresh(&auth.access);
758 auth = match refresh.await {
759 Ok(auth) => {
760 if tx.is_closed() {
761 return;
762 }
763 auth
764 }
765 Err(refresh_err) => {
766 let _ = tx.send(Err(auth_refresh_error(refresh_err))).await;
767 return;
768 }
769 };
770 continue 'attempt;
771 }
772
773 if let Err(err) = &item
774 && continuation_retry_available
775 && is_continuation_retry_error(err)
776 && !forwarded_any
777 {
778 continuation_retry_available = false;
779 super::continuation::clear_continuation(ctx.session_id.as_deref());
780 if let Some(key) = pool_key.as_deref() {
781 super::websocket::invalidate_codex_websocket_pool_key(key);
782 }
783 continuation = None;
784 continue 'attempt;
785 }
786
787 forwarded_any = true;
788 if tx.send(item).await.is_err() {
789 if let Some(key) = pool_key.as_deref() {
790 super::websocket::invalidate_codex_websocket_pool_key(key);
791 }
792 return;
793 }
794 }
795 }
796 }
797
798 async fn attempt_post_http(
799 &self,
800 auth: &StoredAuth,
801 body_json: &str,
802 ctx: &RequestContext,
803 use_responses_lite: bool,
804 ) -> Result<CodexResponse, CodexError> {
805 let url = &self.base_url;
806 let headers = build_codex_headers(auth, ctx, use_responses_lite)?;
807
808 if let Some(traffic) = ctx.traffic.as_deref() {
809 write_codex_http_request_capture(traffic, url, &headers, body_json);
810 }
811
812 let mut req_builder = self.client.post(url);
814 for (key, value) in headers.iter() {
815 req_builder = req_builder.header(key.as_str(), value.as_bytes());
816 }
817
818 let started_at = Instant::now();
820 let send_fut = req_builder.body(body_json.to_string()).send();
821 let header_timeout_dur = Duration::from_millis(self.header_timeout_ms);
822
823 let mut resp = tokio::time::timeout(header_timeout_dur, send_fut)
824 .await
825 .map_err(|_| CodexError {
826 status: 0,
827 message: format!(
828 "Timed out waiting {}ms for Codex response headers",
829 self.header_timeout_ms
830 ),
831 detail: None,
832 retry_after: None,
833 origin: CodexErrorOrigin::Http,
834 })?
835 .map_err(|e| {
836 if is_retryable_reqwest_error(&e) {
837 CodexError {
838 status: 0,
839 message: format!("Transport error: {e}"),
840 detail: None,
841 retry_after: None,
842 origin: CodexErrorOrigin::Http,
843 }
844 } else {
845 CodexError {
846 status: 0,
847 message: format!("Network error: {e}"),
848 detail: None,
849 retry_after: None,
850 origin: CodexErrorOrigin::Http,
851 }
852 }
853 })?;
854
855 let status = resp.status().as_u16();
856 let headers: Vec<(String, String)> = resp
857 .headers()
858 .iter()
859 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
860 .collect();
861
862 let mut body_bytes = Vec::new();
863 loop {
864 let chunk = tokio::time::timeout(
865 Duration::from_millis(self.body_idle_timeout_ms),
866 resp.chunk(),
867 )
868 .await
869 .map_err(|_| CodexError {
870 status: 0,
871 message: format!(
872 "Timed out waiting {}ms for the next Codex response body chunk",
873 self.body_idle_timeout_ms
874 ),
875 detail: Some("http_response_body".to_string()),
876 retry_after: None,
877 origin: CodexErrorOrigin::Http,
878 })?
879 .map_err(|e| CodexError {
880 status: 0,
881 message: format!("Transport error reading Codex response body: {e}"),
882 detail: Some("http_response_body".to_string()),
883 retry_after: None,
884 origin: CodexErrorOrigin::Http,
885 })?;
886
887 let Some(chunk) = chunk else {
888 break;
889 };
890 body_bytes.extend_from_slice(&chunk);
891 }
892
893 if let Some(traffic) = ctx.traffic.as_deref() {
894 write_upstream_response_capture(
895 traffic,
896 status,
897 started_at.elapsed(),
898 &headers,
899 &body_bytes,
900 );
901 }
902
903 Ok(CodexResponse {
904 body: body_bytes,
905 status,
906 headers,
907 })
908 }
909}
910
911fn write_codex_http_request_capture(
912 traffic: &TrafficCapture,
913 url: &str,
914 headers: &http::HeaderMap,
915 body_json: &str,
916) {
917 let body = serde_json::from_str(body_json).unwrap_or_else(|_| {
918 serde_json::json!({
919 "unparseable": true,
920 "bytes": body_json.len(),
921 })
922 });
923 traffic.write_json("020-upstream-request", &body);
924 traffic.write_json(
925 "021-upstream-request-metadata",
926 &serde_json::json!({
927 "provider": "codex",
928 "transport": "http",
929 "url": url,
930 "method": "POST",
931 "headers": headers_to_json(headers),
932 "size": summarize_json_request_size(&body, body_json),
933 }),
934 );
935}
936
937fn write_upstream_response_capture(
938 traffic: &TrafficCapture,
939 status: u16,
940 elapsed: Duration,
941 headers: &[(String, String)],
942 body: &[u8],
943) {
944 traffic.write_json(
945 "030-upstream-response-headers",
946 &serde_json::json!({
947 "status": status,
948 "elapsedMs": elapsed.as_millis(),
949 "headers": headers_to_json_from_pairs(headers),
950 }),
951 );
952 if status >= 400 {
953 traffic.write_text("031-upstream-error-body", &String::from_utf8_lossy(body));
954 } else {
955 traffic.write_bytes("032-upstream-response-body.sse", body);
956 write_codex_sse_event_capture(traffic, body);
957 }
958}
959
960fn write_codex_sse_event_capture(traffic: &TrafficCapture, body: &[u8]) {
961 for event in parse_sse_events(body) {
962 if event.data == "[DONE]" {
963 traffic.write_json_event(
964 "040-upstream-event",
965 &serde_json::json!({
966 "event": event.event,
967 "data": "[DONE]",
968 }),
969 );
970 continue;
971 }
972
973 match serde_json::from_str::<serde_json::Value>(&event.data) {
974 Ok(mut value) => {
975 if let Some(name) = event.event
976 && let Some(obj) = value.as_object_mut()
977 {
978 obj.entry("_sse_event").or_insert(serde_json::json!(name));
979 }
980 traffic.write_json_event("040-upstream-event", &value);
981 }
982 Err(_) => {
983 traffic.write_json_event(
984 "040-upstream-event",
985 &serde_json::json!({
986 "event": event.event,
987 "unparseable": true,
988 "data": event.data,
989 }),
990 );
991 }
992 }
993 }
994}
995
996fn headers_to_json(headers: &http::HeaderMap) -> serde_json::Value {
997 let mut out = serde_json::Map::new();
998 for (key, value) in headers.iter() {
999 out.insert(
1000 key.to_string(),
1001 serde_json::Value::String(value.to_str().unwrap_or("").to_string()),
1002 );
1003 }
1004 serde_json::Value::Object(out)
1005}
1006
1007fn headers_to_json_from_pairs(headers: &[(String, String)]) -> serde_json::Value {
1008 let mut out = serde_json::Map::new();
1009 for (key, value) in headers {
1010 out.insert(key.clone(), serde_json::Value::String(value.clone()));
1011 }
1012 serde_json::Value::Object(out)
1013}
1014
1015fn summarize_json_request_size(body: &serde_json::Value, body_json: &str) -> serde_json::Value {
1016 serde_json::json!({
1017 "bytes": body_json.len(),
1018 "inputCount": body
1019 .get("input")
1020 .and_then(|v| v.as_array())
1021 .map(|items| items.len()),
1022 "toolCount": body
1023 .get("tools")
1024 .and_then(|v| v.as_array())
1025 .map(|items| items.len()),
1026 })
1027}
1028
1029fn auth_refresh_error(err: anyhow::Error) -> CodexError {
1030 CodexError {
1031 status: 401,
1032 message: "Unauthorized".to_string(),
1033 detail: Some(err.to_string()),
1034 retry_after: None,
1035 origin: CodexErrorOrigin::Auth,
1036 }
1037}
1038
1039fn codex_status_error(
1040 response: CodexResponse,
1041 transport: crate::config::CodexTransport,
1042) -> CodexError {
1043 let retry_after = response
1044 .headers
1045 .iter()
1046 .find(|(key, _)| key.eq_ignore_ascii_case("retry-after"))
1047 .map(|(_, value)| value.clone());
1048 let message = codex_status_error_message(&response.body).unwrap_or_else(|| {
1049 format!(
1050 "Upstream Codex request failed with status {}",
1051 response.status
1052 )
1053 });
1054 CodexError {
1055 status: response.status,
1056 message: message.clone(),
1057 detail: Some(message),
1058 retry_after,
1059 origin: buffered_origin(transport),
1060 }
1061}
1062
1063fn codex_status_error_message(body: &[u8]) -> Option<String> {
1064 serde_json::from_slice::<serde_json::Value>(body)
1065 .ok()
1066 .and_then(|value| {
1067 value
1068 .pointer("/error/message")
1069 .or_else(|| value.get("message"))
1070 .or_else(|| value.get("detail"))
1071 .and_then(|value| value.as_str())
1072 .map(str::to_string)
1073 })
1074 .or_else(|| {
1075 parse_sse_events(body).into_iter().find_map(|event| {
1076 let payload = serde_json::from_str::<serde_json::Value>(&event.data).ok()?;
1077 super::events::classify_event_failure(&payload).map(|failure| failure.message)
1078 })
1079 })
1080}
1081
1082fn buffered_origin(transport: crate::config::CodexTransport) -> CodexErrorOrigin {
1083 match transport {
1084 crate::config::CodexTransport::Http => CodexErrorOrigin::BufferedHttp,
1085 crate::config::CodexTransport::WebSocket | crate::config::CodexTransport::Auto => {
1086 CodexErrorOrigin::BufferedWebSocket
1087 }
1088 }
1089}
1090
1091fn should_retry_codex_status(status: u16) -> bool {
1092 should_retry_status(status) || status == 529
1093}
1094
1095fn codex_error_origin_name(origin: CodexErrorOrigin) -> &'static str {
1096 match origin {
1097 CodexErrorOrigin::Http => "http",
1098 CodexErrorOrigin::WebSocket => "websocket",
1099 CodexErrorOrigin::WebSocketHandshake => "websocket_handshake",
1100 CodexErrorOrigin::Auth => "auth",
1101 CodexErrorOrigin::BufferedHttp => "buffered_http",
1102 CodexErrorOrigin::BufferedWebSocket => "buffered_websocket",
1103 }
1104}
1105
1106fn log_buffered_retry(
1107 ctx: &RequestContext,
1108 transport: crate::config::CodexTransport,
1109 failed_attempt: u32,
1110 delay_ms: u64,
1111 status: u16,
1112 origin: &str,
1113 reason: &str,
1114) {
1115 let mut fields = serde_json::Map::new();
1116 fields.insert("reqId".into(), serde_json::json!(ctx.req_id));
1117 fields.insert("transport".into(), serde_json::json!(transport.as_str()));
1118 fields.insert("failedAttempt".into(), serde_json::json!(failed_attempt));
1119 fields.insert("nextAttempt".into(), serde_json::json!(failed_attempt + 1));
1120 fields.insert(
1121 "maxAttempts".into(),
1122 serde_json::json!(MAX_BUFFERED_TRANSPORT_ATTEMPTS),
1123 );
1124 fields.insert("delayMs".into(), serde_json::json!(delay_ms));
1125 fields.insert("status".into(), serde_json::json!(status));
1126 fields.insert("origin".into(), serde_json::json!(origin));
1127 fields.insert("reason".into(), serde_json::json!(reason));
1128 create_logger("codex").warn("buffered_transport_retry", Some(fields));
1129}
1130
1131fn log_buffered_retry_exhausted(
1132 ctx: &RequestContext,
1133 transport: crate::config::CodexTransport,
1134 status: u16,
1135 origin: &str,
1136 reason: &str,
1137) {
1138 let mut fields = serde_json::Map::new();
1139 fields.insert("reqId".into(), serde_json::json!(ctx.req_id));
1140 fields.insert("transport".into(), serde_json::json!(transport.as_str()));
1141 fields.insert(
1142 "attempts".into(),
1143 serde_json::json!(MAX_BUFFERED_TRANSPORT_ATTEMPTS),
1144 );
1145 fields.insert("status".into(), serde_json::json!(status));
1146 fields.insert("origin".into(), serde_json::json!(origin));
1147 fields.insert("reason".into(), serde_json::json!(reason));
1148 create_logger("codex").warn("buffered_transport_retry_exhausted", Some(fields));
1149}
1150
1151fn is_retryable_transport_error(err: &CodexError) -> bool {
1152 if err.origin == CodexErrorOrigin::WebSocketHandshake {
1153 return err.status == 0 || should_retry_codex_status(err.status);
1154 }
1155 if err.detail.as_deref() == Some("websocket_pre_request") {
1156 return err.status == 0 || should_retry_codex_status(err.status);
1157 }
1158 if err.status != 0 {
1159 return false;
1160 }
1161
1162 let message = err.message.to_ascii_lowercase();
1163 message.contains("timed out waiting")
1164 || message.contains("transport error")
1165 || message.contains("connection reset")
1166 || message.contains("connection closed")
1167 || message.contains("timed out")
1168 || message.contains("econnreset")
1169 || message.contains("etimedout")
1170}
1171
1172fn is_retryable_reqwest_error(err: &reqwest::Error) -> bool {
1173 if err.is_timeout() || err.is_connect() {
1174 return true;
1175 }
1176 let msg = err.to_string().to_lowercase();
1177 msg.contains("connection reset")
1178 || msg.contains("connection closed")
1179 || msg.contains("econnreset")
1180 || msg.contains("etimedout")
1181 || msg.contains("epipe")
1182}
1183
1184fn should_refresh_after_unauthorized(
1185 result: &Result<CodexResponse, CodexError>,
1186 auth_refresh_attempted: bool,
1187 transport: crate::config::CodexTransport,
1188) -> bool {
1189 if auth_refresh_attempted {
1190 return false;
1191 }
1192 match result {
1193 Ok(response) => response.status == 401,
1194 Err(err) => {
1195 err.status == 401
1196 && (err.origin != CodexErrorOrigin::WebSocketHandshake
1197 || transport == crate::config::CodexTransport::WebSocket)
1198 }
1199 }
1200}
1201
1202fn should_fallback_to_http(err: &CodexError) -> bool {
1203 err.origin == CodexErrorOrigin::WebSocketHandshake
1204}
1205
1206fn should_retry_without_continuation(
1207 err: &CodexError,
1208 continuation: Option<&super::continuation::ContinuationCandidate>,
1209) -> bool {
1210 if continuation
1211 .and_then(|c| c.previous_response_id.as_deref())
1212 .is_none()
1213 {
1214 return false;
1215 }
1216
1217 is_continuation_retry_error(err)
1218}
1219
1220fn is_continuation_retry_error(err: &CodexError) -> bool {
1221 matches!(
1222 err.detail.as_deref(),
1223 Some("previous_response_not_found")
1224 | Some(super::websocket::WEBSOCKET_RESPONSE_START_TIMEOUT_DETAIL)
1225 | Some(super::websocket::WEBSOCKET_MISSING_TERMINAL_DETAIL)
1226 )
1227}
1228
1229fn websocket_pool_key<'a>(
1230 ctx: &'a RequestContext,
1231 continuation: Option<&super::continuation::ContinuationCandidate>,
1232) -> Option<&'a str> {
1233 let session_id = ctx.session_id.as_deref()?;
1234 let continuation = continuation?;
1235 if continuation.disabled_reason.as_deref() == Some("disabled") {
1236 return None;
1237 }
1238 Some(session_id)
1239}
1240
1241fn should_reset_websocket_pool(
1242 continuation: Option<&super::continuation::ContinuationCandidate>,
1243) -> bool {
1244 let Some(reason) = continuation.and_then(|c| c.disabled_reason.as_deref()) else {
1245 return false;
1246 };
1247 !matches!(reason, "missing_state" | "disabled")
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1254 use tokio::net::TcpListener;
1255
1256 fn http_test_auth() -> StoredAuth {
1257 StoredAuth {
1258 access: "test".into(),
1259 refresh: String::new(),
1260 account_id: Some("acct".into()),
1261 expires: u64::MAX,
1262 }
1263 }
1264
1265 fn http_test_context() -> RequestContext {
1266 RequestContext {
1267 req_id: "http-body-test".into(),
1268 session_id: None,
1269 session_seq: None,
1270 provider: "codex".into(),
1271 traffic: None,
1272 monitor: None,
1273 passthrough: None,
1274 }
1275 }
1276
1277 fn http_test_client(base_url: String, body_idle_timeout_ms: u64) -> CodexHttpClient {
1278 CodexHttpClient::new_for_test(
1279 reqwest::Client::new(),
1280 base_url,
1281 100,
1282 body_idle_timeout_ms,
1283 0,
1284 )
1285 }
1286
1287 fn buffered_test_request() -> ResponsesRequest {
1288 ResponsesRequest {
1289 model: "gpt-5.6-sol".into(),
1290 instructions: None,
1291 input: vec![],
1292 tools: None,
1293 tool_choice: None,
1294 store: false,
1295 stream: true,
1296 parallel_tool_calls: true,
1297 include: None,
1298 client_metadata: None,
1299 service_tier: None,
1300 prompt_cache_key: None,
1301 text: super::super::translate::request::ResponsesText {
1302 verbosity: None,
1303 format: None,
1304 },
1305 reasoning: None,
1306 }
1307 }
1308
1309 fn authenticated_http_test_client(base_url: String) -> CodexHttpClient {
1310 let client = http_test_client(base_url, 100);
1311 client.auth_manager().set_test_auth(http_test_auth());
1312 client
1313 }
1314
1315 #[tokio::test]
1316 async fn buffered_http_retries_retryable_status() {
1317 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1318 let addr = listener.local_addr().unwrap();
1319 let server = tokio::spawn(async move {
1320 for attempt in 0..2 {
1321 let (mut stream, _) = listener.accept().await.unwrap();
1322 let mut request = [0_u8; 16 * 1024];
1323 assert!(stream.read(&mut request).await.unwrap() > 0);
1324 let (status, body): (&str, &[u8]) = if attempt == 0 {
1325 ("503 Service Unavailable", b"retry")
1326 } else {
1327 ("200 OK", b"data: keep\n\n")
1328 };
1329 let response = format!(
1330 "HTTP/1.1 {status}\r\ncontent-length: {}\r\nretry-after: 0\r\nconnection: close\r\n\r\n",
1331 body.len()
1332 );
1333 stream.write_all(response.as_bytes()).await.unwrap();
1334 stream.write_all(body).await.unwrap();
1335 }
1336 });
1337
1338 let response = authenticated_http_test_client(format!("http://{addr}/responses"))
1339 .post_codex_with_transport(
1340 &buffered_test_request(),
1341 &http_test_context(),
1342 None,
1343 crate::config::CodexTransport::Http,
1344 )
1345 .await
1346 .unwrap();
1347 server.await.unwrap();
1348 assert_eq!(response.status, 200);
1349 assert_eq!(response.body, b"data: keep\n\n");
1350 }
1351
1352 #[tokio::test]
1353 async fn auto_falls_back_to_http_after_statusful_websocket_handshake_failure() {
1354 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1355 let addr = listener.local_addr().unwrap();
1356 let server = tokio::spawn(async move {
1357 let (mut websocket, _) = listener.accept().await.unwrap();
1358 let mut request = [0_u8; 16 * 1024];
1359 let read = websocket.read(&mut request).await.unwrap();
1360 assert!(read > 0);
1361 assert!(String::from_utf8_lossy(&request[..read]).contains("Upgrade: websocket"));
1362 websocket
1363 .write_all(
1364 b"HTTP/1.1 401 Unauthorized\r\ncontent-length: 13\r\nconnection: close\r\n\r\npolicy denied",
1365 )
1366 .await
1367 .unwrap();
1368 drop(websocket);
1369
1370 let (mut http, _) = listener.accept().await.unwrap();
1371 let read = http.read(&mut request).await.unwrap();
1372 assert!(read > 0);
1373 assert!(String::from_utf8_lossy(&request[..read]).starts_with("POST "));
1374 let body = b"data: keep\n\n";
1375 let response = format!(
1376 "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
1377 body.len()
1378 );
1379 http.write_all(response.as_bytes()).await.unwrap();
1380 http.write_all(body).await.unwrap();
1381 });
1382
1383 let response = authenticated_http_test_client(format!("http://{addr}/responses"))
1384 .post_codex_with_transport(
1385 &buffered_test_request(),
1386 &http_test_context(),
1387 None,
1388 crate::config::CodexTransport::Auto,
1389 )
1390 .await
1391 .unwrap();
1392 server.await.unwrap();
1393
1394 assert_eq!(response.status, 200);
1395 assert_eq!(response.body, b"data: keep\n\n");
1396 }
1397
1398 #[tokio::test]
1399 async fn over_budget_retry_after_stops_without_replay() {
1400 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1401 let addr = listener.local_addr().unwrap();
1402 let server = tokio::spawn(async move {
1403 let (mut stream, _) = listener.accept().await.unwrap();
1404 let mut request = [0_u8; 16 * 1024];
1405 assert!(stream.read(&mut request).await.unwrap() > 0);
1406 stream
1407 .write_all(
1408 b"HTTP/1.1 503 Service Unavailable\r\ncontent-length: 4\r\nretry-after: 120\r\nconnection: close\r\n\r\nstop",
1409 )
1410 .await
1411 .unwrap();
1412 });
1413
1414 let error = match authenticated_http_test_client(format!("http://{addr}/responses"))
1415 .post_codex_with_transport(
1416 &buffered_test_request(),
1417 &http_test_context(),
1418 None,
1419 crate::config::CodexTransport::Http,
1420 )
1421 .await
1422 {
1423 Ok(_) => panic!("over-budget Retry-After should propagate"),
1424 Err(error) => error,
1425 };
1426 server.await.unwrap();
1427 assert_eq!(error.status, 503);
1428 assert_eq!(error.retry_after.as_deref(), Some("120"));
1429 }
1430
1431 #[tokio::test]
1432 async fn buffered_http_rejects_non_retryable_error_status_before_sse_parsing() {
1433 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1434 let addr = listener.local_addr().unwrap();
1435 let server = tokio::spawn(async move {
1436 let (mut stream, _) = listener.accept().await.unwrap();
1437 let mut request = [0_u8; 16 * 1024];
1438 assert!(stream.read(&mut request).await.unwrap() > 0);
1439 let body = br#"{"error":{"message":"Model not found gpt-test"}}"#;
1440 let response = format!(
1441 "HTTP/1.1 404 Not Found\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
1442 body.len()
1443 );
1444 stream.write_all(response.as_bytes()).await.unwrap();
1445 stream.write_all(body).await.unwrap();
1446 });
1447
1448 let result = authenticated_http_test_client(format!("http://{addr}/responses"))
1449 .post_codex_with_transport(
1450 &buffered_test_request(),
1451 &http_test_context(),
1452 None,
1453 crate::config::CodexTransport::Http,
1454 )
1455 .await;
1456 server.await.unwrap();
1457 let error = match result {
1458 Ok(_) => panic!("non-success HTTP status must not reach the SSE reducer"),
1459 Err(error) => error,
1460 };
1461
1462 assert_eq!(error.status, 404);
1463 assert_eq!(error.detail.as_deref(), Some("Model not found gpt-test"));
1464 assert_eq!(error.origin, CodexErrorOrigin::BufferedHttp);
1465 }
1466
1467 #[test]
1468 fn status_error_preserves_buffered_websocket_event_message() {
1469 let error = codex_status_error(
1470 CodexResponse {
1471 body: b"data: {\"type\":\"error\",\"error\":{\"status\":400,\"message\":\"bad request\"}}\n\n"
1472 .to_vec(),
1473 status: 400,
1474 headers: Vec::new(),
1475 },
1476 crate::config::CodexTransport::WebSocket,
1477 );
1478
1479 assert_eq!(error.status, 400);
1480 assert_eq!(error.detail.as_deref(), Some("bad request"));
1481 assert_eq!(error.origin, CodexErrorOrigin::BufferedWebSocket);
1482 }
1483
1484 #[tokio::test]
1485 async fn active_http_body_can_exceed_header_timeout() {
1486 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1487 let addr = listener.local_addr().unwrap();
1488 let server = tokio::spawn(async move {
1489 let (mut stream, _) = listener.accept().await.unwrap();
1490 let mut request = [0_u8; 4096];
1491 assert!(stream.read(&mut request).await.unwrap() > 0);
1492 stream
1493 .write_all(
1494 b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\nconnection: close\r\n\r\n",
1495 )
1496 .await
1497 .unwrap();
1498 for chunk in [b"a".as_slice(), b"b", b"c"] {
1499 stream.write_all(b"1\r\n").await.unwrap();
1500 stream.write_all(chunk).await.unwrap();
1501 stream.write_all(b"\r\n").await.unwrap();
1502 tokio::time::sleep(Duration::from_millis(45)).await;
1503 }
1504 stream.write_all(b"0\r\n\r\n").await.unwrap();
1505 });
1506
1507 let response = http_test_client(format!("http://{addr}/responses"), 80)
1508 .attempt_post_http(&http_test_auth(), "{}", &http_test_context(), false)
1509 .await
1510 .expect("active body should not hit a whole-request timeout");
1511 server.await.unwrap();
1512
1513 assert_eq!(response.body, b"abc");
1514 }
1515
1516 #[tokio::test]
1517 async fn stalled_http_body_hits_idle_timeout() {
1518 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1519 let addr = listener.local_addr().unwrap();
1520 let server = tokio::spawn(async move {
1521 let (mut stream, _) = listener.accept().await.unwrap();
1522 let mut request = [0_u8; 4096];
1523 assert!(stream.read(&mut request).await.unwrap() > 0);
1524 stream
1525 .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n")
1526 .await
1527 .unwrap();
1528 tokio::time::sleep(Duration::from_millis(100)).await;
1529 });
1530
1531 let result = http_test_client(format!("http://{addr}/responses"), 30)
1532 .attempt_post_http(&http_test_auth(), "{}", &http_test_context(), false)
1533 .await;
1534 server.await.unwrap();
1535 let error = result.err().expect("stalled body should time out");
1536
1537 assert!(error.message.contains("next Codex response body chunk"));
1538 assert_eq!(error.detail.as_deref(), Some("http_response_body"));
1539 }
1540
1541 #[tokio::test]
1542 async fn reset_http_body_returns_transport_error() {
1543 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1544 let addr = listener.local_addr().unwrap();
1545 let server = tokio::spawn(async move {
1546 let (mut stream, _) = listener.accept().await.unwrap();
1547 let mut request = [0_u8; 4096];
1548 assert!(stream.read(&mut request).await.unwrap() > 0);
1549 stream
1550 .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 10\r\n\r\npartial")
1551 .await
1552 .unwrap();
1553 });
1554
1555 let result = http_test_client(format!("http://{addr}/responses"), 100)
1556 .attempt_post_http(&http_test_auth(), "{}", &http_test_context(), false)
1557 .await;
1558 server.await.unwrap();
1559 let error = result.err().expect("truncated body should fail");
1560
1561 assert!(
1562 error
1563 .message
1564 .contains("Transport error reading Codex response body")
1565 );
1566 assert_eq!(error.detail.as_deref(), Some("http_response_body"));
1567 }
1568
1569 #[test]
1570 fn codex_error_display() {
1571 let err = CodexError {
1572 status: 429,
1573 message: "Rate limited".to_string(),
1574 detail: Some("body".to_string()),
1575 retry_after: Some("5".to_string()),
1576 origin: CodexErrorOrigin::Http,
1577 };
1578 let display = format!("{err}");
1579 assert!(display.contains("429"));
1580 assert!(display.contains("Rate limited"));
1581 }
1582
1583 #[test]
1584 fn websocket_pre_request_502_is_retryable() {
1585 let err = CodexError {
1586 status: 502,
1587 message: "WebSocket connect error".to_string(),
1588 detail: Some("websocket_pre_request".to_string()),
1589 retry_after: Some("3".to_string()),
1590 origin: CodexErrorOrigin::WebSocket,
1591 };
1592
1593 assert!(is_retryable_transport_error(&err));
1594 }
1595
1596 #[test]
1597 fn websocket_pre_request_statusless_error_is_retryable() {
1598 let err = CodexError {
1599 status: 0,
1600 message: "WebSocket connect timeout after 15000ms".to_string(),
1601 detail: Some("websocket_pre_request".to_string()),
1602 retry_after: None,
1603 origin: CodexErrorOrigin::WebSocket,
1604 };
1605
1606 assert!(is_retryable_transport_error(&err));
1607 }
1608
1609 #[test]
1610 fn websocket_pre_request_400_is_not_retryable() {
1611 let err = CodexError {
1612 status: 400,
1613 message: "WebSocket connect error".to_string(),
1614 detail: Some("websocket_pre_request".to_string()),
1615 retry_after: None,
1616 origin: CodexErrorOrigin::WebSocket,
1617 };
1618
1619 assert!(!is_retryable_transport_error(&err));
1620 }
1621
1622 #[test]
1623 fn statusless_transport_error_matching_is_case_insensitive() {
1624 let err = CodexError {
1625 status: 0,
1626 message: "WebSocket protocol error: Connection reset without closing handshake"
1627 .to_string(),
1628 detail: None,
1629 retry_after: None,
1630 origin: CodexErrorOrigin::WebSocket,
1631 };
1632
1633 assert!(is_retryable_transport_error(&err));
1634 }
1635
1636 #[test]
1637 fn codex_headers_include_session_and_beta() {
1638 let auth = StoredAuth {
1639 access: "tok".into(),
1640 refresh: String::new(),
1641 account_id: Some("acct".into()),
1642 expires: u64::MAX,
1643 };
1644 let ctx = RequestContext {
1645 req_id: "r".into(),
1646 session_id: Some("s".into()),
1647 session_seq: None,
1648 provider: "codex".into(),
1649 traffic: None,
1650 monitor: None,
1651 passthrough: None,
1652 };
1653 let headers = build_codex_headers(&auth, &ctx, false).unwrap();
1654 assert_eq!(
1655 headers.get("openai-beta").unwrap(),
1656 "responses=experimental"
1657 );
1658 assert_eq!(headers.get("session_id").unwrap(), "s");
1659 }
1660
1661 #[test]
1662 fn codex_headers_include_responses_lite_when_requested() {
1663 let auth = StoredAuth {
1664 access: "tok".into(),
1665 refresh: String::new(),
1666 account_id: None,
1667 expires: u64::MAX,
1668 };
1669 let ctx = RequestContext {
1670 req_id: "r".into(),
1671 session_id: None,
1672 session_seq: None,
1673 provider: "codex".into(),
1674 traffic: None,
1675 monitor: None,
1676 passthrough: None,
1677 };
1678 let headers = build_codex_headers(&auth, &ctx, true).unwrap();
1679 assert_eq!(
1680 headers
1681 .get("x-openai-internal-codex-responses-lite")
1682 .unwrap(),
1683 "true"
1684 );
1685 assert_eq!(headers.get("originator").unwrap(), "codex_cli_rs");
1686 }
1687
1688 #[test]
1689 fn codex_headers_omit_session_when_missing() {
1690 let auth = StoredAuth {
1691 access: "tok".into(),
1692 refresh: String::new(),
1693 account_id: None,
1694 expires: u64::MAX,
1695 };
1696 let ctx = RequestContext {
1697 req_id: "r".into(),
1698 session_id: None,
1699 session_seq: None,
1700 provider: "codex".into(),
1701 traffic: None,
1702 monitor: None,
1703 passthrough: None,
1704 };
1705 let headers = build_codex_headers(&auth, &ctx, false).unwrap();
1706 assert!(headers.get("session_id").is_none());
1707 assert!(headers.get("x-client-request-id").is_none());
1708 }
1709
1710 #[test]
1711 fn codex_headers_return_error_for_invalid_session_header() {
1712 let auth = StoredAuth {
1713 access: "tok".into(),
1714 refresh: String::new(),
1715 account_id: None,
1716 expires: u64::MAX,
1717 };
1718 let ctx = RequestContext {
1719 req_id: "r".into(),
1720 session_id: Some("bad\nsession".into()),
1721 session_seq: None,
1722 provider: "codex".into(),
1723 traffic: None,
1724 monitor: None,
1725 passthrough: None,
1726 };
1727 let err = build_codex_headers(&auth, &ctx, false).unwrap_err();
1728 assert_eq!(err.status, 500);
1729 assert!(err.message.contains("session_id"));
1730 }
1731
1732 #[test]
1733 fn build_websocket_request_removes_stream() {
1734 let input = vec![
1735 super::super::translate::request::ResponsesInputItem::Message {
1736 role: "user".to_string(),
1737 content: vec![
1738 super::super::translate::request::ResponsesContentPart::InputText {
1739 text: "hello".to_string(),
1740 },
1741 ],
1742 },
1743 ];
1744 let req = ResponsesRequest {
1745 model: "gpt-5.5".to_string(),
1746 instructions: None,
1747 input,
1748 tools: None,
1749 tool_choice: None,
1750 store: false,
1751 stream: true,
1752 parallel_tool_calls: true,
1753 include: None,
1754 client_metadata: None,
1755 service_tier: None,
1756 prompt_cache_key: None,
1757 text: super::super::translate::request::ResponsesText {
1758 verbosity: Some("low".to_string()),
1759 format: None,
1760 },
1761 reasoning: None,
1762 };
1763 let payload = build_websocket_request(&req, None);
1764 assert_eq!(
1765 payload.get("type").and_then(|v| v.as_str()),
1766 Some("response.create")
1767 );
1768 assert!(payload.get("stream").is_none());
1769 assert!(payload.get("previous_response_id").is_none());
1770 }
1771
1772 #[test]
1773 fn websocket_pool_key_tracks_continuation_opt_in() {
1774 let ctx = RequestContext {
1775 req_id: "r".into(),
1776 session_id: Some("session".into()),
1777 session_seq: None,
1778 provider: "codex".into(),
1779 traffic: None,
1780 monitor: None,
1781 passthrough: None,
1782 };
1783 let disabled = super::super::continuation::ContinuationCandidate {
1784 previous_response_id: None,
1785 input_delta: None,
1786 input_delta_count: 1,
1787 disabled_reason: Some("disabled".into()),
1788 };
1789 let first_enabled = super::super::continuation::ContinuationCandidate {
1790 previous_response_id: None,
1791 input_delta: None,
1792 input_delta_count: 1,
1793 disabled_reason: Some("missing_state".into()),
1794 };
1795 let append = super::super::continuation::ContinuationCandidate {
1796 previous_response_id: Some("resp_1".into()),
1797 input_delta: None,
1798 input_delta_count: 1,
1799 disabled_reason: None,
1800 };
1801
1802 assert_eq!(websocket_pool_key(&ctx, Some(&disabled)), None);
1803 assert_eq!(
1804 websocket_pool_key(&ctx, Some(&first_enabled)),
1805 Some("session")
1806 );
1807 assert_eq!(websocket_pool_key(&ctx, Some(&append)), Some("session"));
1808 }
1809
1810 #[test]
1811 fn websocket_pool_reset_ignores_initial_and_disabled_states() {
1812 let missing_state = super::super::continuation::ContinuationCandidate {
1813 previous_response_id: None,
1814 input_delta: None,
1815 input_delta_count: 1,
1816 disabled_reason: Some("missing_state".into()),
1817 };
1818 let disabled = super::super::continuation::ContinuationCandidate {
1819 previous_response_id: None,
1820 input_delta: None,
1821 input_delta_count: 1,
1822 disabled_reason: Some("disabled".into()),
1823 };
1824 let prompt_changed = super::super::continuation::ContinuationCandidate {
1825 previous_response_id: None,
1826 input_delta: None,
1827 input_delta_count: 1,
1828 disabled_reason: Some("prompt_changed".into()),
1829 };
1830
1831 assert!(!should_reset_websocket_pool(Some(&missing_state)));
1832 assert!(!should_reset_websocket_pool(Some(&disabled)));
1833 assert!(should_reset_websocket_pool(Some(&prompt_changed)));
1834 }
1835
1836 #[test]
1837 fn build_codex_headers_error_on_empty_access() {
1838 let auth = StoredAuth {
1839 access: "".into(),
1840 refresh: String::new(),
1841 account_id: None,
1842 expires: u64::MAX,
1843 };
1844 let ctx = RequestContext {
1845 req_id: "r".into(),
1846 session_id: None,
1847 session_seq: None,
1848 provider: "codex".into(),
1849 traffic: None,
1850 monitor: None,
1851 passthrough: None,
1852 };
1853 let result = build_codex_headers(&auth, &ctx, false);
1854 assert!(
1855 result.is_ok(),
1856 "empty access should still produce valid Bearer header"
1857 );
1858 }
1859
1860 #[test]
1861 fn codex_header_timeout_error_display() {
1862 let err = CodexHeaderTimeoutError { timeout_ms: 60000 };
1863 let display = format!("{err}");
1864 assert!(display.contains("60000"));
1865 }
1866
1867 #[test]
1868 fn codex_transport_error_display() {
1869 let err = CodexTransportError {
1870 message: "connection reset".to_string(),
1871 };
1872 let display = format!("{err}");
1873 assert!(display.contains("connection reset"));
1874 }
1875
1876 #[test]
1877 fn unauthorized_retry_distinguishes_auto_and_strict_websocket_handshakes() {
1878 let http_unauthorized = Ok(CodexResponse {
1879 body: Vec::new(),
1880 status: 401,
1881 headers: Vec::new(),
1882 });
1883 let websocket_unauthorized = Err(CodexError {
1884 status: 401,
1885 message: "WebSocket connect error".to_string(),
1886 detail: None,
1887 retry_after: None,
1888 origin: CodexErrorOrigin::WebSocket,
1889 });
1890 let forbidden = Err(CodexError {
1891 status: 403,
1892 message: "Forbidden".to_string(),
1893 detail: None,
1894 retry_after: None,
1895 origin: CodexErrorOrigin::WebSocket,
1896 });
1897 let rejected_handshake = Err(CodexError {
1898 status: 401,
1899 message: "WebSocket connect error".to_string(),
1900 detail: Some("policy denied".to_string()),
1901 retry_after: None,
1902 origin: CodexErrorOrigin::WebSocketHandshake,
1903 });
1904 let rejected_handshake_err = match &rejected_handshake {
1905 Err(error) => error,
1906 Ok(_) => panic!("expected rejected handshake"),
1907 };
1908
1909 assert!(should_refresh_after_unauthorized(
1910 &http_unauthorized,
1911 false,
1912 crate::config::CodexTransport::Auto
1913 ));
1914 assert!(should_refresh_after_unauthorized(
1915 &websocket_unauthorized,
1916 false,
1917 crate::config::CodexTransport::Auto
1918 ));
1919 assert!(!should_refresh_after_unauthorized(
1920 &forbidden,
1921 false,
1922 crate::config::CodexTransport::Auto
1923 ));
1924 assert!(!should_refresh_after_unauthorized(
1925 &rejected_handshake,
1926 false,
1927 crate::config::CodexTransport::Auto
1928 ));
1929 assert!(should_refresh_after_unauthorized(
1930 &rejected_handshake,
1931 false,
1932 crate::config::CodexTransport::WebSocket
1933 ));
1934 assert!(!should_refresh_after_unauthorized(
1935 &http_unauthorized,
1936 true,
1937 crate::config::CodexTransport::Auto
1938 ));
1939 assert!(should_fallback_to_http(rejected_handshake_err));
1940 }
1941
1942 #[test]
1943 fn continuation_retry_requires_previous_response_id() {
1944 let append = super::super::continuation::ContinuationCandidate {
1945 previous_response_id: Some("resp_1".into()),
1946 input_delta: None,
1947 input_delta_count: 1,
1948 disabled_reason: None,
1949 };
1950 let initial = super::super::continuation::ContinuationCandidate {
1951 previous_response_id: None,
1952 input_delta: None,
1953 input_delta_count: 1,
1954 disabled_reason: Some("missing_state".into()),
1955 };
1956 let timeout = CodexError {
1957 status: 0,
1958 message: "WebSocket response start timeout after 60000ms".to_string(),
1959 detail: Some(
1960 super::super::websocket::WEBSOCKET_RESPONSE_START_TIMEOUT_DETAIL.to_string(),
1961 ),
1962 retry_after: None,
1963 origin: CodexErrorOrigin::WebSocket,
1964 };
1965 let missing = CodexError {
1966 status: 0,
1967 message: "Previous response not found".to_string(),
1968 detail: Some("previous_response_not_found".to_string()),
1969 retry_after: None,
1970 origin: CodexErrorOrigin::WebSocket,
1971 };
1972 let idle = CodexError {
1973 status: 0,
1974 message: "WebSocket idle timeout after 60000ms".to_string(),
1975 detail: None,
1976 retry_after: None,
1977 origin: CodexErrorOrigin::WebSocket,
1978 };
1979
1980 assert!(should_retry_without_continuation(&timeout, Some(&append)));
1981 assert!(should_retry_without_continuation(&missing, Some(&append)));
1982 assert!(!should_retry_without_continuation(&idle, Some(&append)));
1983 assert!(!should_retry_without_continuation(&timeout, Some(&initial)));
1984 assert!(!should_retry_without_continuation(&timeout, None));
1985 }
1986}