pub(crate) mod http;
pub(crate) mod validate_cache;
pub(crate) mod ws;
use crate::config::Provider;
use crate::error::{NetworkKind, PipelineFailure, PipelineFailureKind, TimerLabel};
use crate::telemetry::{TelemetrySink, TranscriptionEvent};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
pub const CONNECTION_BUDGETS_SECS: [u64; 7] = [2, 5, 8, 11, 15, 30, 120];
pub const DATA_BACKOFF_SECS: [u64; 6] = [5, 15, 30, 60, 120, 120];
const RETRY_AFTER_CAP_SECS: u64 = 120;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Post,
}
pub enum RequestBody {
Empty,
Bytes(std::sync::Arc<Vec<u8>>),
Multipart(Box<dyn Fn() -> reqwest::multipart::Form + Send + Sync>),
}
pub struct Request {
pub method: Method,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: RequestBody,
pub provider: Provider,
pub provider_name: String,
pub phase: crate::error::PipelinePhase,
pub wall_clock: Option<Duration>,
}
#[derive(Debug)]
pub struct Response {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryKind {
Connection,
Data,
}
#[derive(Debug, Clone)]
pub enum ConnectionEvent {
ResolvingDns { t: Instant },
Connecting { t: Instant },
TlsHandshake { t: Instant },
ConnectionReady { t: Instant },
Uploading {
bytes_sent: u64,
total: u64,
t: Instant,
},
UploadComplete { total: u64, t: Instant },
AwaitingResponse { t: Instant },
ResponseHeaders { status: u16, t: Instant },
Downloading {
bytes_received: u64,
total: Option<u64>,
t: Instant,
},
ResponseComplete { total: u64, t: Instant },
RetryScheduled {
kind: RetryKind,
attempt: u32,
max: u32,
budget: Duration,
reason: String,
t: Instant,
},
Completed { t: Instant },
Failed {
error: Arc<PipelineFailure>,
t: Instant,
},
}
pub async fn http_request(
req: Request,
sink: &Arc<dyn TelemetrySink>,
cancel: CancellationToken,
) -> Result<Response, PipelineFailure> {
let max_data_attempts = DATA_BACKOFF_SECS.len() as u32 + 1;
sink.emit(TranscriptionEvent::RequestStarted {
endpoint: req.url.clone(),
t: Instant::now(),
});
let mut data_attempt: u32 = 1;
loop {
let outcome =
run_connection_phase(&req, sink, &cancel, data_attempt, max_data_attempts).await;
let (pf, retry_after) = match outcome {
ConnectionPhase::Done(result) => {
sink.emit(TranscriptionEvent::RequestCompleted {
success: result.is_ok(),
t: Instant::now(),
});
return result;
}
ConnectionPhase::DataRetryable {
failure,
retry_after,
} => (failure, retry_after),
};
let retry_index = data_attempt as usize - 1;
let Some(&slot_secs) = DATA_BACKOFF_SECS.get(retry_index) else {
sink.emit(TranscriptionEvent::RequestCompleted {
success: false,
t: Instant::now(),
});
return Err(pf);
};
let wait = retry_after
.map(|d| d.min(Duration::from_secs(RETRY_AFTER_CAP_SECS)))
.unwrap_or_else(|| Duration::from_secs(slot_secs));
let retry_num = data_attempt;
let reason = pf.to_string();
log::info!(
"{} busy — retrying in {}s (server retry {}/{}): {}",
req.provider_name,
wait.as_secs(),
retry_num,
DATA_BACKOFF_SECS.len(),
reason,
);
sink.emit(TranscriptionEvent::RetryScheduled {
kind: crate::telemetry::RetryKind::Data,
attempt: retry_num,
max: DATA_BACKOFF_SECS.len() as u32,
reason,
delay: wait,
t: Instant::now(),
});
tokio::select! {
biased;
_ = cancel.cancelled() => {
sink.emit(TranscriptionEvent::RequestCompleted {
success: false,
t: Instant::now(),
});
return Err(build_cancellation_failure(&req, data_attempt, max_data_attempts));
}
_ = tokio::time::sleep(wait) => {}
}
data_attempt += 1;
}
}
enum ConnectionPhase {
Done(Result<Response, PipelineFailure>),
DataRetryable {
failure: PipelineFailure,
retry_after: Option<Duration>,
},
}
async fn run_connection_phase(
req: &Request,
sink: &Arc<dyn TelemetrySink>,
cancel: &CancellationToken,
data_attempt: u32,
max_data_attempts: u32,
) -> ConnectionPhase {
let max_connection_attempts = CONNECTION_BUDGETS_SECS.len() as u32;
let mut last_failure: Option<PipelineFailure> = None;
for (idx, &budget_secs) in CONNECTION_BUDGETS_SECS.iter().enumerate() {
let connect_budget = Duration::from_secs(budget_secs);
let attempt_num = (idx as u32) + 1;
if idx > 0 {
let reason = last_failure
.as_ref()
.map(|f| f.to_string())
.unwrap_or_else(|| "connection failed".into());
sink.emit(TranscriptionEvent::RetryScheduled {
kind: crate::telemetry::RetryKind::Connection,
attempt: idx as u32,
max: max_connection_attempts.saturating_sub(1),
reason,
delay: Duration::ZERO,
t: Instant::now(),
});
}
if cancel.is_cancelled() {
return ConnectionPhase::Done(Err(build_cancellation_failure(
req,
attempt_num,
max_connection_attempts,
)));
}
let attempt_outcome = run_single_http_attempt(
req,
connect_budget,
sink,
cancel,
attempt_num,
max_connection_attempts,
data_attempt,
max_data_attempts,
)
.await;
match attempt_outcome {
SingleAttempt::Success(resp) => return ConnectionPhase::Done(Ok(resp)),
SingleAttempt::Cancelled(pf) | SingleAttempt::Permanent(pf) => {
return ConnectionPhase::Done(Err(pf));
}
SingleAttempt::ConnectionRetryable(pf) => {
last_failure = Some(pf);
continue;
}
SingleAttempt::DataRetryable {
failure,
retry_after,
} => {
return ConnectionPhase::DataRetryable {
failure,
retry_after,
};
}
}
}
ConnectionPhase::Done(Err(last_failure.unwrap_or_else(|| {
build_generic_exhausted_failure(req, max_connection_attempts)
})))
}
fn attempt_cap(connect_budget: Duration, wall_clock: Option<Duration>) -> Duration {
match wall_clock {
Some(wc) if wc > connect_budget => wc,
_ => connect_budget,
}
}
fn is_data_retryable_status(status: u16) -> bool {
status == 429 || (500..600).contains(&status)
}
fn parse_retry_after_secs(headers: &[(String, String)]) -> Option<Duration> {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("retry-after"))
.and_then(|(_, value)| value.trim().parse::<u64>().ok())
.map(Duration::from_secs)
}
enum SingleAttempt {
Success(Response),
Cancelled(PipelineFailure),
ConnectionRetryable(PipelineFailure),
DataRetryable {
failure: PipelineFailure,
retry_after: Option<Duration>,
},
Permanent(PipelineFailure),
}
#[allow(clippy::too_many_arguments)] async fn run_single_http_attempt(
req: &Request,
connect_budget: Duration,
sink: &Arc<dyn TelemetrySink>,
cancel: &CancellationToken,
attempt_num: u32,
max_attempts: u32,
data_attempt: u32,
max_data_attempts: u32,
) -> SingleAttempt {
let client = match build_client_with_connect_timeout(connect_budget) {
Ok(c) => c,
Err(e) => {
return SingleAttempt::Permanent(PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::Decode(format!("transport client build failed: {}", e)),
));
}
};
let mut request_builder = match req.method {
Method::Get => client.get(&req.url),
Method::Post => client.post(&req.url),
};
for (name, value) in &req.headers {
request_builder = request_builder.header(name, value);
}
match &req.body {
RequestBody::Empty => {}
RequestBody::Bytes(bytes) => {
request_builder = request_builder.body(bytes.as_ref().clone());
}
RequestBody::Multipart(factory) => {
let form = factory();
request_builder = request_builder.multipart(form);
}
}
if let Some(budget) = req.wall_clock {
request_builder = request_builder.timeout(budget);
}
sink.emit(TranscriptionEvent::ConnectionEstablished { t: Instant::now() });
let send_fut = async {
let response = request_builder.send().await?;
let status = response.status();
sink.emit(TranscriptionEvent::ResponseHeaders {
status: status.as_u16(),
t: Instant::now(),
});
let headers: Vec<(String, String)> = response
.headers()
.iter()
.map(|(n, v)| {
(
n.as_str().to_string(),
v.to_str().unwrap_or_default().to_string(),
)
})
.collect();
let body = response.bytes().await?;
Ok::<_, reqwest::Error>(Response {
status: status.as_u16(),
headers,
body: body.to_vec(),
})
};
let cap = attempt_cap(connect_budget, req.wall_clock);
let send_with_cap = tokio::time::timeout(cap, send_fut);
let outcome = tokio::select! {
biased;
_ = cancel.cancelled() => {
return SingleAttempt::Cancelled(build_cancellation_failure(
req, attempt_num, max_attempts,
));
}
result = send_with_cap => result,
};
let response = match outcome {
Err(_elapsed) => {
let pf = PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::Network {
kind: NetworkKind::Connect,
timer: Some(TimerLabel::from_duration("connect_timeout", cap)),
source: Box::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"connection attempt did not complete \
within {}s",
cap.as_secs()
),
)),
},
);
return SingleAttempt::ConnectionRetryable(pf);
}
Ok(Ok(r)) => r,
Ok(Err(err)) => {
let is_connect_class = err.is_connect() || err.is_timeout() || matches_kernel_tcp(&err);
let timers = [http::TimerSpec {
name: "connect_timeout",
budget: connect_budget,
}];
let kind = http::build_pipeline_failure_kind(err, &timers);
let pf = PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
kind,
);
if is_connect_class {
return SingleAttempt::ConnectionRetryable(pf);
}
return SingleAttempt::Permanent(pf);
}
};
sink.emit(TranscriptionEvent::ResponseComplete {
total: response.body.len() as u64,
t: Instant::now(),
});
if (200..300).contains(&response.status) {
SingleAttempt::Success(response)
} else if is_data_retryable_status(response.status) {
let retry_after = parse_retry_after_secs(&response.headers);
SingleAttempt::DataRetryable {
failure: PipelineFailure::new(
req.provider_name.clone(),
req.phase,
data_attempt,
max_data_attempts,
req.url.clone(),
PipelineFailureKind::HttpStatus {
status: response.status,
body: String::from_utf8_lossy(&response.body).into_owned(),
},
),
retry_after,
}
} else {
SingleAttempt::Permanent(PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::HttpStatus {
status: response.status,
body: String::from_utf8_lossy(&response.body).into_owned(),
},
))
}
}
fn build_client_with_connect_timeout(connect_timeout: Duration) -> Result<reqwest::Client, String> {
let builder = reqwest::Client::builder()
.connect_timeout(connect_timeout)
.tcp_keepalive(Duration::from_secs(5))
.tcp_keepalive_interval(Duration::from_secs(1))
.tcp_keepalive_retries(3);
#[cfg(target_os = "linux")]
let builder = builder.tcp_user_timeout(Duration::from_secs(3));
builder.build().map_err(|e| e.to_string())
}
fn matches_kernel_tcp(err: &reqwest::Error) -> bool {
use std::error::Error as _;
let mut current: Option<&dyn std::error::Error> = err.source();
while let Some(e) = current {
if let Some(io) = e.downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::TimedOut {
return true;
}
}
current = e.source();
}
false
}
fn build_cancellation_failure(
req: &Request,
attempt_num: u32,
max_attempts: u32,
) -> PipelineFailure {
PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::Network {
kind: NetworkKind::Other,
timer: Some(TimerLabel {
name: "cancelled".to_string(),
budget: "0s".to_string(),
}),
source: Box::new(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"cancelled by caller",
)),
},
)
}
fn build_generic_exhausted_failure(req: &Request, max_attempts: u32) -> PipelineFailure {
PipelineFailure::new(
req.provider_name.clone(),
req.phase,
max_attempts,
max_attempts,
req.url.clone(),
PipelineFailureKind::Decode(format!(
"transport: all {} connection attempts exhausted \
with no recorded cause",
max_attempts
)),
)
}
pub async fn ws_upgrade(
req: Request,
sink: &Arc<dyn TelemetrySink>,
cancel: CancellationToken,
) -> Result<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
PipelineFailure,
> {
let max_connection_attempts = CONNECTION_BUDGETS_SECS.len() as u32;
let mut last_failure: Option<PipelineFailure> = None;
if !matches!(req.body, RequestBody::Empty) {
return Err(PipelineFailure::new(
req.provider_name.clone(),
req.phase,
1,
1,
req.url.clone(),
PipelineFailureKind::Decode(
"transport::ws_upgrade: only RequestBody::Empty is supported \
(a WebSocket upgrade handshake carries no body)"
.into(),
),
));
}
sink.emit(TranscriptionEvent::RequestStarted {
endpoint: req.url.clone(),
t: Instant::now(),
});
for (idx, &budget_secs) in CONNECTION_BUDGETS_SECS.iter().enumerate() {
let connect_budget = Duration::from_secs(budget_secs);
let attempt_num = (idx as u32) + 1;
if idx > 0 {
let reason = last_failure
.as_ref()
.map(|f| f.to_string())
.unwrap_or_else(|| "ws connection failed".into());
sink.emit(TranscriptionEvent::RetryScheduled {
kind: crate::telemetry::RetryKind::Connection,
attempt: idx as u32,
max: max_connection_attempts.saturating_sub(1),
reason,
delay: Duration::ZERO,
t: Instant::now(),
});
}
if cancel.is_cancelled() {
let pf = build_cancellation_failure(&req, attempt_num, max_connection_attempts);
sink.emit(TranscriptionEvent::RequestCompleted {
success: false,
t: Instant::now(),
});
return Err(pf);
}
match run_single_ws_attempt(
&req,
connect_budget,
&cancel,
attempt_num,
max_connection_attempts,
)
.await
{
WsAttempt::Success(stream) => {
sink.emit(TranscriptionEvent::ConnectionEstablished { t: Instant::now() });
sink.emit(TranscriptionEvent::RequestCompleted {
success: true,
t: Instant::now(),
});
return Ok(stream);
}
WsAttempt::Cancelled(pf) => {
sink.emit(TranscriptionEvent::RequestCompleted {
success: false,
t: Instant::now(),
});
return Err(pf);
}
WsAttempt::ConnectionRetryable(pf) => {
last_failure = Some(pf);
continue;
}
WsAttempt::Permanent(pf) => {
sink.emit(TranscriptionEvent::RequestCompleted {
success: false,
t: Instant::now(),
});
return Err(pf);
}
}
}
sink.emit(TranscriptionEvent::RequestCompleted {
success: false,
t: Instant::now(),
});
Err(last_failure
.unwrap_or_else(|| build_generic_exhausted_failure(&req, max_connection_attempts)))
}
enum WsAttempt {
Success(
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
),
Cancelled(PipelineFailure),
ConnectionRetryable(PipelineFailure),
Permanent(PipelineFailure),
}
async fn run_single_ws_attempt(
req: &Request,
connect_budget: Duration,
cancel: &CancellationToken,
attempt_num: u32,
max_attempts: u32,
) -> WsAttempt {
let parsed = match url::Url::parse(&req.url) {
Ok(u) => u,
Err(e) => {
return WsAttempt::Permanent(PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::Decode(format!("invalid WebSocket URL: {}", e)),
));
}
};
let host = match parsed.host_str() {
Some(h) => h.to_string(),
None => {
return WsAttempt::Permanent(PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::Decode("WebSocket URL is missing a host component".into()),
));
}
};
let mut builder = tokio_tungstenite::tungstenite::http::Request::builder()
.uri(&req.url)
.header("Host", &host)
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header(
"Sec-WebSocket-Key",
tokio_tungstenite::tungstenite::handshake::client::generate_key(),
);
for (name, value) in &req.headers {
builder = builder.header(name, value);
}
let request = match builder.body(()) {
Ok(r) => r,
Err(e) => {
return WsAttempt::Permanent(PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::Decode(format!("WebSocket request build failed: {}", e)),
));
}
};
let connect_fut = tokio_tungstenite::connect_async(request);
let capped = tokio::time::timeout(connect_budget, connect_fut);
let outcome = tokio::select! {
biased;
_ = cancel.cancelled() => {
return WsAttempt::Cancelled(build_cancellation_failure(
req, attempt_num, max_attempts,
));
}
result = capped => result,
};
match outcome {
Err(_elapsed) => WsAttempt::ConnectionRetryable(PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::Network {
kind: NetworkKind::Connect,
timer: Some(TimerLabel::from_duration("connect_timeout", connect_budget)),
source: Box::new(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"WebSocket upgrade did not complete within {}s",
connect_budget.as_secs()
),
)),
},
)),
Ok(Ok((stream, _response))) => WsAttempt::Success(stream),
Ok(Err(err)) => {
let is_connect_class = matches!(
err,
tokio_tungstenite::tungstenite::Error::Io(_)
| tokio_tungstenite::tungstenite::Error::Tls(_)
| tokio_tungstenite::tungstenite::Error::ConnectionClosed
| tokio_tungstenite::tungstenite::Error::AlreadyClosed
);
let pf = PipelineFailure::new(
req.provider_name.clone(),
req.phase,
attempt_num,
max_attempts,
req.url.clone(),
PipelineFailureKind::Network {
kind: if is_connect_class {
NetworkKind::Connect
} else {
NetworkKind::Other
},
timer: Some(TimerLabel::from_duration("connect_timeout", connect_budget)),
source: Box::new(std::io::Error::other(err.to_string())),
},
);
if is_connect_class {
WsAttempt::ConnectionRetryable(pf)
} else {
WsAttempt::Permanent(pf)
}
}
}
}