helix_driver_host/network/
http.rs1use bytes::Bytes;
2use helix_core::effect::{HttpRequest, HttpResponse};
3use helix_core::ports::HttpRequester;
4use helix_core::PortError;
5use reqwest::header::USER_AGENT;
6use reqwest::{Client, Method};
7use std::collections::BTreeMap;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::Arc;
10use std::time::Instant;
11
12use crate::metrics::{
13 AsyncMetricSink, LabelKey, MetricEvent, MetricId, MetricLabels, NoopMetricSink,
14};
15
16use super::network_util::{
17 header_name, header_value, headers_to_strings, resolve_http_url, validate_url,
18};
19use super::{HostHeaderRegistry, HostNetworkConfig};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct PreparedHttpRequest {
24 pub method: String,
25 pub url: String,
26 pub headers: BTreeMap<String, String>,
27 pub body: Option<Bytes>,
28}
29
30#[derive(Clone)]
32pub struct SharedHttpClient {
33 client: Client,
34 config: HostNetworkConfig,
35 headers: HostHeaderRegistry,
36 metrics: Arc<dyn AsyncMetricSink>,
37 inflight: Arc<AtomicUsize>,
38}
39
40impl SharedHttpClient {
41 pub fn new(config: HostNetworkConfig) -> Result<Self, PortError> {
42 Self::with_registry(config, HostHeaderRegistry::default())
43 }
44
45 pub fn with_registry(
46 config: HostNetworkConfig,
47 headers: HostHeaderRegistry,
48 ) -> Result<Self, PortError> {
49 validate_url(&config.api_base_url, "api_base_url")?;
50 if !config.default_api_base_url.is_empty() {
51 validate_url(&config.default_api_base_url, "default_api_base_url")?;
52 }
53 let mut builder = Client::builder().timeout(config.timeout);
54 if let Some(user_agent) = &config.user_agent {
55 builder = builder.user_agent(user_agent);
56 }
57 let client = builder
58 .build()
59 .map_err(|e| PortError::Http(format!("reqwest client build error: {e}")))?;
60 Ok(Self {
61 client,
62 config,
63 headers,
64 metrics: Arc::new(NoopMetricSink),
65 inflight: Arc::new(AtomicUsize::new(0)),
66 })
67 }
68
69 pub fn headers(&self) -> HostHeaderRegistry {
70 self.headers.clone()
71 }
72
73 pub fn with_metric_sink(mut self, metrics: Arc<dyn AsyncMetricSink>) -> Self {
74 self.metrics = metrics;
75 self
76 }
77
78 pub async fn prepare_for_test(
79 &self,
80 req: HttpRequest,
81 ) -> Result<PreparedHttpRequest, PortError> {
82 self.prepare(req).await
83 }
84
85 async fn prepare(&self, req: HttpRequest) -> Result<PreparedHttpRequest, PortError> {
86 let method = Method::from_bytes(req.method.as_bytes())
87 .map_err(|e| PortError::Http(format!("invalid method '{}': {e}", req.method)))?;
88 let url = resolve_http_url(&self.config.api_base_url, &req.url)?;
89 let mut headers = self.headers.snapshot().await;
90 if let Some(user_agent) = &self.config.user_agent {
91 headers.insert(USER_AGENT, header_value("user-agent", user_agent)?);
92 }
93 for (name, value) in &req.headers {
94 let name = header_name(name)?;
95 let value = header_value(name.as_str(), value)?;
96 headers.insert(name, value);
97 }
98 Ok(PreparedHttpRequest {
99 method: method.as_str().to_string(),
100 url,
101 headers: headers_to_strings(&headers),
102 body: req.body,
103 })
104 }
105
106 fn classify_error(e: &reqwest::Error) -> PortError {
107 if e.is_timeout() {
108 PortError::Transport(format!("timeout: {e}"))
109 } else if e.is_connect() || e.is_request() {
110 PortError::Transport(format!("network: {e}"))
111 } else {
112 PortError::Http(e.to_string())
113 }
114 }
115
116 async fn execute(&self, req: HttpRequest) -> Result<HttpResponse, PortError> {
117 let prepared = self.prepare(req).await?;
118 crate::network_debug::dump_http_request(&prepared);
119 let method = Method::from_bytes(prepared.method.as_bytes())
120 .map_err(|e| PortError::Http(format!("invalid method '{}': {e}", prepared.method)))?;
121 let mut builder = self.client.request(method, &prepared.url);
122 for (name, value) in &prepared.headers {
123 builder = builder.header(name, value);
124 }
125 if let Some(body) = prepared.body {
126 builder = builder.body(body);
127 }
128 let resp = builder.send().await.map_err(|e| Self::classify_error(&e))?;
129 let status = resp.status().as_u16();
130 let headers = resp
131 .headers()
132 .iter()
133 .map(|(k, v)| {
134 (
135 k.as_str().to_string(),
136 v.to_str().unwrap_or("<non-utf8>").to_string(),
137 )
138 })
139 .collect::<Vec<_>>();
140 let body = resp.bytes().await.map_err(|e| Self::classify_error(&e))?;
141 crate::network_debug::dump_http_response(status, &headers, &body);
142 Ok(HttpResponse {
143 status,
144 headers,
145 body,
146 })
147 }
148}
149
150#[async_trait::async_trait]
151impl HttpRequester for SharedHttpClient {
152 async fn request(&self, req: HttpRequest) -> Result<HttpResponse, PortError> {
153 if !self.metrics.is_enabled() {
154 return self.execute(req).await;
155 }
156 let request_bytes = req.body.as_ref().map_or(0, Bytes::len);
157 let started = Instant::now();
158 let inflight = self.inflight.fetch_add(1, Ordering::Relaxed) + 1;
159 record_http_gauge(self.metrics.as_ref(), inflight);
160 let result = self.execute(req).await;
161 let remaining = self
162 .inflight
163 .fetch_sub(1, Ordering::Relaxed)
164 .saturating_sub(1);
165 record_http_gauge(self.metrics.as_ref(), remaining);
166 let failed = match &result {
167 Ok(response) => response.status >= 400,
168 Err(_) => true,
169 };
170 let status = if failed { "error" } else { "ok" };
171 let labels = MetricLabels::one(LabelKey::Stage, "http")
172 .with(LabelKey::Protocol, "http")
173 .with(LabelKey::Operation, "request")
174 .with(LabelKey::Status, status);
175 let _ = self.metrics.try_record(MetricEvent::histogram(
176 MetricId::HttpRequestDurationSeconds,
177 started.elapsed().as_secs_f64(),
178 labels,
179 ));
180 let _ = self.metrics.try_record(MetricEvent::histogram(
181 MetricId::HttpRequestBytes,
182 request_bytes as f64,
183 labels,
184 ));
185 if let Ok(response) = &result {
186 let status_labels = labels.with(LabelKey::StatusClass, status_class(response.status));
187 let _ = self.metrics.try_record(MetricEvent::histogram(
188 MetricId::HttpResponseBytes,
189 response.body.len() as f64,
190 status_labels,
191 ));
192 let _ = self.metrics.try_record(MetricEvent::counter(
193 MetricId::HttpStatusTotal,
194 1.0,
195 status_labels,
196 ));
197 }
198 let _ =
199 self.metrics
200 .try_record(MetricEvent::counter(MetricId::OperationsTotal, 1.0, labels));
201 if failed {
202 if result
203 .as_ref()
204 .err()
205 .is_some_and(|error| error.to_string().to_ascii_lowercase().contains("timeout"))
206 {
207 let _ = self.metrics.try_record(MetricEvent::counter(
208 MetricId::HttpTimeoutTotal,
209 1.0,
210 labels,
211 ));
212 }
213 let error_kind = if result.is_err() {
214 "http_request_failed"
215 } else {
216 "http_status_error"
217 };
218 let _ = self.metrics.try_record(MetricEvent::counter(
219 MetricId::ErrorsTotal,
220 1.0,
221 labels.with(LabelKey::ErrorKind, error_kind),
222 ));
223 }
224 result
225 }
226}
227
228fn status_class(status: u16) -> &'static str {
230 match status {
231 100..=199 => "1xx",
232 200..=299 => "2xx",
233 300..=399 => "3xx",
234 400..=499 => "4xx",
235 500..=599 => "5xx",
236 _ => "other",
237 }
238}
239
240fn record_http_gauge(metrics: &dyn AsyncMetricSink, inflight: usize) {
242 let _ = metrics.try_record(MetricEvent::gauge(
243 MetricId::HttpInflight,
244 inflight as f64,
245 MetricLabels::one(LabelKey::Stage, "http"),
246 ));
247}