1use std::collections::HashMap;
8use std::pin::Pin;
9use std::sync::{Mutex, OnceLock};
10use std::task::{Context, Poll};
11use std::time::{Duration, Instant};
12
13use bytes::Bytes;
14use futures::{Stream, StreamExt, stream::BoxStream};
15use reqwest::Client;
16use serde::{Deserialize, Serialize};
17
18use aimux_core::recording::{
19 HttpExchange, HttpRecord, RecordingContext, ResponseRecord, TimingRecord,
20};
21use aimux_core::{AiMuxError, ApiCallError};
22
23use crate::logging::{body_logging_enabled, redact_body};
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct ProxyConfig {
28 pub http_url: Option<String>,
29 pub https_url: Option<String>,
30 pub all_url: Option<String>,
31 pub no_proxy: Option<String>,
32}
33
34static GLOBAL_PROXY: OnceLock<ProxyConfig> = OnceLock::new();
35static SHARED: OnceLock<Mutex<HashMap<Option<tokio::runtime::Id>, Client>>> = OnceLock::new();
45
46const SHARED_CLIENT_CAP: usize = 8;
52
53pub fn init_proxy(config: ProxyConfig) -> bool {
60 GLOBAL_PROXY.set(config).is_ok()
61}
62
63pub(crate) fn global_proxy() -> ProxyConfig {
68 GLOBAL_PROXY.get().cloned().unwrap_or_default()
69}
70
71pub fn shared_client() -> Result<Client, AiMuxError> {
80 let key = tokio::runtime::Handle::try_current().ok().map(|h| h.id());
81 let mut clients = SHARED
82 .get_or_init(|| Mutex::new(HashMap::new()))
83 .lock()
84 .expect("shared HTTP client mutex poisoned");
85 if let Some(client) = clients.get(&key) {
86 return Ok(client.clone());
87 }
88 let client = build_client(global_proxy()).map_err(|error| {
89 AiMuxError::Other(format!("shared HTTP client initialization failed: {error}"))
90 })?;
91 if clients.len() >= SHARED_CLIENT_CAP {
92 clients.clear();
95 }
96 clients.insert(key, client.clone());
97 Ok(client)
98}
99
100fn build_client(proxy: ProxyConfig) -> Result<Client, String> {
101 let builder = Client::builder()
102 .connect_timeout(Duration::from_millis(10_000))
103 .pool_max_idle_per_host(10)
104 .pool_idle_timeout(Some(Duration::from_secs(30)))
105 .tcp_keepalive(Some(Duration::from_secs(20)));
106 apply_proxy(builder, &proxy)
107 .build()
108 .map_err(|error| error.to_string())
109}
110
111const MAX_REDIRECTS: usize = 10;
113
114fn download_client() -> Result<Client, AiMuxError> {
119 let builder = Client::builder()
120 .connect_timeout(Duration::from_millis(10_000))
121 .redirect(reqwest::redirect::Policy::none());
122 apply_proxy(builder, &global_proxy())
123 .build()
124 .map_err(|e| AiMuxError::Other(format!("download client initialization failed: {e}")))
125}
126
127fn pinned_client(url: &str, addresses: &[std::net::IpAddr]) -> Result<Client, AiMuxError> {
135 if addresses.is_empty() {
136 return Err(AiMuxError::Other(
137 "cannot build a pinned download client without an address".into(),
138 ));
139 }
140 let parsed = reqwest::Url::parse(url)
141 .map_err(|e| AiMuxError::Other(format!("invalid download url: {e}")))?;
142 let host = parsed
143 .host_str()
144 .ok_or_else(|| AiMuxError::Other("download url has no host".to_string()))?
145 .to_string();
146 let port = parsed.port_or_known_default().unwrap_or(80);
147 let socket_addresses: Vec<_> = addresses
148 .iter()
149 .map(|address| std::net::SocketAddr::new(*address, port))
150 .collect();
151 let builder = Client::builder()
152 .connect_timeout(Duration::from_millis(10_000))
153 .redirect(reqwest::redirect::Policy::none());
154 apply_proxy(builder, &global_proxy())
155 .resolve_to_addrs(&host, &socket_addresses)
156 .build()
157 .map_err(|e| {
158 AiMuxError::Other(format!("pinned download client initialization failed: {e}"))
159 })
160}
161
162fn apply_proxy(mut builder: reqwest::ClientBuilder, proxy: &ProxyConfig) -> reqwest::ClientBuilder {
163 let http = proxy.http_url.as_deref().or(proxy.all_url.as_deref());
164 let https = proxy.https_url.as_deref().or(proxy.all_url.as_deref());
165 if let Some(url) = http
166 && let Ok(reqwest_proxy) = reqwest::Proxy::http(url)
167 {
168 builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy);
169 }
170 if let Some(url) = https
171 && let Ok(reqwest_proxy) = reqwest::Proxy::https(url)
172 {
173 builder = apply_no_proxy(builder, reqwest_proxy, &proxy.no_proxy);
174 }
175 builder
176}
177
178fn apply_no_proxy(
180 builder: reqwest::ClientBuilder,
181 proxy: reqwest::Proxy,
182 no_proxy: &Option<String>,
183) -> reqwest::ClientBuilder {
184 match no_proxy.as_deref().map(reqwest::NoProxy::from_string) {
185 Some(Some(no_proxy)) => builder.proxy(proxy.no_proxy(Some(no_proxy))),
186 _ => builder.proxy(proxy),
187 }
188}
189
190#[derive(Debug, Clone)]
192pub enum HttpBody {
193 Json(serde_json::Value),
194 Bytes(Vec<u8>, String),
195 Empty,
196}
197
198#[derive(Debug, Clone, Copy)]
199pub(crate) enum HttpMethod {
200 Get,
201 Post,
202}
203
204impl HttpMethod {
205 fn as_str(self) -> &'static str {
206 match self {
207 Self::Get => "GET",
208 Self::Post => "POST",
209 }
210 }
211}
212
213#[derive(Debug, Clone, Default)]
217pub struct HttpRequest {
218 pub url: String,
219 pub headers: Vec<(String, String)>,
220 pub abort_signal: Option<aimux_core::AbortSignal>,
221 pub call_id: Option<String>,
222 pub recording_context: Option<RecordingContext>,
223 pub response_timeout: Option<std::time::Duration>,
228 pub max_json_response_bytes: Option<usize>,
234 pub validate_url: bool,
240 pub trusted_origin: Option<String>,
246 pub credentialed_origin: Option<String>,
251}
252
253pub trait ExchangeContext {
257 fn abort_signal(&self) -> Option<aimux_core::AbortSignal>;
258 fn call_id(&self) -> Option<String> {
261 None
262 }
263 fn recording_context(&self) -> Option<RecordingContext> {
264 None
265 }
266}
267
268impl ExchangeContext for aimux_core::options::CallOptions {
269 fn abort_signal(&self) -> Option<aimux_core::AbortSignal> {
270 self.abort_signal.clone()
271 }
272 fn call_id(&self) -> Option<String> {
273 self.call_id.clone()
274 }
275 fn recording_context(&self) -> Option<RecordingContext> {
276 self.recording_context.clone()
277 }
278}
279
280macro_rules! exchange_context_abort_only {
281 ($($ty:path),* $(,)?) => {
282 $(impl ExchangeContext for $ty {
283 fn abort_signal(&self) -> Option<aimux_core::AbortSignal> {
284 self.abort_signal.clone()
285 }
286 })*
287 };
288}
289
290exchange_context_abort_only!(
291 aimux_core::search_model::SearchCallOptions,
292 aimux_core::speech_model::SpeechCallOptions,
293 aimux_core::image_model::ImageCallOptions,
294 aimux_core::video_model::VideoCallOptions,
295 aimux_core::transcription_model::TranscriptionCallOptions,
296 aimux_core::embedding_model::EmbeddingCallOptions,
297 aimux_core::reranking_model::RerankingCallOptions,
298 aimux_core::files_model::UploadFileCallOptions,
299);
300
301impl HttpRequest {
302 #[must_use]
306 pub fn new(
307 url: impl Into<String>,
308 headers: Vec<(String, String)>,
309 options: &impl ExchangeContext,
310 ) -> Self {
311 Self {
312 url: url.into(),
313 headers,
314 abort_signal: options.abort_signal(),
315 call_id: options.call_id(),
316 recording_context: options.recording_context(),
317 ..Self::default()
318 }
319 }
320}
321
322#[derive(Debug, Clone)]
325pub(crate) struct DownloadValidation {
326 pub(crate) trusted_origin: Option<String>,
327 pub(crate) credentialed_origin: Option<String>,
328}
329
330#[derive(Debug, Clone)]
331pub(crate) struct PreparedRequest {
332 method: HttpMethod,
333 body: HttpBody,
334 pub(crate) url: String,
335 headers: Vec<(String, String)>,
336 pub(crate) abort_signal: Option<aimux_core::AbortSignal>,
337 call_id: Option<String>,
338 recording_context: Option<RecordingContext>,
339 pub(crate) response_timeout: Option<std::time::Duration>,
340 pub(crate) max_json_response_bytes: Option<usize>,
341 pub(crate) validation: Option<DownloadValidation>,
342}
343
344impl HttpRequest {
345 pub(crate) fn prepare(self, method: HttpMethod, body: HttpBody) -> PreparedRequest {
346 let mut headers = self.headers;
350 if let Some(origin) = self.credentialed_origin.as_deref()
351 && !crate::download_guard::same_origin(&self.url, origin)
352 {
353 crate::download_guard::retain_user_agent(&mut headers);
354 }
355 PreparedRequest {
356 method,
357 body,
358 url: self.url,
359 headers,
360 abort_signal: self.abort_signal,
361 call_id: self.call_id,
362 recording_context: self.recording_context,
363 response_timeout: self.response_timeout,
364 max_json_response_bytes: self.max_json_response_bytes,
365 validation: self.validate_url.then_some(DownloadValidation {
366 trusted_origin: self.trusted_origin,
367 credentialed_origin: self.credentialed_origin,
368 }),
369 }
370 }
371}
372
373fn api_call_error(request: &PreparedRequest, message: impl Into<String>) -> ApiCallError {
374 ApiCallError::new(
375 message,
376 sanitized_request_url(&request.url),
377 crate::logging::redact_request_values(&request.body),
378 )
379}
380
381struct ExchangeGuard<'a> {
382 request: &'a PreparedRequest,
383 attempt: u32,
384 exchange_index: u32,
385 started: Instant,
386 armed: bool,
387}
388
389impl<'a> ExchangeGuard<'a> {
390 fn new(
391 request: &'a PreparedRequest,
392 attempt: u32,
393 exchange_index: u32,
394 started: Instant,
395 ) -> Self {
396 Self {
397 request,
398 attempt,
399 exchange_index,
400 started,
401 armed: true,
402 }
403 }
404
405 fn fail(&mut self, error: &str) {
406 if self.armed {
407 record_failed_exchange(
408 self.request,
409 self.attempt,
410 self.exchange_index,
411 self.started.elapsed().as_millis() as u64,
412 error,
413 );
414 record_transport_closed(self.request);
415 self.armed = false;
416 }
417 }
418
419 fn disarm(&mut self) {
420 self.armed = false;
421 }
422}
423
424impl Drop for ExchangeGuard<'_> {
425 fn drop(&mut self) {
426 self.fail("exchange future cancelled");
427 }
428}
429
430pub(crate) async fn send_request_once(
432 request: &PreparedRequest,
433) -> Result<reqwest::Response, AiMuxError> {
434 let started = Instant::now();
435 let (attempt, exchange_index) = request
436 .recording_context
437 .as_ref()
438 .map(RecordingContext::next_exchange)
439 .unwrap_or((1, 1));
440 let mut exchange = ExchangeGuard::new(request, attempt, exchange_index, started);
441 tracing::debug!(
442 target: "aimux_provider_utils::http",
443 method = request.method.as_str(),
444 url = %sanitized_request_url(&request.url),
445 host = %request_host(&request.url),
446 body_size = request_body_size(request),
447 header_count = request.headers.len(),
448 call_id = request.call_id.as_deref().unwrap_or(""),
449 "request"
450 );
451 if body_logging_enabled()
452 && let HttpBody::Json(value) = &request.body
453 {
454 tracing::trace!(
455 target: "aimux_provider_utils::http",
456 body = %redact_body(&value.to_string()),
457 "request_body"
458 );
459 }
460
461 let sent = if request.validation.is_some() {
462 send_validated_redirects(request).await
463 } else {
464 match shared_client() {
465 Ok(client) => send_one_request(&client, request).await,
466 Err(error) => Err(error),
467 }
468 };
469 let response = match sent {
470 Ok(response) => response,
471 Err(error) => {
472 exchange.fail(&error.to_string());
473 return Err(error);
474 }
475 };
476 exchange.disarm();
477 let latency_ms = started.elapsed().as_millis() as u64;
478 tracing::debug!(
479 target: "aimux_provider_utils::http",
480 status = response.status().as_u16(),
481 latency_ms,
482 "response"
483 );
484 Ok(observe_response(
485 response,
486 request,
487 started,
488 latency_ms,
489 attempt,
490 exchange_index,
491 ))
492}
493
494async fn send_one_request(
495 client: &Client,
496 request: &PreparedRequest,
497) -> Result<reqwest::Response, AiMuxError> {
498 if let Some(signal) = &request.abort_signal {
499 if signal.is_aborted() {
500 return Err(AiMuxError::from_abort_signal(signal));
501 }
502 tokio::select! {
503 biased;
504 () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
505 response = build_request_builder(client, request)?.send() => {
506 response.map_err(|error| AiMuxError::ApiCall(Box::new(ApiCallError {
507 is_retryable: true,
508 ..api_call_error(request, error.to_string())
509 })))
510 }
511 }
512 } else {
513 build_request_builder(client, request)?
514 .send()
515 .await
516 .map_err(|error| {
517 AiMuxError::ApiCall(Box::new(ApiCallError {
518 is_retryable: true,
519 ..api_call_error(request, error.to_string())
520 }))
521 })
522 }
523}
524
525fn is_redirect_status(status: reqwest::StatusCode) -> bool {
526 matches!(
527 status,
528 reqwest::StatusCode::MOVED_PERMANENTLY
529 | reqwest::StatusCode::FOUND
530 | reqwest::StatusCode::SEE_OTHER
531 | reqwest::StatusCode::TEMPORARY_REDIRECT
532 | reqwest::StatusCode::PERMANENT_REDIRECT
533 )
534}
535
536fn redirect_error(
537 request: &PreparedRequest,
538 message: impl Into<String>,
539 status: reqwest::StatusCode,
540) -> AiMuxError {
541 AiMuxError::ApiCall(Box::new(ApiCallError {
542 status_code: Some(status.as_u16()),
543 ..api_call_error(request, message)
544 }))
545}
546
547async fn send_validated_redirects(
552 request: &PreparedRequest,
553) -> Result<reqwest::Response, AiMuxError> {
554 let validation = request
555 .validation
556 .clone()
557 .expect("send_validated_redirects requires a validation config");
558 let trusted_origin = validation.trusted_origin.as_deref();
559 let credentialed_origin = validation.credentialed_origin.as_deref();
560 let mut current = request.clone();
561 crate::download_guard::sanitize_download_headers(&mut current.headers);
564 let credential_anchor = credentialed_origin.unwrap_or(&request.url);
567 let mut pinned =
568 crate::download_guard::validate_download_target(¤t.url, trusted_origin).await?;
569 for redirect_count in 0..=MAX_REDIRECTS {
570 let client = if pinned.is_empty() {
572 download_client()?
573 } else {
574 pinned_client(¤t.url, &pinned)?
575 };
576 let response = send_one_request(&client, ¤t).await?;
577 let status = response.status();
578 if !is_redirect_status(status) {
579 return Ok(response);
580 }
581 let Some(location) = response.headers().get(reqwest::header::LOCATION) else {
582 return Ok(response);
583 };
584 if redirect_count == MAX_REDIRECTS {
585 return Err(redirect_error(request, "too many redirects", status));
586 }
587 let location = location
588 .to_str()
589 .map(str::to_owned)
590 .map_err(|_| redirect_error(request, "redirect location is not valid UTF-8", status))?;
591 drop(response);
594 let base = url::Url::parse(¤t.url)
595 .map_err(|e| AiMuxError::InvalidArgument(format!("invalid request URL: {e}")))?;
596 let next = base
597 .join(&location)
598 .map_err(|e| redirect_error(request, format!("invalid redirect URL: {e}"), status))?;
599 if !matches!(next.scheme(), "http" | "https") {
603 return Err(redirect_error(
604 request,
605 format!("redirect to non-HTTP scheme: {}", next.scheme()),
606 status,
607 ));
608 }
609 let next = next.to_string();
610 let hop_trusted = crate::download_guard::hop_trusted_origin(trusted_origin, ¤t.url);
611 pinned = crate::download_guard::validate_download_target(&next, hop_trusted).await?;
612 if !crate::download_guard::same_origin(&next, credential_anchor) {
615 crate::download_guard::retain_user_agent(&mut current.headers);
616 }
617 if status == reqwest::StatusCode::SEE_OTHER
618 || (matches!(
619 status,
620 reqwest::StatusCode::MOVED_PERMANENTLY | reqwest::StatusCode::FOUND
621 ) && matches!(current.method, HttpMethod::Post))
622 {
623 current.method = HttpMethod::Get;
624 current.body = HttpBody::Empty;
625 }
626 current.url = next;
627 }
628 unreachable!("redirect loop returns on response or error")
629}
630
631fn build_request_builder(
632 client: &Client,
633 request: &PreparedRequest,
634) -> Result<reqwest::RequestBuilder, AiMuxError> {
635 let mut builder = match request.method {
636 HttpMethod::Get => client.get(&request.url),
637 HttpMethod::Post => client.post(&request.url),
638 };
639 for (name, value) in &request.headers {
640 let header_name = reqwest::header::HeaderName::try_from(name)
641 .map_err(|_| AiMuxError::InvalidArgument(format!("invalid header name: {name}")))?;
642 let header_value = reqwest::header::HeaderValue::try_from(value).map_err(|_| {
643 AiMuxError::InvalidArgument(format!("invalid header value for {name}: {value}"))
644 })?;
645 builder = builder.header(header_name, header_value);
646 }
647 Ok(match &request.body {
648 HttpBody::Json(value) => builder.json(value),
649 HttpBody::Bytes(bytes, content_type) => builder
650 .header(reqwest::header::CONTENT_TYPE, content_type)
651 .body(bytes.clone()),
652 HttpBody::Empty => builder,
653 })
654}
655
656pub async fn sleep_or_abort(
663 duration: Duration,
664 abort_signal: Option<&aimux_core::AbortSignal>,
665) -> Result<(), AiMuxError> {
666 match abort_signal {
667 Some(signal) => tokio::select! {
668 biased;
669 () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
670 () = tokio::time::sleep(duration) => Ok(()),
671 },
672 None => {
673 tokio::time::sleep(duration).await;
674 Ok(())
675 }
676 }
677}
678
679fn observe_response(
680 response: reqwest::Response,
681 request: &PreparedRequest,
682 started: Instant,
683 latency_ms: u64,
684 attempt: u32,
685 exchange_index: u32,
686) -> reqwest::Response {
687 let status = response.status();
688 let version = response.version();
689 let headers = response.headers().clone();
690 let recording = request.recording_context.as_ref().map(|context| {
691 let redacted_headers = redacted_response_headers(&headers);
692 let response = ResponseRecord {
693 status: status.as_u16(),
694 headers: redacted_headers.clone(),
695 body: None,
696 stream_chunks: Some(0),
697 ttfb_ms: Some(latency_ms),
698 };
699 record_exchange(
700 request,
701 HttpExchange {
702 step: request
703 .recording_context
704 .as_ref()
705 .and_then(RecordingContext::step),
706 attempt,
707 exchange_index,
708 request: to_http_record(request),
709 response: Some(response),
710 timing: TimingRecord {
711 latency_ms,
712 ttfb_ms: Some(latency_ms),
713 },
714 error: None,
715 finalized: false,
716 },
717 );
718 ResponseRecording {
719 context: context.clone(),
720 attempt,
721 exchange_index,
722 status: status.as_u16(),
723 headers: redacted_headers,
724 ttfb_ms: Some(latency_ms),
725 body: ByteAccumulator::new(RECORD_BODY_CAP),
726 chunks: 0,
727 finalized: false,
728 }
729 });
730 let observed = ObservedBody {
731 inner: response.bytes_stream().boxed(),
732 started,
733 chunks: 0,
734 done: false,
735 recording,
736 };
737 let mut rebuilt = http::Response::builder()
738 .status(status)
739 .version(version)
740 .body(reqwest::Body::wrap_stream(observed))
741 .expect("status and version came from a valid reqwest response");
742 *rebuilt.headers_mut() = headers;
743 rebuilt.into()
744}
745
746struct ObservedBody {
747 inner: BoxStream<'static, Result<Bytes, reqwest::Error>>,
748 started: Instant,
749 chunks: usize,
750 done: bool,
751 recording: Option<ResponseRecording>,
752}
753
754impl Stream for ObservedBody {
755 type Item = Result<Bytes, reqwest::Error>;
756
757 fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
758 if self.done {
759 return Poll::Ready(None);
760 }
761 let result = self.inner.as_mut().poll_next(context);
762 match &result {
763 Poll::Ready(Some(Ok(bytes))) => {
764 self.chunks += 1;
765 let chunks = self.chunks;
766 if let Some(recording) = &mut self.recording {
767 recording.body.push(bytes);
768 recording.chunks = chunks;
769 }
770 }
771 Poll::Ready(Some(Err(error))) => {
772 self.done = true;
773 if let Some(recording) = &mut self.recording {
774 recording.finalize(Some(error.to_string()));
775 }
776 }
777 Poll::Ready(None) => {
778 self.done = true;
779 tracing::debug!(
780 target: "aimux_provider_utils::http",
781 chunks = self.chunks,
782 duration_ms = self.started.elapsed().as_millis() as u64,
783 "response_body_end"
784 );
785 if let Some(recording) = &mut self.recording {
786 recording.finalize(None);
787 }
788 }
789 Poll::Pending => {}
790 }
791 result
792 }
793}
794
795impl Drop for ObservedBody {
796 fn drop(&mut self) {
797 if !self.done
798 && let Some(recording) = &mut self.recording
799 {
800 recording.finalize(Some("response body abandoned".into()));
801 }
802 }
803}
804
805struct ResponseRecording {
806 context: RecordingContext,
807 attempt: u32,
808 exchange_index: u32,
809 status: u16,
810 headers: Vec<(String, String)>,
811 ttfb_ms: Option<u64>,
812 body: ByteAccumulator,
813 chunks: usize,
814 finalized: bool,
815}
816
817impl ResponseRecording {
818 fn finalize(&mut self, error: Option<String>) {
819 if self.finalized {
820 return;
821 }
822 self.finalized = true;
823 self.context.recorder.record_exchange_update(
824 &self.context.call_id,
825 self.attempt,
826 self.exchange_index,
827 &ResponseRecord {
828 status: self.status,
829 headers: self.headers.clone(),
830 body: self.body.decode(),
831 stream_chunks: Some(self.chunks),
832 ttfb_ms: self.ttfb_ms,
833 },
834 error,
835 );
836 self.context
837 .recorder
838 .record_transport_closed(&self.context.call_id);
839 }
840}
841
842const RECORD_BODY_CAP: usize = 1 << 20;
843
844struct ByteAccumulator {
845 bytes: Vec<u8>,
846 truncated: bool,
847}
848
849impl ByteAccumulator {
850 fn new(capacity: usize) -> Self {
851 Self {
852 bytes: Vec::with_capacity(capacity.min(8 * 1024)),
853 truncated: false,
854 }
855 }
856
857 fn push(&mut self, bytes: &[u8]) {
858 let remaining = RECORD_BODY_CAP.saturating_sub(self.bytes.len());
859 if bytes.len() > remaining {
860 self.bytes.extend_from_slice(&bytes[..remaining]);
861 self.truncated = true;
862 } else {
863 self.bytes.extend_from_slice(bytes);
864 }
865 }
866
867 fn decode(&self) -> Option<String> {
868 if self.bytes.is_empty() {
869 return None;
870 }
871 let mut value = String::from_utf8_lossy(&self.bytes).into_owned();
872 if self.truncated {
873 while value.ends_with('\u{FFFD}') {
874 value.pop();
875 }
876 value.push_str("…(truncated)");
877 }
878 Some(value)
879 }
880}
881
882fn to_http_record(request: &PreparedRequest) -> HttpRecord {
883 use aimux_core::recording::is_sensitive_key;
884 let headers = request
885 .headers
886 .iter()
887 .map(|(name, value)| {
888 (
889 name.clone(),
890 if is_sensitive_key(name) {
891 "[REDACTED]".into()
892 } else {
893 value.clone()
894 },
895 )
896 })
897 .collect();
898 let body = match &request.body {
899 HttpBody::Json(value) => {
900 serde_json::to_string(&crate::logging::redact_error_context(value.clone())).ok()
901 }
902 HttpBody::Bytes(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()),
903 HttpBody::Empty => None,
904 }
905 .map(|body| truncate_utf8(&body, RECORD_BODY_CAP).to_owned());
906 HttpRecord {
907 method: request.method.as_str().into(),
908 url: sanitized_request_url(&request.url),
909 headers,
910 body,
911 }
912}
913
914fn record_exchange(request: &PreparedRequest, exchange: HttpExchange) {
915 if let Some(context) = &request.recording_context {
916 context
917 .recorder
918 .record_exchange(&context.call_id, &exchange);
919 }
920}
921
922fn record_transport_closed(request: &PreparedRequest) {
923 if let Some(context) = &request.recording_context {
924 context.recorder.record_transport_closed(&context.call_id);
925 }
926}
927
928fn record_failed_exchange(
929 request: &PreparedRequest,
930 attempt: u32,
931 exchange_index: u32,
932 latency_ms: u64,
933 error: &str,
934) {
935 record_exchange(
936 request,
937 HttpExchange {
938 step: request
939 .recording_context
940 .as_ref()
941 .and_then(RecordingContext::step),
942 attempt,
943 exchange_index,
944 request: to_http_record(request),
945 response: None,
946 timing: TimingRecord {
947 latency_ms,
948 ttfb_ms: None,
949 },
950 error: Some(error.to_owned()),
951 finalized: true,
952 },
953 );
954}
955
956fn redacted_response_headers(headers: &reqwest::header::HeaderMap) -> Vec<(String, String)> {
957 crate::extract_response_headers::extract_response_header_pairs(headers)
958}
959
960fn truncate_utf8(value: &str, maximum: usize) -> &str {
961 if value.len() <= maximum {
962 return value;
963 }
964 let mut end = maximum;
965 while end > 0 && !value.is_char_boundary(end) {
966 end -= 1;
967 }
968 &value[..end]
969}
970
971fn request_body_size(request: &PreparedRequest) -> u64 {
972 match &request.body {
973 HttpBody::Json(value) => serde_json::to_vec(value)
974 .map(|bytes| bytes.len() as u64)
975 .unwrap_or_default(),
976 HttpBody::Bytes(bytes, _) => bytes.len() as u64,
977 HttpBody::Empty => 0,
978 }
979}
980
981fn request_host(url: &str) -> String {
982 url::Url::parse(url)
983 .ok()
984 .and_then(|url| url.host_str().map(str::to_owned))
985 .unwrap_or_else(|| "unknown".into())
986}
987
988pub(crate) fn sanitized_request_url(url: &str) -> String {
989 match url::Url::parse(url) {
990 Ok(mut parsed) => {
991 parsed.set_query(None);
992 parsed.to_string()
993 }
994 Err(_) => url.split('?').next().unwrap_or(url).to_owned(),
995 }
996}