Kill reqwest — minimal HTTP client on raw hyper + tokio-rustls

New src/agent/api/http.rs: ~240 lines, supports GET/POST, JSON/form
bodies, SSE streaming via chunk(), TLS via rustls. No tracing dep.

Removes reqwest from the main crate and telegram channel crate.
Cargo.lock drops ~900 lines of transitive dependencies.

tracing now only pulled in by tui-markdown.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-07 12:50:40 -04:00
parent a421c3c9f3
commit 1cf4f504c0
9 changed files with 360 additions and 915 deletions

239
src/agent/api/http.rs Normal file
View file

@ -0,0 +1,239 @@
// http.rs — Minimal async HTTP client
//
// Replaces reqwest with direct hyper + rustls. No tracing dependency.
// Supports: GET/POST, JSON/form bodies, streaming responses, TLS.
use anyhow::{Context, Result};
use bytes::Bytes;
use http_body_util::{BodyExt, Full, Empty};
use hyper::body::Incoming;
use hyper::{Request, StatusCode};
use hyper_util::rt::TokioIo;
use rustls::ClientConfig;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpStream;
/// Lightweight async HTTP client with connection pooling via keep-alive.
#[derive(Clone)]
pub struct HttpClient {
tls: Arc<ClientConfig>,
connect_timeout: Duration,
request_timeout: Duration,
}
/// An in-flight response — provides status, headers, and body access.
pub struct HttpResponse {
parts: http::response::Parts,
body: Incoming,
}
impl HttpClient {
pub fn new() -> Self {
Self::builder().build()
}
pub fn builder() -> HttpClientBuilder {
HttpClientBuilder {
connect_timeout: Duration::from_secs(30),
request_timeout: Duration::from_secs(600),
}
}
/// Send a GET request.
pub async fn get(&self, url: &str) -> Result<HttpResponse> {
self.get_with_headers(url, &[]).await
}
/// Send a GET request with custom headers.
pub async fn get_with_headers(&self, url: &str, headers: &[(&str, &str)]) -> Result<HttpResponse> {
let mut builder = Request::get(url);
for &(k, v) in headers {
builder = builder.header(k, v);
}
let req = builder.body(Empty::<Bytes>::new())
.context("building GET request")?;
self.send_empty(req).await
}
/// Send a POST request with a JSON body.
pub async fn post_json(&self, url: &str, body: &impl serde::Serialize) -> Result<HttpResponse> {
let json = serde_json::to_vec(body).context("serializing JSON body")?;
let req = Request::post(url)
.header("content-type", "application/json")
.body(Full::new(Bytes::from(json)))
.context("building POST request")?;
self.send_full(req).await
}
/// Send a POST request with URL-encoded form data.
pub async fn post_form(&self, url: &str, params: &[(&str, &str)]) -> Result<HttpResponse> {
let body = serde_urlencoded::to_string(params).context("encoding form")?;
let req = Request::post(url)
.header("content-type", "application/x-www-form-urlencoded")
.body(Full::new(Bytes::from(body)))
.context("building form POST")?;
self.send_full(req).await
}
/// Send a request with headers pre-set. JSON body.
pub async fn send_json(
&self,
method: &str,
url: &str,
headers: &[(&str, &str)],
body: &impl serde::Serialize,
) -> Result<HttpResponse> {
let json = serde_json::to_vec(body).context("serializing JSON body")?;
let mut builder = Request::builder()
.method(method)
.uri(url)
.header("content-type", "application/json");
for &(k, v) in headers {
builder = builder.header(k, v);
}
let req = builder.body(Full::new(Bytes::from(json)))
.context("building request")?;
self.send_full(req).await
}
async fn connect(&self, url: &str) -> Result<(bool, TokioIo<Box<dyn IoStream>>)> {
let uri: http::Uri = url.parse().context("parsing URL")?;
let host = uri.host().context("URL has no host")?.to_string();
let is_https = uri.scheme_str() == Some("https");
let port = uri.port_u16().unwrap_or(if is_https { 443 } else { 80 });
let tcp = tokio::time::timeout(
self.connect_timeout,
TcpStream::connect(format!("{}:{}", host, port)),
).await
.context("connect timeout")?
.context("TCP connect")?;
if is_https {
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
.map_err(|e| anyhow::anyhow!("invalid server name: {}", e))?;
let connector = tokio_rustls::TlsConnector::from(self.tls.clone());
let tls = connector.connect(server_name.to_owned(), tcp).await
.context("TLS handshake")?;
Ok((is_https, TokioIo::new(Box::new(tls) as Box<dyn IoStream>)))
} else {
Ok((is_https, TokioIo::new(Box::new(tcp) as Box<dyn IoStream>)))
}
}
async fn send_full(&self, req: Request<Full<Bytes>>) -> Result<HttpResponse> {
let url = req.uri().to_string();
let (_is_https, io) = self.connect(&url).await?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await
.context("HTTP handshake")?;
tokio::spawn(conn);
let resp = tokio::time::timeout(
self.request_timeout,
sender.send_request(req),
).await
.context("request timeout")?
.context("sending request")?;
let (parts, body) = resp.into_parts();
Ok(HttpResponse { parts, body })
}
async fn send_empty(&self, req: Request<Empty<Bytes>>) -> Result<HttpResponse> {
let url = req.uri().to_string();
let (_is_https, io) = self.connect(&url).await?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await
.context("HTTP handshake")?;
tokio::spawn(conn);
let resp = tokio::time::timeout(
self.request_timeout,
sender.send_request(req),
).await
.context("request timeout")?
.context("sending request")?;
let (parts, body) = resp.into_parts();
Ok(HttpResponse { parts, body })
}
}
impl HttpResponse {
pub fn status(&self) -> StatusCode {
self.parts.status
}
pub fn header(&self, name: &str) -> Option<&str> {
self.parts.headers.get(name)?.to_str().ok()
}
/// Read the entire body as text.
pub async fn text(self) -> Result<String> {
let bytes = self.body.collect().await
.context("reading response body")?
.to_bytes();
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// Read the entire body and deserialize as JSON.
pub async fn json<T: serde::de::DeserializeOwned>(self) -> Result<T> {
let bytes = self.body.collect().await
.context("reading response body")?
.to_bytes();
serde_json::from_slice(&bytes).context("deserializing JSON response")
}
/// Read the next chunk from the response body (for SSE streaming).
/// Returns None when the body is complete.
pub async fn chunk(&mut self) -> Result<Option<Bytes>> {
match self.body.frame().await {
Some(Ok(frame)) => Ok(frame.into_data().ok()),
Some(Err(e)) => Err(anyhow::anyhow!("body read error: {}", e)),
None => Ok(None),
}
}
}
pub struct HttpClientBuilder {
connect_timeout: Duration,
request_timeout: Duration,
}
impl HttpClientBuilder {
pub fn connect_timeout(mut self, d: Duration) -> Self {
self.connect_timeout = d;
self
}
pub fn timeout(mut self, d: Duration) -> Self {
self.request_timeout = d;
self
}
pub fn build(self) -> HttpClient {
let certs = rustls_native_certs::load_native_certs()
.certs.into_iter()
.collect::<Vec<_>>();
let mut root_store = rustls::RootCertStore::empty();
for cert in certs {
root_store.add(cert).ok();
}
let tls = Arc::new(
ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth()
);
HttpClient {
tls,
connect_timeout: self.connect_timeout,
request_timeout: self.request_timeout,
}
}
}
/// Trait alias for streams that work with hyper's IO adapter.
trait IoStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static {}
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static> IoStream for T {}