mod circuit;
#[cfg(test)]
mod tests;
use std::time::Duration;
use circuit::{CircuitBreaker, CircuitState};
use reqwest::redirect;
use tracing::{debug, warn};
pub const DEPTH_HEADER: &str = "x-praxis-callout-depth";
const DEFAULT_MAX_DEPTH: u32 = 1;
const DEFAULT_TIMEOUT_MS: u64 = 5_000;
const DEFAULT_STATUS_ON_ERROR: u16 = 403;
const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureMode {
Closed,
Open,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rejection {
pub status: u16,
}
#[derive(Debug)]
pub enum CalloutResult {
Success(CalloutResponse),
Failed,
Rejected(Rejection),
}
#[derive(Debug)]
pub struct CalloutResponse {
pub body: Vec<u8>,
pub headers: Vec<(http::HeaderName, http::HeaderValue)>,
pub status: u16,
}
#[derive(Debug)]
pub struct CalloutRequest {
pub body: Option<Vec<u8>>,
pub depth: u32,
pub headers: Vec<(http::HeaderName, http::HeaderValue)>,
pub method: http::Method,
pub url: String,
}
#[derive(Debug, thiserror::Error)]
pub enum CalloutError {
#[error("callout config error: {0}")]
Config(String),
#[error("callout client build error: {0}")]
ClientBuild(#[from] reqwest::Error),
}
#[derive(Debug, Clone)]
pub struct CircuitBreakerConfig {
pub consecutive_failures: u32,
pub recovery_window_ms: u64,
}
#[derive(Debug, Clone)]
pub struct CalloutConfig {
pub circuit_breaker: Option<CircuitBreakerConfig>,
pub failure_mode: FailureMode,
pub max_depth: u32,
pub pool_max_idle_per_host: usize,
pub status_on_error: u16,
pub timeout_ms: u64,
}
impl Default for CalloutConfig {
fn default() -> Self {
Self {
circuit_breaker: None,
failure_mode: FailureMode::Closed,
max_depth: DEFAULT_MAX_DEPTH,
pool_max_idle_per_host: DEFAULT_POOL_MAX_IDLE_PER_HOST,
status_on_error: DEFAULT_STATUS_ON_ERROR,
timeout_ms: DEFAULT_TIMEOUT_MS,
}
}
}
#[derive(Debug)]
pub struct CalloutClient {
circuit_breaker: Option<CircuitBreaker>,
client: reqwest::Client,
failure_mode: FailureMode,
max_depth: u32,
status_on_error: u16,
timeout: Duration,
}
impl CalloutClient {
pub fn new(config: CalloutConfig) -> Result<Self, CalloutError> {
validate_config(&config)?;
let client = reqwest::Client::builder()
.no_proxy()
.redirect(redirect::Policy::none())
.pool_max_idle_per_host(config.pool_max_idle_per_host)
.build()?;
let circuit_breaker = config
.circuit_breaker
.map(|cb| CircuitBreaker::new(cb.consecutive_failures, cb.recovery_window_ms));
Ok(Self {
circuit_breaker,
client,
failure_mode: config.failure_mode,
max_depth: config.max_depth,
status_on_error: config.status_on_error,
timeout: Duration::from_millis(config.timeout_ms),
})
}
pub async fn execute(&self, request: CalloutRequest) -> CalloutResult {
if let Some(result) = self.check_preconditions(&request) {
return result;
}
match self.send_request(request).await {
Ok(response) => self.process_response(response).await,
Err(reason) => {
warn!(reason, "callout request failed");
self.record_failure();
self.on_failure()
},
}
}
fn check_preconditions(&self, request: &CalloutRequest) -> Option<CalloutResult> {
if request.depth >= self.max_depth {
warn!(
depth = request.depth,
max_depth = self.max_depth,
"callout depth limit reached"
);
return Some(self.on_failure());
}
if let Some(cb) = &self.circuit_breaker {
match cb.check() {
CircuitState::Open => {
debug!("circuit breaker open; skipping callout");
return Some(self.on_failure());
},
CircuitState::HalfOpen => {
debug!("circuit breaker half-open; sending probe");
},
CircuitState::Closed => {},
}
}
None
}
async fn send_request(&self, request: CalloutRequest) -> Result<reqwest::Response, String> {
let mut builder = self.client.request(request.method, &request.url);
for (name, value) in request.headers {
builder = builder.header(name, value);
}
let next_depth = request.depth.saturating_add(1);
builder = builder.header(DEPTH_HEADER, next_depth.to_string());
if let Some(body) = request.body {
builder = builder.body(body);
}
tokio::time::timeout(self.timeout, builder.send())
.await
.map_err(|_elapsed| "request timed out".to_owned())?
.map_err(|e| e.to_string())
}
async fn process_response(&self, response: reqwest::Response) -> CalloutResult {
let status = response.status();
if !status.is_success() {
warn!(status = status.as_u16(), "callout received non-2xx response");
self.record_failure();
return self.on_failure();
}
let headers = extract_headers(&response);
let body = match response.bytes().await {
Ok(b) => b.to_vec(),
Err(err) => {
warn!(%err, "failed to read callout response body");
self.record_failure();
return self.on_failure();
},
};
self.record_success();
CalloutResult::Success(CalloutResponse {
body,
headers,
status: status.as_u16(),
})
}
fn on_failure(&self) -> CalloutResult {
match self.failure_mode {
FailureMode::Closed => CalloutResult::Rejected(Rejection {
status: self.status_on_error,
}),
FailureMode::Open => CalloutResult::Failed,
}
}
fn record_success(&self) {
if let Some(cb) = &self.circuit_breaker {
cb.record_success();
}
}
fn record_failure(&self) {
if let Some(cb) = &self.circuit_breaker {
cb.record_failure();
}
}
}
fn validate_config(config: &CalloutConfig) -> Result<(), CalloutError> {
if config.timeout_ms == 0 {
return Err(CalloutError::Config("timeout_ms must be greater than 0".into()));
}
if config.status_on_error < 100 || config.status_on_error > 599 {
return Err(CalloutError::Config(format!(
"status_on_error must be between 100 and 599, got {status}",
status = config.status_on_error,
)));
}
if let Some(cb) = &config.circuit_breaker {
if cb.consecutive_failures == 0 {
return Err(CalloutError::Config(
"circuit_breaker.consecutive_failures must be greater than 0".into(),
));
}
if cb.recovery_window_ms == 0 {
return Err(CalloutError::Config(
"circuit_breaker.recovery_window_ms must be greater than 0".into(),
));
}
}
Ok(())
}
fn extract_headers(response: &reqwest::Response) -> Vec<(http::HeaderName, http::HeaderValue)> {
response
.headers()
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.collect()
}