use std::collections::HashMap;
use std::env::{self, VarError};
use std::future::{Future, IntoFuture};
use std::pin::Pin;
use std::time::{Duration, Instant};
use reqwest::redirect::Policy;
use reqwest::{Client, Method};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::sync::LazyLock;
use tokio::time;
use tracing::{debug, warn};
use url::Url;
use crate::retry::RetryPolicy;
use crate::ssrf::{self, AllowedHosts, GuardedResolver};
use crate::trace_context::WorkflowTraceContext;
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
use crate::error::OperationError;
#[cfg(feature = "prometheus")]
use crate::metric_names;
use crate::utils::MAX_OUTPUT_SIZE;
const ALLOWED_HOSTS_ENV: &str = "IRONFLOW_HTTP_ALLOWED_HOSTS";
static ENV_ALLOWED_HOSTS: LazyLock<AllowedHosts> =
LazyLock::new(|| match env::var(ALLOWED_HOSTS_ENV) {
Ok(list) => AllowedHosts::parse_list(&list),
Err(VarError::NotPresent) => AllowedHosts::default(),
Err(err) => {
warn!(error = %err, "{ALLOWED_HOSTS_ENV} ignored: no internal host is allowed");
AllowedHosts::default()
}
});
static HTTP_CLIENT: LazyLock<Client> = LazyLock::new(|| {
Client::builder()
.redirect(Policy::none())
.build()
.expect("failed to build HTTP client")
});
static GUARDED_HTTP_CLIENT: LazyLock<Client> = LazyLock::new(|| {
Client::builder()
.redirect(Policy::none())
.no_proxy()
.dns_resolver(GuardedResolver::default())
.build()
.expect("failed to build HTTP client")
});
#[must_use = "an Http request does nothing until .run() or .await is called"]
pub struct Http {
method: Method,
url: String,
headers: HashMap<String, String>,
body: Option<HttpBody>,
timeout: Option<Duration>,
max_response_size: usize,
dry_run: Option<bool>,
retry_policy: Option<RetryPolicy>,
allowed_hosts: AllowedHosts,
}
enum HttpBody {
Text(String),
Json(Value),
}
impl Http {
pub fn new(method: Method, url: &str) -> Self {
let trimmed = url.trim();
assert!(!trimmed.is_empty(), "url must not be empty");
assert!(
trimmed.starts_with("http://") || trimmed.starts_with("https://"),
"url must use http:// or https:// scheme, got: {trimmed}"
);
Self {
method,
url: trimmed.to_string(),
headers: HashMap::new(),
body: None,
timeout: Some(DEFAULT_HTTP_TIMEOUT),
max_response_size: MAX_OUTPUT_SIZE,
dry_run: None,
retry_policy: None,
allowed_hosts: AllowedHosts::default(),
}
}
pub fn get(url: &str) -> Self {
Self::new(Method::GET, url)
}
pub fn post(url: &str) -> Self {
Self::new(Method::POST, url)
}
pub fn put(url: &str) -> Self {
Self::new(Method::PUT, url)
}
pub fn patch(url: &str) -> Self {
Self::new(Method::PATCH, url)
}
pub fn delete(url: &str) -> Self {
Self::new(Method::DELETE, url)
}
pub fn header(mut self, key: &str, value: &str) -> Self {
self.headers.insert(key.to_string(), value.to_string());
self
}
pub fn json(mut self, value: Value) -> Self {
self.body = Some(HttpBody::Json(value));
self
}
pub fn text(mut self, body: &str) -> Self {
self.body = Some(HttpBody::Text(body.to_string()));
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn allow_host(mut self, host: &str) -> Self {
self.allowed_hosts.add(host);
self
}
pub fn max_response_size(mut self, bytes: usize) -> Self {
self.max_response_size = bytes;
self
}
pub fn retry(mut self, max_retries: u32) -> Self {
self.retry_policy = Some(RetryPolicy::new(max_retries));
self
}
pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = Some(policy);
self
}
pub fn trace_context(self, ctx: &WorkflowTraceContext) -> Self {
self.header("traceparent", &ctx.to_traceparent())
}
pub fn dry_run(mut self, enabled: bool) -> Self {
self.dry_run = Some(enabled);
self
}
#[tracing::instrument(name = "http", skip_all, fields(method = %self.method, url = %self.url))]
pub async fn run(self) -> Result<HttpOutput, OperationError> {
if crate::dry_run::effective_dry_run(self.dry_run) {
debug!(method = %self.method, url = %self.url, "[dry-run] http request skipped");
return Ok(HttpOutput {
status: 200,
headers: HashMap::new(),
body: String::new(),
duration_ms: 0,
});
}
let url = Url::parse(&self.url).map_err(|e| OperationError::Http {
status: None,
message: format!("invalid URL {}: {e}", self.url),
})?;
let client = if self.allowed_hosts.contains_url_host(&url)
|| ENV_ALLOWED_HOSTS.contains_url_host(&url)
{
&*HTTP_CLIENT
} else {
ssrf::check_url(&url)
.await
.map_err(|blocked| OperationError::Http {
status: None,
message: blocked.to_string(),
})?;
&*GUARDED_HTTP_CLIENT
};
let result = self.execute_once(client).await;
let policy = match &self.retry_policy {
Some(p) => p,
None => return result,
};
match &result {
Ok(output) if !crate::retry::is_retryable_status(output.status) => return result,
Err(err) if !crate::retry::is_retryable(err) => return result,
_ => {}
}
let mut last_result = result;
for attempt in 0..policy.max_retries {
let delay = policy.delay_for_attempt(attempt);
warn!(
attempt = attempt + 1,
max_retries = policy.max_retries,
delay_ms = delay.as_millis() as u64,
"retrying http request"
);
time::sleep(delay).await;
last_result = self.execute_once(client).await;
match &last_result {
Ok(output) if !crate::retry::is_retryable_status(output.status) => {
return last_result;
}
Err(err) if !crate::retry::is_retryable(err) => return last_result,
_ => {}
}
}
last_result
}
async fn execute_once(&self, client: &Client) -> Result<HttpOutput, OperationError> {
debug!(method = %self.method, url = %self.url, "executing http request");
let start = Instant::now();
#[cfg(feature = "prometheus")]
let method_label = self.method.to_string();
let mut builder = client.request(self.method.clone(), &self.url);
if let Some(timeout) = self.timeout {
builder = builder.timeout(timeout);
}
for (k, v) in &self.headers {
builder = builder.header(k.as_str(), v.as_str());
}
match &self.body {
Some(HttpBody::Json(v)) => {
builder = builder.json(v);
}
Some(HttpBody::Text(t)) => {
builder = builder.body(t.clone());
}
None => {}
}
let response = match builder.send().await {
Ok(resp) => resp,
Err(e) => {
#[cfg(feature = "prometheus")]
{
metrics::counter!(metric_names::HTTP_TOTAL, "method" => method_label, "status" => metric_names::STATUS_ERROR).increment(1);
}
return Err(OperationError::Http {
status: None,
message: match ssrf::find_blocked(&e) {
Some(blocked) => blocked.to_string(),
None => format!("request failed: {e}"),
},
});
}
};
let status = response.status().as_u16();
let headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| {
let val = match v.to_str() {
Ok(s) => s.to_string(),
Err(_) => {
debug!(header = %k, "non-UTF-8 header value, replacing with empty string");
String::new()
}
};
(k.to_string(), val)
})
.collect();
let max_response_size = self.max_response_size;
let response_too_large = |size: usize, limit: usize| OperationError::Http {
status: Some(status),
message: format!(
"response body too large: {size} bytes exceeds limit of {limit} bytes"
),
};
if let Some(cl) = response.content_length() {
let content_length = usize::try_from(cl).unwrap_or(usize::MAX);
if content_length > max_response_size {
return Err(response_too_large(content_length, max_response_size));
}
}
let mut body_bytes = Vec::new();
let mut response = response;
loop {
match response.chunk().await {
Ok(Some(chunk)) => {
if body_bytes.len() + chunk.len() > max_response_size {
return Err(response_too_large(
body_bytes.len() + chunk.len(),
max_response_size,
));
}
body_bytes.extend_from_slice(&chunk);
}
Ok(None) => break,
Err(e) => {
return Err(OperationError::Http {
status: Some(status),
message: format!("failed to read response body: {e}"),
});
}
}
}
let body = String::from_utf8_lossy(&body_bytes).into_owned();
let duration_ms = start.elapsed().as_millis() as u64;
debug!(
status,
body_len = body.len(),
duration_ms,
"http request completed"
);
#[cfg(feature = "prometheus")]
{
let status_label = status.to_string();
metrics::counter!(metric_names::HTTP_TOTAL, "method" => method_label, "status" => status_label).increment(1);
metrics::histogram!(metric_names::HTTP_DURATION_SECONDS)
.record(duration_ms as f64 / 1000.0);
}
Ok(HttpOutput {
status,
headers,
body,
duration_ms,
})
}
}
impl IntoFuture for Http {
type Output = Result<HttpOutput, OperationError>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.run())
}
}
#[derive(Debug)]
pub struct HttpOutput {
status: u16,
headers: HashMap<String, String>,
body: String,
duration_ms: u64,
}
impl HttpOutput {
pub fn status(&self) -> u16 {
self.status
}
pub fn headers(&self) -> &HashMap<String, String> {
&self.headers
}
pub fn body(&self) -> &str {
&self.body
}
pub fn json<T: DeserializeOwned>(&self) -> Result<T, OperationError> {
serde_json::from_str(&self.body).map_err(OperationError::deserialize::<T>)
}
pub fn duration_ms(&self) -> u64 {
self.duration_ms
}
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
}
#[cfg(test)]
mod tests;