refactor(http): centralize reqwest behind fabro-http

Add the shared fabro-http transport crate and route hand-written HTTP client construction through it.

Use FABRO_HTTP_PROXY_POLICY for test no-proxy defaults, remove direct reqwest deps from ordinary crates, and add clippy bans for raw reqwest entrypoints.
This commit is contained in:
Bryan Helmkamp 2026-04-12 11:48:54 -04:00
parent 2bf35ab184
commit 3b2cffceaf
73 changed files with 740 additions and 269 deletions

39
Cargo.lock generated
View file

@ -1481,6 +1481,7 @@ dependencies = [
"clap",
"dirs",
"fabro-config",
"fabro-http",
"fabro-llm",
"fabro-macros",
"fabro-mcp",
@ -1495,7 +1496,6 @@ dependencies = [
"jsonschema",
"libc",
"paste",
"reqwest 0.13.2",
"serde",
"serde_json",
"shell-escape",
@ -1567,6 +1567,7 @@ dependencies = [
"fabro-github",
"fabro-graphviz",
"fabro-hooks",
"fabro-http",
"fabro-interview",
"fabro-llm",
"fabro-macros",
@ -1597,7 +1598,6 @@ dependencies = [
"progenitor-client",
"rand 0.8.5",
"regex",
"reqwest 0.13.2",
"rustls",
"rustls-pemfile",
"scopeguard",
@ -1657,9 +1657,9 @@ dependencies = [
name = "fabro-devcontainer"
version = "0.176.2"
dependencies = [
"fabro-http",
"fabro-util",
"insta",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_yaml",
@ -1675,10 +1675,10 @@ version = "0.176.2"
dependencies = [
"base64",
"chrono",
"fabro-http",
"fabro-macros",
"fabro-test",
"jsonwebtoken",
"reqwest 0.13.2",
"serde",
"serde_json",
"tokio",
@ -1704,6 +1704,7 @@ dependencies = [
"async-trait",
"fabro-agent",
"fabro-config",
"fabro-http",
"fabro-llm",
"fabro-model",
"fabro-template",
@ -1711,7 +1712,6 @@ dependencies = [
"fabro-util",
"httpmock",
"regex",
"reqwest 0.13.2",
"serde",
"serde_json",
"tokio",
@ -1720,6 +1720,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "fabro-http"
version = "0.176.2"
dependencies = [
"http",
"reqwest 0.13.2",
"thiserror 2.0.18",
]
[[package]]
name = "fabro-interview"
version = "0.176.2"
@ -1742,6 +1751,7 @@ dependencies = [
"async-trait",
"base64",
"bytes",
"fabro-http",
"fabro-macros",
"fabro-model",
"fabro-test",
@ -1751,7 +1761,6 @@ dependencies = [
"httpmock",
"insta",
"rand 0.8.5",
"reqwest 0.13.2",
"serde",
"serde_json",
"thiserror 2.0.18",
@ -1802,11 +1811,11 @@ version = "0.176.2"
dependencies = [
"axum",
"base64",
"fabro-http",
"hex",
"httpmock",
"open",
"rand 0.8.5",
"reqwest 0.13.2",
"serde",
"serde_json",
"sha2",
@ -1891,6 +1900,7 @@ dependencies = [
"fabro-github",
"fabro-graphviz",
"fabro-hooks",
"fabro-http",
"fabro-interview",
"fabro-llm",
"fabro-model",
@ -1917,7 +1927,6 @@ dependencies = [
"openapiv3",
"rand 0.8.5",
"regex",
"reqwest 0.13.2",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
@ -1947,10 +1956,10 @@ dependencies = [
name = "fabro-slack"
version = "0.176.2"
dependencies = [
"fabro-http",
"fabro-interview",
"fabro-workflow",
"futures-util",
"reqwest 0.13.2",
"rustls",
"serde",
"serde_json",
@ -2000,6 +2009,7 @@ dependencies = [
"chrono",
"dirs",
"exec",
"fabro-http",
"fabro-util",
"fork",
"git2",
@ -2007,7 +2017,6 @@ dependencies = [
"mac_address",
"md5",
"regex",
"reqwest 0.13.2",
"sentry",
"serde",
"serde_json",
@ -2035,11 +2044,11 @@ dependencies = [
"assert_cmd",
"axum",
"fabro-config",
"fabro-http",
"fabro-proc",
"fabro-types",
"insta",
"regex",
"reqwest 0.13.2",
"serde",
"serde_json",
"tempfile",
@ -2055,8 +2064,8 @@ version = "0.176.2"
dependencies = [
"async-trait",
"fabro-github",
"fabro-http",
"httpmock",
"reqwest 0.13.2",
"serde_json",
"tokio",
"tracing",
@ -2131,6 +2140,7 @@ dependencies = [
"fabro-github",
"fabro-graphviz",
"fabro-hooks",
"fabro-http",
"fabro-interview",
"fabro-llm",
"fabro-macros",
@ -2153,7 +2163,6 @@ dependencies = [
"predicates",
"rand 0.8.5",
"regex",
"reqwest 0.13.2",
"scopeguard",
"serde",
"serde_json",
@ -6685,8 +6694,8 @@ dependencies = [
"axum",
"base64",
"chrono",
"fabro-http",
"jsonwebtoken",
"reqwest 0.13.2",
"serde",
"serde_json",
"tempfile",
@ -6703,9 +6712,9 @@ dependencies = [
"anyhow",
"async-stream",
"axum",
"fabro-http",
"futures-util",
"http",
"reqwest 0.13.2",
"serde",
"serde_json",
"tokio",

View file

@ -74,6 +74,7 @@ object_store = { version = "0.12.5", features = ["aws"] }
rust-embed = "8"
percent-encoding = "2"
minijinja = "2"
fabro-http = { path = "lib/crates/fabro-http" }
[workspace.lints.rust]
unsafe_code = "deny"
@ -99,6 +100,7 @@ print_stderr = "warn"
dbg_macro = "warn"
empty_drop = "warn"
empty_structs_with_brackets = "warn"
disallowed_methods = "deny"
exit = "warn"
get_unwrap = "warn"
rc_buffer = "warn"

View file

@ -1,2 +1,10 @@
absolute-paths-max-segments = 2
absolute-paths-allowed-crates = ["std", "core", "alloc"]
disallowed-methods = [
{ path = "reqwest::Client::new", reason = "Use fabro_http::http_client() or fabro_http::test_http_client()", allow-invalid = true },
{ path = "reqwest::Client::builder", reason = "Use fabro_http::HttpClientBuilder::new()", allow-invalid = true },
{ path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true },
{ path = "reqwest::blocking::Client::builder", reason = "Use fabro_http::BlockingHttpClientBuilder::new()", allow-invalid = true },
{ path = "reqwest::get", reason = "Build a fabro_http client and send the request explicitly", allow-invalid = true },
]

View file

@ -31,6 +31,7 @@ fabro-model = { path = "../fabro-model" }
fabro-mcp = { path = "../fabro-mcp" }
fabro-sandbox = { path = "../fabro-sandbox" }
fabro-util = { path = "../fabro-util" }
fabro-http.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
@ -40,7 +41,6 @@ futures.workspace = true
async-trait.workspace = true
jsonschema.workspace = true
chrono.workspace = true
reqwest.workspace = true
tokio-util.workspace = true
tracing.workspace = true
dirs = "6"

View file

@ -467,7 +467,7 @@ pub(crate) fn make_web_search_tool() -> RegisteredTool {
fn make_web_search_tool_with_api_key(api_key: Option<String>) -> RegisteredTool {
use std::sync::OnceLock;
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
static CLIENT: OnceLock<fabro_http::HttpClient> = OnceLock::new();
RegisteredTool {
definition: ToolDefinition {
@ -490,7 +490,11 @@ fn make_web_search_tool_with_api_key(api_key: Option<String>) -> RegisteredTool
})?;
let query = required_str(&args, "query")?;
let client = CLIENT.get_or_init(reqwest::Client::new).clone();
let client = CLIENT
.get_or_init(|| {
fabro_http::http_client().expect("Brave Search HTTP client should build")
})
.clone();
let count = args
.get("max_results")
.and_then(serde_json::Value::as_u64)

View file

@ -2,6 +2,8 @@
clippy::absolute_paths,
clippy::all,
clippy::derivable_impls,
clippy::disallowed_methods,
clippy::disallowed_types,
clippy::needless_lifetimes,
unreachable_pub,
unused_imports

View file

@ -41,6 +41,7 @@ fabro-telemetry = { path = "../fabro-telemetry" }
fabro-store = { path = "../fabro-store" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
fabro-http.workspace = true
clap.workspace = true
clap_complete.workspace = true
cli-table.workspace = true
@ -60,7 +61,6 @@ toml.workspace = true
futures.workspace = true
regex.workspace = true
semver.workspace = true
reqwest.workspace = true
progenitor-client = "0.13"
async-trait.workspace = true
jsonwebtoken.workspace = true

View file

@ -198,7 +198,7 @@ pub(crate) async fn execute(
user_config::build_server_client(tls.as_ref())?,
),
user_config::ServerTarget::UnixSocket(path) => {
let http_client = reqwest::ClientBuilder::new()
let http_client = fabro_http::HttpClientBuilder::new()
.unix_socket(path.as_path())
.no_proxy()
.build()?;

View file

@ -596,7 +596,7 @@ async fn setup_github_app(
" {}",
s.dim.apply_to("Exchanging code with GitHub...")
);
let client = reqwest::Client::new();
let client = fabro_http::http_client()?;
let resp = client
.post(format!(
"https://api.github.com/app-manifests/{code}/conversions"

View file

@ -424,8 +424,8 @@ mod tests {
use super::*;
fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
fn test_api_client(api_url: &str) -> fabro_api::Client {

View file

@ -19,11 +19,11 @@ const GITHUB_REPO: &str = "fabro-sh/fabro";
enum Backend {
Gh,
Http(reqwest::Client),
Http(fabro_http::HttpClient),
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
fn http_client() -> Result<fabro_http::HttpClient> {
fabro_http::HttpClientBuilder::new()
.user_agent("fabro-cli")
.build()
.context("failed to build HTTP client")

View file

@ -6,14 +6,14 @@ use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail};
use bytes::Bytes;
use fabro_api::types;
use fabro_http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use fabro_http::multipart::{Form, Part};
use fabro_server::bind::Bind;
use fabro_store::{EventEnvelope, RunSummary, StageId};
use fabro_types::settings::SettingsLayer;
use fabro_types::{RunBlobId, RunEvent, RunId};
use fabro_workflow::artifact_snapshot::CapturedArtifactInfo;
use futures::StreamExt;
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
use reqwest::multipart::{Form, Part};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::fs::File;
@ -28,7 +28,7 @@ use crate::{sse, user_config};
#[derive(Clone)]
pub(crate) struct ServerStoreClient {
client: fabro_api::Client,
http_client: reqwest::Client,
http_client: fabro_http::HttpClient,
base_url: String,
}
@ -174,7 +174,7 @@ fn normalize_remote_server_target(api_url: &str) -> String {
.to_string()
}
fn build_unix_socket_http_client(path: &Path) -> Result<reqwest::Client> {
fn build_unix_socket_http_client(path: &Path) -> Result<fabro_http::HttpClient> {
cli_http_client_builder()
.unix_socket(path)
.no_proxy()
@ -182,7 +182,7 @@ fn build_unix_socket_http_client(path: &Path) -> Result<reqwest::Client> {
.context("Failed to build Unix-socket HTTP client for fabro server")
}
fn unix_socket_api_client_bundle(http_client: reqwest::Client) -> ServerStoreClient {
fn unix_socket_api_client_bundle(http_client: fabro_http::HttpClient) -> ServerStoreClient {
let base_url = "http://fabro".to_string();
let client = fabro_api::Client::new_with_client(&base_url, http_client.clone());
ServerStoreClient {
@ -204,7 +204,7 @@ async fn connect_unix_socket_api_client_bundle(path: &Path) -> Result<ServerStor
Ok(unix_socket_api_client_bundle(http_client))
}
async fn check_server_ready(http_client: &reqwest::Client) -> Result<()> {
async fn check_server_ready(http_client: &fabro_http::HttpClient) -> Result<()> {
match http_client.get("http://fabro/health").send().await {
Ok(response) if response.status().is_success() => Ok(()),
Ok(response) => bail!("server health check returned status {}", response.status()),
@ -212,7 +212,7 @@ async fn check_server_ready(http_client: &reqwest::Client) -> Result<()> {
}
}
async fn wait_for_server_ready(http_client: &reqwest::Client) -> Result<()> {
async fn wait_for_server_ready(http_client: &fabro_http::HttpClient) -> Result<()> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut last_error = None;
@ -268,7 +268,7 @@ impl ServerStoreClient {
}
#[allow(dead_code)]
pub(crate) fn http_client(&self) -> &reqwest::Client {
pub(crate) fn http_client(&self) -> &fabro_http::HttpClient {
&self.http_client
}
@ -584,8 +584,8 @@ impl ServerStoreClient {
Ok(bytes)
}
fn stage_artifacts_url(&self, run_id: &RunId, stage_id: &StageId) -> Result<reqwest::Url> {
let mut url = reqwest::Url::parse(&self.base_url)
fn stage_artifacts_url(&self, run_id: &RunId, stage_id: &StageId) -> Result<fabro_http::Url> {
let mut url = fabro_http::Url::parse(&self.base_url)
.with_context(|| format!("invalid server base URL {}", self.base_url))?;
url.path_segments_mut()
.map_err(|()| anyhow!("server base URL cannot accept path segments"))?
@ -620,7 +620,7 @@ impl ServerStoreClient {
.await
.with_context(|| format!("failed to stat artifact {}", path.display()))?
.len();
let body = reqwest::Body::wrap_stream(ReaderStream::new(file));
let body = fabro_http::Body::wrap_stream(ReaderStream::new(file));
let response = self
.http_client
@ -670,7 +670,7 @@ impl ServerStoreClient {
file_parts.push((
part_name,
Part::stream_with_length(
reqwest::Body::wrap_stream(ReaderStream::new(file)),
fabro_http::Body::wrap_stream(ReaderStream::new(file)),
content_length,
)
.file_name(artifact.path.clone()),
@ -819,7 +819,7 @@ where
}
}
async fn ensure_raw_response_success(response: reqwest::Response) -> Result<()> {
async fn ensure_raw_response_success(response: fabro_http::Response) -> Result<()> {
if response.status().is_success() {
return Ok(());
}
@ -851,10 +851,10 @@ where
{
match err {
progenitor_client::Error::ErrorResponse(response) => {
response.status() == reqwest::StatusCode::NOT_FOUND
response.status() == fabro_http::StatusCode::NOT_FOUND
}
progenitor_client::Error::UnexpectedResponse(response) => {
response.status() == reqwest::StatusCode::NOT_FOUND
response.status() == fabro_http::StatusCode::NOT_FOUND
}
_ => false,
}

View file

@ -193,13 +193,13 @@ pub(crate) fn exec_server_target(
Ok(target)
}
pub(crate) fn cli_http_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder().user_agent(format!("fabro-cli/{FABRO_VERSION}"))
pub(crate) fn cli_http_client_builder() -> fabro_http::HttpClientBuilder {
fabro_http::HttpClientBuilder::new().user_agent(format!("fabro-cli/{FABRO_VERSION}"))
}
pub(crate) fn build_server_client(
tls: Option<&ClientTlsSettings>,
) -> anyhow::Result<reqwest::Client> {
) -> anyhow::Result<fabro_http::HttpClient> {
let Some(tls) = tls else {
return Ok(cli_http_client_builder().build()?);
};
@ -216,8 +216,8 @@ pub(crate) fn build_server_client(
identity_pem.push(b'\n');
identity_pem.extend_from_slice(&key_pem);
let identity = reqwest::Identity::from_pem(&identity_pem)?;
let ca_cert = reqwest::Certificate::from_pem(&ca_pem)?;
let identity = fabro_http::Identity::from_pem(&identity_pem)?;
let ca_cert = fabro_http::Certificate::from_pem(&ca_pem)?;
let client = cli_http_client_builder()
.use_rustls_tls()

View file

@ -14,11 +14,11 @@ use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, u
const SHARED_DAEMON_TIMEOUT: Duration = Duration::from_secs(30);
fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
fn server_endpoint(storage_dir: &Path) -> (fabro_http::HttpClient, String) {
let target = server_target(storage_dir);
if target.starts_with('/') {
(
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.unix_socket(target)
.no_proxy()
.build()
@ -27,7 +27,7 @@ fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
)
} else {
(
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.no_proxy()
.build()
.expect("test TCP HTTP client should build"),
@ -36,7 +36,11 @@ fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
}
}
async fn wait_for_server_question(client: &reqwest::Client, base_url: &str, run_id: &str) -> Value {
async fn wait_for_server_question(
client: &fabro_http::HttpClient,
base_url: &str,
run_id: &str,
) -> Value {
let deadline = std::time::Instant::now() + SHARED_DAEMON_TIMEOUT;
loop {
let response = client
@ -216,7 +220,9 @@ fn attach_before_completion_streams_to_finished_state() {
attach_cmd.current_dir(&context.temp_dir);
attach_cmd.env("NO_COLOR", "1");
attach_cmd.env("HOME", &context.home_dir);
attach_cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
attach_cmd
.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
attach_cmd.env("FABRO_SERVER_MAX_CONCURRENT_RUNS", "64");
attach_cmd.env("FABRO_TEST_IN_MEMORY_STORE", "1");
attach_cmd.args(["attach", &run_id]);
@ -818,7 +824,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
.send()
.await
.expect("answer submission should succeed");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
assert_eq!(response.status(), fabro_http::StatusCode::NO_CONTENT);
});
wait_for_status(&run.run_dir, &["succeeded"]);
}

View file

@ -69,7 +69,8 @@ async fn twin_doctor() {
cmd.env_clear();
cmd.env("NO_COLOR", "1");
cmd.env("HOME", &context.home_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
cmd.env("FABRO_STORAGE_DIR", &context.storage_dir);
cmd.env(
"PATH",

View file

@ -108,7 +108,8 @@ fn exec_uses_user_config_defaults() {
cmd.env_clear();
cmd.env("HOME", &context.home_dir);
cmd.env("FABRO_STORAGE_DIR", &context.storage_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
fabro_snapshot!(context.filters(), cmd, @"
success: false
@ -133,7 +134,8 @@ fn exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution(
let mut cmd = context.exec_cmd();
cmd.env_clear();
cmd.env("HOME", &context.home_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
cmd.args([
"--server",
&format!("{}/api/v1", server.base_url()),
@ -175,7 +177,8 @@ fn exec_configured_server_target_alone_does_not_reroute_exec() {
let mut cmd = context.exec_cmd();
cmd.env_clear();
cmd.env("HOME", &context.home_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
cmd.args([
"--provider",
"openai",
@ -222,7 +225,8 @@ fn exec_cli_server_target_overrides_configured_server_target() {
let mut cmd = context.exec_cmd();
cmd.env_clear();
cmd.env("HOME", &context.home_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
cmd.args([
"--server",
&format!("{}/api/v1", cli_server.base_url()),

View file

@ -72,7 +72,7 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() {
serde_json::from_str(&std::fs::read_to_string(record_path).unwrap()).unwrap();
let (client, base_url) = match record.bind {
Bind::Unix(path) => (
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.unix_socket(path)
.no_proxy()
.build()
@ -80,7 +80,10 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() {
"http://fabro".to_string(),
),
Bind::Tcp(addr) => (
reqwest::ClientBuilder::new().no_proxy().build().unwrap(),
fabro_http::HttpClientBuilder::new()
.no_proxy()
.build()
.unwrap(),
format!("http://{addr}"),
),
};

View file

@ -46,7 +46,8 @@ fn spawn_worker_process(
cmd.current_dir(&context.temp_dir);
cmd.env("NO_COLOR", "1");
cmd.env("HOME", &context.home_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
cmd.env("FABRO_SERVER_MAX_CONCURRENT_RUNS", "64");
cmd.env("FABRO_TEST_IN_MEMORY_STORE", "1");
cmd.args([
@ -98,11 +99,11 @@ fn child_output(mut child: Child, status: ExitStatus) -> Output {
}
}
fn server_endpoint(storage_dir: &std::path::Path) -> (reqwest::Client, String) {
fn server_endpoint(storage_dir: &std::path::Path) -> (fabro_http::HttpClient, String) {
let target = server_target(storage_dir);
if target.starts_with('/') {
(
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.unix_socket(target)
.no_proxy()
.build()
@ -111,7 +112,7 @@ fn server_endpoint(storage_dir: &std::path::Path) -> (reqwest::Client, String) {
)
} else {
(
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.no_proxy()
.build()
.expect("test TCP HTTP client should build"),
@ -121,7 +122,7 @@ fn server_endpoint(storage_dir: &std::path::Path) -> (reqwest::Client, String) {
}
async fn wait_for_server_question(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
base_url: &str,
run_id: &str,
) -> serde_json::Value {
@ -608,7 +609,7 @@ fn detached_run_answers_pending_question_without_interview_scratch_files() {
.send()
.await
.expect("answer submission should succeed");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
assert_eq!(response.status(), fabro_http::StatusCode::NO_CONTENT);
question_id
});

View file

@ -350,6 +350,7 @@ fn concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up() {
.env("HOME", home_dir)
.env("FABRO_CONFIG", config_path)
.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled")
.args(["ps", "-a", "--json"])
.output()
.expect("ps command should execute")
@ -442,6 +443,7 @@ fn concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up() {
.env("NO_COLOR", "1")
.env("FABRO_CONFIG", &config_path)
.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled")
.args(["server", "stop", "--timeout", "0"])
.output()
.expect("server stop should execute");

View file

@ -581,14 +581,14 @@ struct TestServerRecord {
bind: Bind,
}
fn server_endpoint(storage_dir: &Path) -> Option<(reqwest::Client, String)> {
fn server_endpoint(storage_dir: &Path) -> Option<(fabro_http::HttpClient, String)> {
let record_path = Storage::new(storage_dir).server_state().record_path();
let record = std::fs::read_to_string(record_path)
.ok()
.and_then(|content| serde_json::from_str::<TestServerRecord>(&content).ok())?;
match record.bind {
Bind::Unix(path) if path.exists() => Some((
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.unix_socket(path)
.no_proxy()
.build()
@ -597,7 +597,7 @@ fn server_endpoint(storage_dir: &Path) -> Option<(reqwest::Client, String)> {
)),
Bind::Unix(_) => None,
Bind::Tcp(addr) => Some((
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.no_proxy()
.build()
.expect("test TCP HTTP client should build"),

View file

@ -41,7 +41,7 @@ struct TestServerRecord {
bind: Bind,
}
fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
fn server_endpoint(storage_dir: &Path) -> (fabro_http::HttpClient, String) {
let record_path = Storage::new(storage_dir).server_state().record_path();
let record: TestServerRecord = serde_json::from_str(
&std::fs::read_to_string(record_path).expect("server record should exist"),
@ -49,7 +49,7 @@ fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
.expect("server record should parse");
match record.bind {
Bind::Unix(path) => (
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.unix_socket(path)
.no_proxy()
.build()
@ -57,7 +57,7 @@ fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
"http://fabro".to_string(),
),
Bind::Tcp(addr) => (
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.no_proxy()
.build()
.expect("test TCP HTTP client should build"),

View file

@ -91,7 +91,8 @@ impl LightweightCli {
}
cmd.env("HOME", self.home_dir.path());
cmd.env("NO_COLOR", "1");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
cmd.current_dir(self.home_dir.path());
cmd
}

View file

@ -110,7 +110,7 @@ struct TestServerRecord {
bind: Bind,
}
fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
fn server_endpoint(storage_dir: &Path) -> (fabro_http::HttpClient, String) {
let record_path = Storage::new(storage_dir).server_state().record_path();
let record: TestServerRecord = serde_json::from_str(
&std::fs::read_to_string(record_path).expect("server record should exist"),
@ -118,7 +118,7 @@ fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
.expect("server record should parse");
match record.bind {
Bind::Unix(path) => (
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.unix_socket(path)
.no_proxy()
.build()
@ -126,7 +126,7 @@ fn server_endpoint(storage_dir: &Path) -> (reqwest::Client, String) {
"http://fabro".to_string(),
),
Bind::Tcp(addr) => (
reqwest::ClientBuilder::new()
fabro_http::HttpClientBuilder::new()
.no_proxy()
.build()
.expect("test TCP HTTP client should build"),

View file

@ -14,13 +14,13 @@ workspace = true
[dependencies]
fabro-util = { path = "../fabro-util" }
fabro-http.workspace = true
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = "0.9"
thiserror = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
reqwest = { workspace = true }
[dev-dependencies]
insta = { workspace = true }

View file

@ -248,7 +248,10 @@ async fn fetch_feature_https(
info!(feature_id, "downloading feature from HTTPS");
let response = reqwest::get(feature_id)
let response = fabro_http::http_client()
.map_err(|e| DevcontainerError::Feature(format!("failed to build HTTP client: {e}")))?
.get(feature_id)
.send()
.await
.map_err(|e| DevcontainerError::Feature(format!("failed to download {feature_id}: {e}")))?;

View file

@ -15,7 +15,7 @@ workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
fabro-http.workspace = true
jsonwebtoken.workspace = true
chrono.workspace = true
tracing.workspace = true

View file

@ -10,6 +10,10 @@ pub fn github_api_base_url() -> String {
std::env::var("GITHUB_BASE_URL").unwrap_or_else(|_| GITHUB_API_BASE_URL.to_string())
}
fn http_client() -> Result<fabro_http::HttpClient, String> {
fabro_http::http_client().map_err(|err| err.to_string())
}
/// Detailed information about a pull request from the GitHub API.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PullRequestDetail {
@ -186,7 +190,7 @@ impl HttpResponse {
/// Abstract HTTP client for GitHub API calls.
///
/// Implemented for `reqwest::Client` in production; tests use a mock
/// Implemented for `fabro_http::HttpClient` in production; tests use a mock
/// to avoid TCP/process overhead.
pub trait HttpClient: Send + Sync {
fn request(
@ -198,7 +202,7 @@ pub trait HttpClient: Send + Sync {
) -> impl std::future::Future<Output = Result<HttpResponse, String>> + Send;
}
impl HttpClient for reqwest::Client {
impl HttpClient for fabro_http::HttpClient {
async fn request(
&self,
method: HttpMethod,
@ -474,7 +478,7 @@ pub async fn create_pull_request(
node_id: String,
}
let client = reqwest::Client::new();
let client = http_client()?;
let token = creds
.resolve_bearer_token(
&client,
@ -571,7 +575,7 @@ pub async fn enable_auto_merge(
merge_method: AutoMergeMethod,
base_url: &str,
) -> Result<(), String> {
let client = reqwest::Client::new();
let client = http_client()?;
let token = creds
.resolve_bearer_token(
&client,
@ -697,15 +701,8 @@ pub async fn branch_exists(
branch: &str,
base_url: &str,
) -> Result<bool, String> {
branch_exists_with_client(
&reqwest::Client::new(),
creds,
owner,
repo,
branch,
base_url,
)
.await
let client = http_client()?;
branch_exists_with_client(&client, creds, owner, repo, branch, base_url).await
}
async fn branch_exists_with_client(
@ -854,7 +851,7 @@ pub async fn resolve_clone_credentials(
let token = match creds {
GitHubCredentials::Token(token) => token.clone(),
GitHubCredentials::App(_) => {
let client = reqwest::Client::new();
let client = http_client()?;
creds
.resolve_bearer_token(
&client,
@ -902,15 +899,8 @@ pub async fn get_pull_request(
number: u64,
base_url: &str,
) -> Result<PullRequestDetail, String> {
get_pull_request_with_client(
&reqwest::Client::new(),
creds,
owner,
repo,
number,
base_url,
)
.await
let client = http_client()?;
get_pull_request_with_client(&client, creds, owner, repo, number, base_url).await
}
async fn get_pull_request_with_client(
@ -974,16 +964,8 @@ pub async fn merge_pull_request(
method: &str,
base_url: &str,
) -> Result<(), String> {
merge_pull_request_with_client(
&reqwest::Client::new(),
creds,
owner,
repo,
number,
method,
base_url,
)
.await
let client = http_client()?;
merge_pull_request_with_client(&client, creds, owner, repo, number, method, base_url).await
}
#[allow(clippy::too_many_arguments)]
@ -1045,15 +1027,8 @@ pub async fn close_pull_request(
number: u64,
base_url: &str,
) -> Result<(), String> {
close_pull_request_with_client(
&reqwest::Client::new(),
creds,
owner,
repo,
number,
base_url,
)
.await
let client = http_client()?;
close_pull_request_with_client(&client, creds, owner, repo, number, base_url).await
}
async fn close_pull_request_with_client(

View file

@ -20,12 +20,12 @@ fabro-model = { path = "../fabro-model" }
fabro-template = { path = "../fabro-template" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
fabro-http.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
async-trait.workspace = true
regex.workspace = true
reqwest.workspace = true
tracing.workspace = true
tokio-util.workspace = true

View file

@ -438,11 +438,11 @@ impl HookExecutorImpl {
}
/// Build a reqwest client for the given TLS mode.
fn build_http_client(tls: TlsMode) -> reqwest::Client {
fn build_http_client(tls: TlsMode) -> fabro_http::HttpClient {
let accept_invalid = matches!(tls, TlsMode::NoVerify | TlsMode::Off);
#[cfg(test)]
{
reqwest::Client::builder()
fabro_http::HttpClientBuilder::new()
.danger_accept_invalid_certs(accept_invalid)
.no_proxy()
.build()
@ -450,7 +450,7 @@ impl HookExecutorImpl {
}
#[cfg(not(test))]
{
reqwest::Client::builder()
fabro_http::HttpClientBuilder::new()
.danger_accept_invalid_certs(accept_invalid)
.build()
.unwrap_or_default()
@ -461,7 +461,7 @@ impl HookExecutorImpl {
/// Fail-open: non-2xx and connection errors return `Proceed`.
#[allow(clippy::too_many_arguments)]
async fn execute_http<E>(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
url: &str,
headers: Option<&HashMap<String, String>>,
allowed_env_vars: &[String],
@ -560,9 +560,9 @@ impl HookExecutorImpl {
/// Cached HTTP clients keyed by TLS mode.
struct HttpClientCache {
verify: reqwest::Client,
no_verify: reqwest::Client,
off: reqwest::Client,
verify: fabro_http::HttpClient,
no_verify: fabro_http::HttpClient,
off: fabro_http::HttpClient,
}
impl HttpClientCache {
@ -574,7 +574,7 @@ impl HttpClientCache {
}
}
fn get(&self, tls: TlsMode) -> &reqwest::Client {
fn get(&self, tls: TlsMode) -> &fabro_http::HttpClient {
match tls {
TlsMode::Verify => &self.verify,
TlsMode::NoVerify => &self.no_verify,
@ -704,7 +704,7 @@ mod tests {
))
}
fn test_http_client() -> reqwest::Client {
fn test_http_client() -> fabro_http::HttpClient {
HookExecutorImpl::build_http_client(TlsMode::Off)
}

View file

@ -0,0 +1,20 @@
[package]
name = "fabro-http"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Shared HTTP client utilities and reqwest wrappers for Fabro"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
reqwest = { workspace = true, features = ["blocking"] }
thiserror.workspace = true
[dev-dependencies]
http = "1"

View file

@ -0,0 +1,351 @@
#![allow(
clippy::absolute_paths,
clippy::disallowed_methods,
clippy::disallowed_types
)]
use std::path::Path;
use std::time::Duration;
pub use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
pub use reqwest::tls::{Certificate, Identity};
pub use reqwest::{
Body, Method, RequestBuilder, Response, StatusCode, Url, header, multipart, tls,
};
pub type HttpClient = reqwest::Client;
pub type BlockingHttpClient = reqwest::blocking::Client;
pub type BlockingRequestBuilder = reqwest::blocking::RequestBuilder;
pub type BlockingResponse = reqwest::blocking::Response;
pub type Proxy = reqwest::Proxy;
pub const HTTP_PROXY_POLICY_ENV: &str = "FABRO_HTTP_PROXY_POLICY";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProxyPolicy {
System,
Disabled,
}
impl ProxyPolicy {
fn parse(value: &str) -> Result<Self, HttpClientBuildError> {
match value.to_ascii_lowercase().as_str() {
"system" => Ok(Self::System),
"disabled" => Ok(Self::Disabled),
_ => Err(HttpClientBuildError::InvalidProxyPolicy(value.to_string())),
}
}
fn resolve(explicit: Option<Self>) -> Result<Self, HttpClientBuildError> {
if let Some(policy) = explicit {
return Ok(policy);
}
match std::env::var(HTTP_PROXY_POLICY_ENV) {
Ok(value) => Self::parse(&value),
Err(std::env::VarError::NotPresent) => Ok(Self::System),
Err(std::env::VarError::NotUnicode(value)) => Err(
HttpClientBuildError::InvalidProxyPolicy(value.to_string_lossy().into_owned()),
),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum HttpClientBuildError {
#[error("invalid {HTTP_PROXY_POLICY_ENV} value `{0}`; expected `system` or `disabled`")]
InvalidProxyPolicy(String),
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
}
#[derive(Default)]
pub struct HttpClientBuilder {
inner: reqwest::ClientBuilder,
proxy_policy: Option<ProxyPolicy>,
}
impl HttpClientBuilder {
#[must_use]
pub fn new() -> Self {
Self {
inner: reqwest::Client::builder(),
proxy_policy: None,
}
}
#[must_use]
pub fn proxy_policy(mut self, proxy_policy: ProxyPolicy) -> Self {
self.proxy_policy = Some(proxy_policy);
self
}
#[must_use]
pub fn no_proxy(mut self) -> Self {
self.inner = self.inner.no_proxy();
self
}
#[must_use]
pub fn proxy(mut self, proxy: Proxy) -> Self {
self.inner = self.inner.proxy(proxy);
self
}
#[must_use]
pub fn user_agent(mut self, value: impl Into<String>) -> Self {
self.inner = self.inner.user_agent(value.into());
self
}
#[must_use]
pub fn default_headers(mut self, headers: HeaderMap) -> Self {
self.inner = self.inner.default_headers(headers);
self
}
#[must_use]
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.inner = self.inner.connect_timeout(timeout);
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.inner = self.inner.timeout(timeout);
self
}
#[must_use]
pub fn read_timeout(mut self, timeout: Duration) -> Self {
self.inner = self.inner.read_timeout(timeout);
self
}
#[must_use]
pub fn use_rustls_tls(mut self) -> Self {
self.inner = self.inner.use_rustls_tls();
self
}
#[must_use]
pub fn danger_accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
self.inner = self.inner.danger_accept_invalid_certs(accept_invalid_certs);
self
}
#[must_use]
pub fn add_root_certificate(mut self, cert: Certificate) -> Self {
self.inner = self.inner.add_root_certificate(cert);
self
}
#[must_use]
pub fn identity(mut self, identity: Identity) -> Self {
self.inner = self.inner.identity(identity);
self
}
#[cfg(unix)]
#[must_use]
pub fn unix_socket<P>(mut self, path: P) -> Self
where
P: AsRef<Path>,
{
self.inner = self.inner.unix_socket(path.as_ref());
self
}
pub fn build(self) -> Result<HttpClient, HttpClientBuildError> {
let proxy_policy = ProxyPolicy::resolve(self.proxy_policy)?;
let inner = match proxy_policy {
ProxyPolicy::System => self.inner,
ProxyPolicy::Disabled => self.inner.no_proxy(),
};
inner.build().map_err(Into::into)
}
}
pub fn http_client() -> Result<HttpClient, HttpClientBuildError> {
HttpClientBuilder::new().build()
}
pub fn test_http_client() -> Result<HttpClient, HttpClientBuildError> {
HttpClientBuilder::new()
.proxy_policy(ProxyPolicy::Disabled)
.build()
}
#[derive(Default)]
pub struct BlockingHttpClientBuilder {
inner: reqwest::blocking::ClientBuilder,
proxy_policy: Option<ProxyPolicy>,
}
impl BlockingHttpClientBuilder {
#[must_use]
pub fn new() -> Self {
Self {
inner: reqwest::blocking::Client::builder(),
proxy_policy: None,
}
}
#[must_use]
pub fn proxy_policy(mut self, proxy_policy: ProxyPolicy) -> Self {
self.proxy_policy = Some(proxy_policy);
self
}
#[must_use]
pub fn no_proxy(mut self) -> Self {
self.inner = self.inner.no_proxy();
self
}
#[must_use]
pub fn proxy(mut self, proxy: Proxy) -> Self {
self.inner = self.inner.proxy(proxy);
self
}
#[must_use]
pub fn user_agent(mut self, value: impl Into<String>) -> Self {
self.inner = self.inner.user_agent(value.into());
self
}
#[must_use]
pub fn default_headers(mut self, headers: HeaderMap) -> Self {
self.inner = self.inner.default_headers(headers);
self
}
#[must_use]
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.inner = self.inner.connect_timeout(timeout);
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.inner = self.inner.timeout(timeout);
self
}
#[must_use]
pub fn use_rustls_tls(mut self) -> Self {
self.inner = self.inner.use_rustls_tls();
self
}
#[must_use]
pub fn danger_accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
self.inner = self.inner.danger_accept_invalid_certs(accept_invalid_certs);
self
}
#[must_use]
pub fn add_root_certificate(mut self, cert: Certificate) -> Self {
self.inner = self.inner.add_root_certificate(cert);
self
}
#[must_use]
pub fn identity(mut self, identity: Identity) -> Self {
self.inner = self.inner.identity(identity);
self
}
#[cfg(unix)]
#[must_use]
pub fn unix_socket<P>(mut self, path: P) -> Self
where
P: AsRef<Path>,
{
self.inner = self.inner.unix_socket(path.as_ref());
self
}
pub fn build(self) -> Result<BlockingHttpClient, HttpClientBuildError> {
let proxy_policy = ProxyPolicy::resolve(self.proxy_policy)?;
let inner = match proxy_policy {
ProxyPolicy::System => self.inner,
ProxyPolicy::Disabled => self.inner.no_proxy(),
};
inner.build().map_err(Into::into)
}
}
pub fn blocking_http_client() -> Result<BlockingHttpClient, HttpClientBuildError> {
BlockingHttpClientBuilder::new().build()
}
pub fn blocking_test_http_client() -> Result<BlockingHttpClient, HttpClientBuildError> {
BlockingHttpClientBuilder::new()
.proxy_policy(ProxyPolicy::Disabled)
.build()
}
#[cfg(test)]
mod tests {
use super::*;
struct EnvGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.original.as_ref() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn proxy_policy_defaults_to_system() {
let _guard = EnvGuard::set(HTTP_PROXY_POLICY_ENV, None);
assert_eq!(ProxyPolicy::resolve(None).unwrap(), ProxyPolicy::System);
}
#[test]
fn proxy_policy_reads_disabled_from_env() {
let _guard = EnvGuard::set(HTTP_PROXY_POLICY_ENV, Some("disabled"));
assert_eq!(ProxyPolicy::resolve(None).unwrap(), ProxyPolicy::Disabled);
}
#[test]
fn proxy_policy_rejects_invalid_env_values() {
let _guard = EnvGuard::set(HTTP_PROXY_POLICY_ENV, Some("bogus"));
let error = ProxyPolicy::resolve(None).unwrap_err();
assert!(
error
.to_string()
.contains("expected `system` or `disabled`")
);
}
#[test]
fn explicit_proxy_policy_overrides_env() {
let _guard = EnvGuard::set(HTTP_PROXY_POLICY_ENV, Some("system"));
assert_eq!(
ProxyPolicy::resolve(Some(ProxyPolicy::Disabled)).unwrap(),
ProxyPolicy::Disabled
);
}
}

View file

@ -27,11 +27,11 @@ rand.workspace = true
futures.workspace = true
tokio-stream.workspace = true
async-trait.workspace = true
reqwest.workspace = true
base64.workspace = true
bytes.workspace = true
tokio-util.workspace = true
tracing.workspace = true
fabro-http.workspace = true
fabro-model = { path = "../fabro-model" }
fabro-util = { path = "../fabro-util" }

View file

@ -987,7 +987,7 @@ struct SseReaderState {
impl SseReaderState {
fn new(
http_resp: reqwest::Response,
http_resp: fabro_http::Response,
rate_limit: Option<RateLimitInfo>,
json_schema_mode: bool,
stream_read_timeout: Option<std::time::Duration>,
@ -1078,7 +1078,7 @@ fn build_api_request(
adapter: &Adapter,
request: &Request,
stream: bool,
) -> (ApiRequest, reqwest::RequestBuilder) {
) -> (ApiRequest, fabro_http::RequestBuilder) {
let (system, other_messages) = extract_system_prompt(&request.messages);
let mut api_messages = translate_messages(&other_messages);

View file

@ -1,6 +1,6 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use reqwest::header::HeaderMap;
use fabro_http::HeaderMap;
use tokio::time;
use tracing::warn;
@ -169,7 +169,7 @@ pub fn parse_rate_limit_headers(headers: &HeaderMap) -> Option<RateLimitInfo> {
/// Returns `Error::Network` on connection failure or `Error::Provider` on
/// non-success status.
pub async fn send_and_read_response(
request: reqwest::RequestBuilder,
request: fabro_http::RequestBuilder,
provider: &str,
error_code_field: &str,
) -> Result<(String, HeaderMap), Error> {
@ -209,18 +209,18 @@ pub async fn send_and_read_response(
/// Shared line reader for SSE streams.
///
/// Buffers bytes from a `reqwest::Response` and splits them by a configurable
/// delimiter (e.g. `"\n"` for Gemini/OpenAI-compatible, `"\n\n"` for
/// Anthropic/OpenAI SSE event blocks).
/// Buffers bytes from a `fabro_http::Response` and splits them by a
/// configurable delimiter (e.g. `"\n"` for Gemini/OpenAI-compatible, `"\n\n"`
/// for Anthropic/OpenAI SSE event blocks).
pub struct LineReader {
response: reqwest::Response,
response: fabro_http::Response,
buffer: String,
stream_read_timeout: Option<std::time::Duration>,
}
impl LineReader {
pub fn new(
response: reqwest::Response,
response: fabro_http::Response,
stream_read_timeout: Option<std::time::Duration>,
) -> Self {
Self {

View file

@ -10,14 +10,14 @@ use crate::types::{FinishReason, Message, Request, Response, StreamEvent, TokenC
/// `/completions` endpoint, delegating to whatever real provider the server
/// is configured with.
pub struct Adapter {
client: reqwest::Client,
client: fabro_http::HttpClient,
base_url: String,
provider_name: String,
}
impl Adapter {
pub fn new(
client: reqwest::Client,
client: fabro_http::HttpClient,
base_url: impl Into<String>,
provider_name: impl Into<String>,
) -> Self {
@ -74,11 +74,11 @@ fn build_body(request: &Request, stream: bool) -> Result<serde_json::Value, Erro
///
/// Handles timeout/network error mapping and non-2xx status codes.
async fn send_request(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
url: &str,
body: &serde_json::Value,
provider: &str,
) -> Result<reqwest::Response, Error> {
) -> Result<fabro_http::Response, Error> {
let http_resp = client.post(url).json(body).send().await.map_err(|e| {
if e.is_timeout() {
Error::request_timeout(e.to_string(), e)
@ -228,8 +228,8 @@ mod tests {
use crate::error::ProviderErrorKind;
use crate::types::Message;
fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
fn make_request() -> Request {

View file

@ -1,7 +1,7 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_http::HeaderMap;
use futures::stream;
use reqwest::header::HeaderMap;
use crate::error::{
Error, ProviderErrorDetail, ProviderErrorKind, error_from_grpc_status, error_from_status_code,
@ -512,7 +512,7 @@ fn parse_usage(metadata: Option<&UsageMetadata>) -> TokenCounts {
/// Like `send_and_read_response` but uses gRPC status code mapping when
/// available.
async fn send_gemini_response(
request: reqwest::RequestBuilder,
request: fabro_http::RequestBuilder,
) -> Result<(String, HeaderMap), Error> {
let http_resp = request.send().await.map_err(|e| {
if e.is_timeout() {
@ -567,13 +567,13 @@ fn gemini_error(
}
}
/// Send an HTTP request for streaming and return the `reqwest::Response`.
/// Send an HTTP request for streaming and return the `fabro_http::Response`.
///
/// Checks for HTTP errors before returning. On error, reads the body and
/// maps it to `Error` using gRPC status code mapping when available.
async fn send_streaming_request(
request: reqwest::RequestBuilder,
) -> Result<reqwest::Response, Error> {
request: fabro_http::RequestBuilder,
) -> Result<fabro_http::Response, Error> {
let http_resp = request
.send()
.await
@ -596,7 +596,7 @@ async fn send_streaming_request(
/// Process a stream of SSE chunks from the Gemini `streamGenerateContent`
/// endpoint and yield `StreamEvent` values.
fn process_sse_stream(
http_resp: reqwest::Response,
http_resp: fabro_http::Response,
model: String,
rate_limit: Option<RateLimitInfo>,
stream_read_timeout: Option<std::time::Duration>,
@ -712,7 +712,7 @@ struct SseStreamState {
impl SseStreamState {
fn new(
http_resp: reqwest::Response,
http_resp: fabro_http::Response,
model: String,
rate_limit: Option<RateLimitInfo>,
stream_read_timeout: Option<std::time::Duration>,

View file

@ -12,16 +12,16 @@ pub struct HttpApi {
pub(crate) api_key: String,
pub(crate) base_url: String,
pub(crate) default_headers: HashMap<String, String>,
pub(crate) client: reqwest::Client,
pub(crate) client: fabro_http::HttpClient,
pub(crate) request_timeout: Option<Duration>,
pub(crate) stream_read_timeout: Option<Duration>,
}
impl HttpApi {
fn build_client(timeout: AdapterTimeout) -> reqwest::Client {
fn build_client(timeout: AdapterTimeout) -> fabro_http::HttpClient {
#[cfg(test)]
{
reqwest::Client::builder()
fabro_http::HttpClientBuilder::new()
.connect_timeout(Duration::from_secs_f64(timeout.connect))
.no_proxy()
.build()
@ -29,7 +29,7 @@ impl HttpApi {
}
#[cfg(not(test))]
{
reqwest::Client::builder()
fabro_http::HttpClientBuilder::new()
.connect_timeout(Duration::from_secs_f64(timeout.connect))
.build()
.unwrap_or_default()

View file

@ -80,9 +80,9 @@ impl Adapter {
}
}
/// Build a `reqwest::RequestBuilder` with default headers, org/project
/// Build a `fabro_http::RequestBuilder` with default headers, org/project
/// headers, and auth.
fn build_request(&self, url: &str) -> reqwest::RequestBuilder {
fn build_request(&self, url: &str) -> fabro_http::RequestBuilder {
let mut req = self.http.client.post(url);
// Apply default_headers first so adapter-specific headers can override
for (key, value) in &self.http.default_headers {
@ -1597,7 +1597,7 @@ mod tests {
fn empty_sse_state() -> SseStreamState {
let http_resp = http::Response::builder().status(200).body("").unwrap();
let response = reqwest::Response::from(http_resp);
let response = fabro_http::Response::from(http_resp);
SseStreamState {
line_reader: LineReader::new(response, None),
model: String::new(),

View file

@ -54,8 +54,8 @@ impl Adapter {
}
}
/// Build a `reqwest::RequestBuilder` with default headers and auth.
fn build_request(&self, url: &str) -> reqwest::RequestBuilder {
/// Build a `fabro_http::RequestBuilder` with default headers and auth.
fn build_request(&self, url: &str) -> fabro_http::RequestBuilder {
let mut req = self.http.client.post(url);
// Apply default_headers first so adapter-specific headers can override
for (key, value) in &self.http.default_headers {
@ -688,7 +688,7 @@ struct StreamState {
impl StreamState {
fn new(
response: reqwest::Response,
response: fabro_http::Response,
provider_name: String,
model: String,
rate_limit: Option<RateLimitInfo>,
@ -969,7 +969,7 @@ mod tests {
#[test]
fn stream_state_process_text_chunks() {
let http_resp =
reqwest::Response::from(http::Response::builder().status(200).body("").unwrap());
fabro_http::Response::from(http::Response::builder().status(200).body("").unwrap());
let mut state = StreamState::new(
http_resp,
"test".into(),
@ -1001,7 +1001,7 @@ mod tests {
#[test]
fn stream_state_process_tool_call_chunks() {
let http_resp =
reqwest::Response::from(http::Response::builder().status(200).body("").unwrap());
fabro_http::Response::from(http::Response::builder().status(200).body("").unwrap());
let mut state = StreamState::new(
http_resp,
"test".into(),
@ -1032,7 +1032,7 @@ mod tests {
#[test]
fn stream_state_finish_events_text_only() {
let http_resp =
reqwest::Response::from(http::Response::builder().status(200).body("").unwrap());
fabro_http::Response::from(http::Response::builder().status(200).body("").unwrap());
let mut state = StreamState::new(
http_resp,
"test-provider".into(),
@ -1075,7 +1075,7 @@ mod tests {
#[test]
fn stream_state_finish_events_with_tool_calls() {
let http_resp =
reqwest::Response::from(http::Response::builder().status(200).body("").unwrap());
fabro_http::Response::from(http::Response::builder().status(200).body("").unwrap());
let mut state = StreamState::new(
http_resp,
"test".into(),
@ -1120,7 +1120,7 @@ mod tests {
#[test]
fn stream_state_uses_request_model_as_fallback() {
let http_resp =
reqwest::Response::from(http::Response::builder().status(200).body("").unwrap());
fabro_http::Response::from(http::Response::builder().status(200).body("").unwrap());
let mut state = StreamState::new(
http_resp,
"test".into(),

View file

@ -1,3 +1,5 @@
#![allow(clippy::disallowed_methods, clippy::disallowed_types)]
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;

View file

@ -15,7 +15,7 @@ workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
reqwest.workspace = true
fabro-http.workspace = true
sha2.workspace = true
base64.workspace = true
rand.workspace = true

View file

@ -107,7 +107,7 @@ pub struct TokenResponse {
// ---------------------------------------------------------------------------
pub async fn exchange_code_for_tokens(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
issuer: &str,
client_id: &str,
code: &str,
@ -154,7 +154,7 @@ pub async fn exchange_code_for_tokens(
// ---------------------------------------------------------------------------
pub async fn refresh_access_token(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
issuer: &str,
client_id: &str,
refresh_token: &str,
@ -394,7 +394,7 @@ pub async fn run_browser_flow(
.map_err(|_| "Did not receive authorization code".to_string())?
.map_err(|e| format!("Authorization failed: {e}"))?;
let client = reqwest::Client::new();
let client = fabro_http::http_client().map_err(|e| e.to_string())?;
exchange_code_for_tokens(
&client,
issuer,
@ -414,8 +414,8 @@ pub async fn run_browser_flow(
mod tests {
use super::*;
fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
// -----------------------------------------------------------------------

View file

@ -32,6 +32,7 @@ fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
fabro-api = { path = "../fabro-api" }
fabro-store = { path = "../fabro-store" }
fabro-http.workspace = true
chrono.workspace = true
futures-util.workspace = true
axum.workspace = true
@ -65,7 +66,6 @@ uuid.workspace = true
hmac.workspace = true
sha2.workspace = true
hex.workspace = true
reqwest.workspace = true
rand.workspace = true
bytes = "1"
tempfile = "3"

View file

@ -321,7 +321,18 @@ async fn check_github_app(state: &AppState) -> CheckResult {
}
};
let http = reqwest::Client::new();
let http = match fabro_http::http_client() {
Ok(http) => http,
Err(err) => {
return CheckResult {
name: "GitHub CLI".to_string(),
status: CheckStatus::Error,
summary: "client error".to_string(),
details: vec![CheckDetail::new(err.to_string())],
remediation: Some(err.to_string()),
};
}
};
let probe = timeout(
Duration::from_secs(15),
http.get(format!("{}/user", fabro_github::github_api_base_url()))
@ -340,7 +351,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
details: Vec::new(),
remediation: None,
},
Ok(Ok(response)) if response.status() == reqwest::StatusCode::UNAUTHORIZED => {
Ok(Ok(response)) if response.status() == fabro_http::StatusCode::UNAUTHORIZED => {
CheckResult {
name: "GitHub CLI".to_string(),
status: CheckStatus::Error,
@ -461,7 +472,18 @@ async fn check_github_app(state: &AppState) -> CheckResult {
}
};
let http = reqwest::Client::new();
let http = match fabro_http::http_client() {
Ok(http) => http,
Err(err) => {
return CheckResult {
name: "GitHub App".to_string(),
status: CheckStatus::Error,
summary: "client error".to_string(),
details: vec![CheckDetail::new(err.to_string())],
remediation: Some(err.to_string()),
};
}
};
let auth_result = timeout(
Duration::from_secs(15),
fabro_github::get_authenticated_app(&http, &jwt, &fabro_github::github_api_base_url()),
@ -523,9 +545,21 @@ async fn check_brave_search(state: &AppState) -> CheckResult {
};
};
let probe = timeout(Duration::from_secs(15), async {
reqwest::Client::new()
.get("https://api.search.brave.com/res/v1/web/search?q=test&count=1")
let http = match fabro_http::http_client() {
Ok(http) => http,
Err(err) => {
return CheckResult {
name: "Brave Search".to_string(),
status: CheckStatus::Warning,
summary: "client error".to_string(),
details: vec![CheckDetail::new(err.to_string())],
remediation: Some(err.to_string()),
};
}
};
let probe = timeout(Duration::from_secs(15), async move {
http.get("https://api.search.brave.com/res/v1/web/search?q=test&count=1")
.header("X-Subscription-Token", api_key)
.send()
.await

View file

@ -255,7 +255,7 @@ async fn update_github_app_webhook(
let jwt =
fabro_github::sign_app_jwt(app_id, private_key_pem).map_err(|e| anyhow::anyhow!(e))?;
let client = reqwest::Client::new();
let client = fabro_http::http_client()?;
let body = serde_json::json!({
"url": webhook_url,
"content_type": "json",
@ -287,8 +287,8 @@ mod tests {
use super::*;
fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
// -----------------------------------------------------------------------

View file

@ -769,7 +769,7 @@ async fn mint_github_token(
};
let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem)
.map_err(|err| anyhow!("{err}"))?;
let client = reqwest::Client::new();
let client = fabro_http::http_client()?;
let perms_json = serde_json::to_value(permissions)?;
fabro_github::create_installation_access_token_with_permissions(
&client,

View file

@ -1708,7 +1708,7 @@ async fn get_github_repo(
let settings = state.server_settings();
let github_settings = &settings.integrations.github;
let base_url = fabro_github::github_api_base_url();
let mut client = None;
let mut client: Option<fabro_http::HttpClient> = None;
let token = match github_settings.strategy {
GithubIntegrationStrategy::App => {
let Some(app_id) = github_settings.app_id.as_ref() else {
@ -1754,9 +1754,18 @@ async fn get_github_repo(
None => format!("https://github.com/organizations/{owner}/settings/installations"),
};
let client_ref = client.get_or_insert_with(reqwest::Client::new);
if client.is_none() {
client = Some(match fabro_http::http_client() {
Ok(http) => http,
Err(err) => {
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
.into_response();
}
});
}
let client_ref = client.as_ref().expect("client initialized above");
let installed = match fabro_github::check_app_installed(
&*client_ref,
client_ref,
&jwt,
&owner,
&name,
@ -1787,7 +1796,7 @@ async fn get_github_repo(
}
match fabro_github::create_installation_access_token_with_permissions(
&*client_ref,
client_ref,
&jwt,
&owner,
&name,
@ -1816,7 +1825,16 @@ async fn get_github_repo(
},
};
let client = client.unwrap_or_else(reqwest::Client::new);
let client = match client {
Some(client) => client,
None => match fabro_http::http_client() {
Ok(http) => http,
Err(err) => {
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
.into_response();
}
},
};
let repo_response = match client
.get(format!("{base_url}/repos/{owner}/{name}"))
.header("Authorization", format!("Bearer {token}"))
@ -1830,7 +1848,7 @@ async fn get_github_repo(
if github_settings.strategy == GithubIntegrationStrategy::GhCli
&& matches!(
response.status(),
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND
fabro_http::StatusCode::FORBIDDEN | fabro_http::StatusCode::NOT_FOUND
) =>
{
return (
@ -1849,7 +1867,7 @@ async fn get_github_repo(
}
Ok(response)
if github_settings.strategy == GithubIntegrationStrategy::GhCli
&& response.status() == reqwest::StatusCode::UNAUTHORIZED =>
&& response.status() == fabro_http::StatusCode::UNAUTHORIZED =>
{
return ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,

View file

@ -207,7 +207,7 @@ async fn login_github(State(state): State<Arc<AppState>>) -> Response {
let state_token = format!("fabro-{}", ulid::Ulid::new());
let authorize_url =
reqwest::Url::parse_with_params("https://github.com/login/oauth/authorize", &[
fabro_http::Url::parse_with_params("https://github.com/login/oauth/authorize", &[
("client_id", client_id.as_str()),
("redirect_uri", &format!("{web_url}/auth/callback/github")),
("scope", "read:user user:email"),
@ -286,7 +286,16 @@ async fn callback_github(
}
};
let http = reqwest::Client::new();
let http = match fabro_http::http_client() {
Ok(http) => http,
Err(err) => {
error!(error = %err, "OAuth callback failed: could not build GitHub HTTP client");
return json_response(
StatusCode::SERVICE_UNAVAILABLE,
json!({"error": format!("Failed to build GitHub HTTP client: {err}")}),
);
}
};
let token = match http
.post("https://github.com/login/oauth/access_token")
.header(header::ACCEPT, "application/json")
@ -522,10 +531,19 @@ async fn setup_register(
.get(header::ORIGIN)
.or_else(|| headers.get(header::REFERER))
.and_then(|v| v.to_str().ok())
.and_then(|s| reqwest::Url::parse(s).ok())
.and_then(|s| fabro_http::Url::parse(s).ok())
.map(|url| format!("{}://{}", url.scheme(), url.authority()));
let http = reqwest::Client::new();
let http = match fabro_http::http_client() {
Ok(http) => http,
Err(err) => {
error!(error = %err, "Setup register failed: could not build GitHub HTTP client");
return json_response(
StatusCode::SERVICE_UNAVAILABLE,
json!({"error": format!("Failed to build GitHub HTTP client: {err}")}),
);
}
};
let response = match http
.post(format!(
"https://api.github.com/app-manifests/{}/conversions",

View file

@ -62,11 +62,11 @@ fn build_client(
ca_cert_path: &Path,
client_cert_path: Option<&Path>,
client_key_path: Option<&Path>,
) -> reqwest::Client {
) -> fabro_http::HttpClient {
let ca_pem = std::fs::read(ca_cert_path).unwrap();
let ca_cert = reqwest::tls::Certificate::from_pem(&ca_pem).unwrap();
let ca_cert = fabro_http::tls::Certificate::from_pem(&ca_pem).unwrap();
let mut builder = reqwest::Client::builder()
let mut builder = fabro_http::HttpClientBuilder::new()
.add_root_certificate(ca_cert)
.no_proxy()
.use_rustls_tls();
@ -76,7 +76,7 @@ fn build_client(
let key_pem = std::fs::read(key_path).unwrap();
let mut identity_pem = cert_pem;
identity_pem.extend_from_slice(&key_pem);
let identity = reqwest::tls::Identity::from_pem(&identity_pem).unwrap();
let identity = fabro_http::tls::Identity::from_pem(&identity_pem).unwrap();
builder = builder.identity(identity);
}

View file

@ -15,13 +15,13 @@ workspace = true
[dependencies]
fabro-interview = { path = "../fabro-interview" }
fabro-workflow = { path = "../fabro-workflow" }
fabro-http.workspace = true
futures-util.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
thiserror.workspace = true
tokio-tungstenite.workspace = true
reqwest.workspace = true
tracing.workspace = true
[dev-dependencies]

View file

@ -1,4 +1,3 @@
use reqwest::Client;
use serde_json::{Value, json};
use tracing::debug;
@ -14,7 +13,7 @@ pub struct PostedMessage {
pub struct SlackClient {
bot_token: String,
api_base: String,
http: Client,
http: fabro_http::HttpClient,
}
impl SlackClient {
@ -24,11 +23,11 @@ impl SlackClient {
Self {
bot_token,
api_base,
http: Client::new(),
http: fabro_http::http_client().expect("Slack HTTP client should build"),
}
}
pub fn http(&self) -> &Client {
pub fn http(&self) -> &fabro_http::HttpClient {
&self.http
}

View file

@ -57,7 +57,7 @@ pub fn process_message(
/// Fetch a WebSocket URL from Slack's `apps.connections.open` endpoint.
pub async fn open_socket_url(
http: &reqwest::Client,
http: &fabro_http::HttpClient,
app_token: &str,
) -> Result<String, ConnectionError> {
let resp = http

View file

@ -18,13 +18,13 @@ base64.workspace = true
chrono.workspace = true
dirs.workspace = true
exec.workspace = true
fabro-http.workspace = true
fabro-util = { path = "../fabro-util" }
fork.workspace = true
git2.workspace = true
mac_address.workspace = true
md5.workspace = true
regex.workspace = true
reqwest = { workspace = true, features = ["blocking"] }
sentry.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -2,7 +2,6 @@ use std::path::Path;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use reqwest::blocking::Client as BlockingClient;
use uuid::Uuid;
use crate::event::Track;
@ -102,7 +101,7 @@ pub fn upload_blocking(tracks: &[Track]) -> anyhow::Result<()> {
let auth = STANDARD.encode(format!("{write_key}:"));
let resp = BlockingClient::new()
let resp = fabro_http::blocking_http_client()?
.post(format!("{SEGMENT_BASE_URL}/v1/batch"))
.header("Authorization", format!("Basic {auth}"))
.json(&payload)
@ -130,7 +129,7 @@ pub async fn upload(path: &Path) -> anyhow::Result<()> {
let auth = STANDARD.encode(format!("{write_key}:"));
let resp = reqwest::Client::new()
let resp = fabro_http::http_client()?
.post(format!("{SEGMENT_BASE_URL}/v1/batch"))
.header("Authorization", format!("Basic {auth}"))
.json(&payload)

View file

@ -18,9 +18,9 @@ axum = { workspace = true }
fabro-config = { path = "../fabro-config" }
fabro-proc = { path = "../fabro-proc" }
fabro-types = { path = "../fabro-types" }
fabro-http.workspace = true
insta = { workspace = true, features = ["filters"] }
regex = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = "3"

View file

@ -605,6 +605,7 @@ fn ensure_server_running(fabro_bin: &Path, server: &ServerPaths, config_path: &P
let output = std::process::Command::new(fabro_bin)
.env("NO_COLOR", "1")
.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled")
.env("FABRO_SERVER_MAX_CONCURRENT_RUNS", "64")
.env(TEST_IN_MEMORY_STORE_ENV, "1")
.env("FABRO_HOME", &server.root)
@ -930,7 +931,8 @@ impl TestContext {
}
cmd.env("NO_COLOR", "1");
cmd.env("HOME", &self.home_dir);
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
cmd.env("FABRO_NO_UPGRADE_CHECK", "true")
.env("FABRO_HTTP_PROXY_POLICY", "disabled");
cmd.env("FABRO_SERVER_MAX_CONCURRENT_RUNS", "64");
cmd.env(TEST_IN_MEMORY_STORE_ENV, "1");
cmd
@ -1385,8 +1387,8 @@ pub struct TwinGitHub {
server: twin_github::TestServer,
}
pub fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
pub fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
impl TwinGitHub {

View file

@ -15,11 +15,11 @@ workspace = true
[dependencies]
fabro-github = { path = "../fabro-github" }
async-trait.workspace = true
fabro-http.workspace = true
serde_json.workspace = true
reqwest.workspace = true
tracing.workspace = true
tokio = { workspace = true }
[dev-dependencies]
httpmock = "0.8"
tokio = { workspace = true, features = ["test-util", "macros"] }
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -8,7 +8,7 @@ use crate::{Issue, Tracker, execute_graphql_request};
/// Execute a GitHub GraphQL request and return the response JSON.
async fn execute_github_graphql(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
token: &str,
endpoint: &str,
query: &str,
@ -30,7 +30,7 @@ async fn execute_github_graphql(
/// Scoped to a single project board identified by `project_number`.
pub struct GitHubTracker {
creds: GitHubCredentials,
client: reqwest::Client,
client: fabro_http::HttpClient,
owner: String,
repo: String,
project_number: u64,
@ -41,7 +41,7 @@ pub struct GitHubTracker {
impl GitHubTracker {
pub fn new(
creds: GitHubCredentials,
client: reqwest::Client,
client: fabro_http::HttpClient,
owner: String,
repo: String,
project_number: u64,
@ -206,7 +206,7 @@ fn normalize_github_item(item: &serde_json::Value) -> Option<Issue> {
/// Fetch one page of project items. Returns (items, has_next_page, end_cursor).
async fn fetch_project_items_page(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
token: &str,
graphql_url: &str,
project_node_id: &str,
@ -502,8 +502,8 @@ mod tests {
use super::*;
use crate::Issue;
fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
fn test_rsa_key() -> String {

View file

@ -12,7 +12,7 @@ pub use linear::{LINEAR_API_ENDPOINT, LinearOptions, LinearTracker};
/// as the `Authorization` value, and returns the parsed JSON response.
/// Provider-specific error messages use `provider` as a label.
pub(crate) async fn execute_graphql_request(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
endpoint: &str,
auth_header: &str,
provider: &str,

View file

@ -128,7 +128,7 @@ fn normalize_issue(node: &Value) -> Result<Issue, String> {
}
async fn execute_graphql(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
config: &LinearOptions,
query: &str,
variables: Value,
@ -155,12 +155,16 @@ fn extract_issues(response: &Value) -> Result<Vec<Issue>, String> {
/// A `Tracker` implementation backed by Linear.
pub struct LinearTracker {
config: LinearOptions,
client: reqwest::Client,
client: fabro_http::HttpClient,
project_slug: String,
}
impl LinearTracker {
pub fn new(config: LinearOptions, client: reqwest::Client, project_slug: String) -> Self {
pub fn new(
config: LinearOptions,
client: fabro_http::HttpClient,
project_slug: String,
) -> Self {
Self {
config,
client,
@ -355,8 +359,8 @@ impl Tracker for LinearTracker {
#[cfg(test)]
mod tests {
use super::*;
fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
fn mock_config(server_url: &str) -> LinearOptions {

View file

@ -37,6 +37,7 @@ fabro-retro = { path = "../fabro-retro" }
fabro-core = { path = "../fabro-core" }
fabro-store = { path = "../fabro-store" }
fabro-types = { path = "../fabro-types" }
fabro-http.workspace = true
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
@ -61,7 +62,6 @@ git2.workspace = true
tokio-util.workspace = true
tracing.workspace = true
walkdir.workspace = true
reqwest.workspace = true
tempfile = "3"
toml.workspace = true
[dev-dependencies]

View file

@ -218,7 +218,7 @@ async fn mint_github_token(
};
let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem)
.map_err(|e| Error::engine(e.clone()))?;
let client = reqwest::Client::new();
let client = fabro_http::http_client().map_err(|e| Error::engine(e.to_string()))?;
let perms_json = serde_json::to_value(permissions).map_err(|e| Error::engine(e.to_string()))?;
fabro_github::create_installation_access_token_with_permissions(
&client,

View file

@ -1708,7 +1708,7 @@ async fn daytona_toolbox_idle_diagnostic() {
// Diagnose with raw HTTP calls
let api_key = std::env::var("DAYTONA_API_KEY").unwrap_or_default();
let client = reqwest::Client::builder()
let client = fabro_http::HttpClientBuilder::new()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap();

View file

@ -16,6 +16,7 @@ workspace = true
axum = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true }
fabro-http.workspace = true
jsonwebtoken = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@ -26,5 +27,4 @@ tracing-subscriber = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
reqwest = { workspace = true }
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -96,7 +96,7 @@ mod tests {
use crate::test_support::{sign_test_jwt, test_http_client, test_rsa_private_key};
async fn get_installation_token(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
jwt: &str,
owner: &str,
repo: &str,

View file

@ -620,7 +620,7 @@ mod tests {
use crate::test_support::{sign_test_jwt, test_http_client, test_rsa_private_key};
async fn get_installation_token(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
jwt: &str,
owner: &str,
repo: &str,
@ -663,7 +663,7 @@ mod tests {
async fn setup_with_token(
state: &mut AppState,
pem: &str,
) -> (TestServer, reqwest::Client, String) {
) -> (TestServer, fabro_http::HttpClient, String) {
state.register_app(AppOptions {
app_id: "100".to_string(),
slug: "test-app".to_string(),

View file

@ -306,7 +306,7 @@ mod tests {
use crate::test_support::{sign_test_jwt, test_http_client, test_rsa_private_key};
async fn get_installation_token(
client: &reqwest::Client,
client: &fabro_http::HttpClient,
jwt: &str,
owner: &str,
repo: &str,
@ -342,7 +342,7 @@ mod tests {
async fn setup_and_get_token(
state: &mut AppState,
pem: &str,
) -> (TestServer, reqwest::Client, String) {
) -> (TestServer, fabro_http::HttpClient, String) {
state.register_app(AppOptions {
app_id: "100".to_string(),
slug: "test-app".to_string(),

View file

@ -1,6 +1,6 @@
#[cfg(test)]
pub fn test_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
pub fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
#[cfg(test)]

View file

@ -16,9 +16,9 @@ workspace = true
anyhow.workspace = true
async-stream = "0.3"
axum = { workspace = true, features = ["macros"] }
fabro-http.workspace = true
futures-util.workspace = true
http = "1"
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true

View file

@ -6,9 +6,9 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use anyhow::Result;
use fabro_http::{HttpClient as Client, HttpClientBuilder};
use fabro_http::header::AUTHORIZATION;
use futures_util::StreamExt;
use reqwest::Client;
use reqwest::header::AUTHORIZATION;
use serde_json::Value;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
@ -31,7 +31,7 @@ pub struct ApiClient {
}
pub struct RecordedResponse {
pub status: reqwest::StatusCode,
pub status: fabro_http::StatusCode,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
@ -43,7 +43,7 @@ pub struct RawStreamResponse {
}
pub struct TimedStreamResponse {
pub status: reqwest::StatusCode,
pub status: fabro_http::StatusCode,
pub first_event_elapsed: Duration,
pub chunks: Vec<String>,
}
@ -64,7 +64,7 @@ pub struct ParsedSseEvent {
static NEXT_BEARER_TOKEN: AtomicU64 = AtomicU64::new(1);
pub fn test_http_client() -> Result<Client> {
Client::builder().no_proxy().build().map_err(Into::into)
fabro_http::test_http_client().map_err(Into::into)
}
pub async fn spawn_server() -> Result<TestServer> {
@ -95,8 +95,8 @@ fn authorization_header_value(bearer_token: &str) -> String {
}
fn build_authenticated_client(bearer_token: &str) -> Result<Client> {
Client::builder()
.no_proxy()
HttpClientBuilder::new()
.proxy_policy(fabro_http::ProxyPolicy::Disabled)
.default_headers(
[(
AUTHORIZATION,
@ -120,8 +120,8 @@ impl ApiClient {
) -> Result<Self> {
Ok(Self {
base_url: base_url.into(),
client: Client::builder()
.no_proxy()
client: HttpClientBuilder::new()
.proxy_policy(fabro_http::ProxyPolicy::Disabled)
.timeout(Duration::from_secs(30))
.build()?,
bearer_token,
@ -146,7 +146,7 @@ impl ApiClient {
}
}
pub async fn post_json(&self, path: &str, body: &Value) -> reqwest::Response {
pub async fn post_json(&self, path: &str, body: &Value) -> fabro_http::Response {
self.post(path)
.json(body)
.send()
@ -168,15 +168,15 @@ impl ApiClient {
.await
}
pub fn post(&self, path: &str) -> reqwest::RequestBuilder {
pub fn post(&self, path: &str) -> fabro_http::RequestBuilder {
self.request(self.client.post(format!("{}{}", self.base_url, path)))
}
pub fn get(&self, path: &str) -> reqwest::RequestBuilder {
pub fn get(&self, path: &str) -> fabro_http::RequestBuilder {
self.request(self.client.get(format!("{}{}", self.base_url, path)))
}
fn request(&self, mut request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
fn request(&self, mut request: fabro_http::RequestBuilder) -> fabro_http::RequestBuilder {
if let Some(token) = &self.bearer_token {
request = request.bearer_auth(token);
}
@ -224,7 +224,7 @@ impl TestServer {
}
impl TestServer {
pub async fn post_responses(&self, body: Value) -> reqwest::Response {
pub async fn post_responses(&self, body: Value) -> fabro_http::Response {
self.auth_client
.post(format!("{}/v1/responses", self.base_url))
.json(&body)
@ -238,7 +238,7 @@ impl TestServer {
body: Value,
org: Option<&str>,
project: Option<&str>,
) -> reqwest::Response {
) -> fabro_http::Response {
let mut request = self
.auth_client
.post(format!("{}/v1/responses", self.base_url));
@ -258,7 +258,10 @@ impl TestServer {
.expect("request should complete")
}
pub async fn post_responses_stream(&self, body: Value) -> (reqwest::StatusCode, Vec<String>) {
pub async fn post_responses_stream(
&self,
body: Value,
) -> (fabro_http::StatusCode, Vec<String>) {
let response = self
.auth_client
.post(format!("{}/v1/responses", self.base_url))
@ -280,7 +283,7 @@ impl TestServer {
(status, chunks)
}
pub async fn post_chat(&self, body: Value) -> reqwest::Response {
pub async fn post_chat(&self, body: Value) -> fabro_http::Response {
self.auth_client
.post(format!("{}/v1/chat/completions", self.base_url))
.json(&body)
@ -289,7 +292,7 @@ impl TestServer {
.expect("request should complete")
}
pub async fn post_chat_stream(&self, body: Value) -> (reqwest::StatusCode, Vec<String>) {
pub async fn post_chat_stream(&self, body: Value) -> (fabro_http::StatusCode, Vec<String>) {
let response = self.post_chat(body).await;
let status = response.status();
let mut stream = response.bytes_stream();
@ -308,7 +311,7 @@ impl TestServer {
&self,
body: Value,
authorization: Option<&str>,
) -> reqwest::Response {
) -> fabro_http::Response {
let mut request = self
.client
.post(format!("{}/v1/chat/completions", self.base_url));
@ -491,7 +494,7 @@ fn parse_sse_block(block: &str) -> Result<ParsedSseEvent, String> {
})
}
pub async fn record_response(response: reqwest::Response) -> RecordedResponse {
pub async fn record_response(response: fabro_http::Response) -> RecordedResponse {
let status = response.status();
let headers = response
.headers()

View file

@ -2,7 +2,7 @@ mod common;
use std::time::Duration;
use reqwest::header::AUTHORIZATION;
use fabro_http::header::AUTHORIZATION;
use serde_json::json;
#[tokio::test]
@ -106,7 +106,7 @@ async fn scripted_hang_times_out_client_side() {
}))
.await;
let client = reqwest::Client::builder()
let client = fabro_http::HttpClientBuilder::new()
.no_proxy()
.timeout(Duration::from_millis(150))
.default_headers(

View file

@ -270,7 +270,7 @@ async fn probe_surface_availability(
) -> Result<SurfaceAvailability> {
let response = client.post_json_recorded(path, body).await;
if response.status == reqwest::StatusCode::OK {
if response.status == fabro_http::StatusCode::OK {
return Ok(SurfaceAvailability::Available);
}
@ -1312,7 +1312,7 @@ async fn enqueue_responses_continuation_scenarios(server: &common::TestServer, m
async fn post_json_ok(client: &common::ApiClient, path: &str, body: &Value) -> Result<Value> {
let response = client.post_json_recorded(path, body).await;
ensure!(
response.status == reqwest::StatusCode::OK,
response.status == fabro_http::StatusCode::OK,
"{} returned {}: {}",
path,
response.status,
@ -1328,7 +1328,7 @@ async fn post_sse_ok(
) -> Result<common::ParsedSseTranscript> {
let response = client.post_json_recorded(path, body).await;
ensure!(
response.status == reqwest::StatusCode::OK,
response.status == fabro_http::StatusCode::OK,
"{} returned {}: {}",
path,
response.status,
@ -1362,14 +1362,14 @@ fn classify_live_access_blocker(response: &common::RecordedResponse) -> Option<S
let message = error.get("message").and_then(Value::as_str).unwrap_or("");
match response.status {
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
fabro_http::StatusCode::UNAUTHORIZED | fabro_http::StatusCode::FORBIDDEN
if message.contains("Missing scopes:")
|| message.contains("insufficient permissions")
|| message.contains("correct role in your organization") =>
{
Some("missing required OpenAI API scope or role".to_owned())
}
reqwest::StatusCode::TOO_MANY_REQUESTS if error_type == Some("insufficient_quota") => {
fabro_http::StatusCode::TOO_MANY_REQUESTS if error_type == Some("insufficient_quota") => {
Some("account has insufficient quota for live API calls".to_owned())
}
_ => None,
@ -1391,7 +1391,7 @@ fn ensure_matching_status(
summarize_recorded_body(live)
);
ensure!(
local.status == reqwest::StatusCode::OK,
local.status == fabro_http::StatusCode::OK,
"{} returned non-OK responses: local={} body={} live={} body={}",
label,
local.status,

View file

@ -1,6 +1,6 @@
mod common;
use reqwest::header::AUTHORIZATION;
use fabro_http::header::AUTHORIZATION;
use serde_json::json;
#[tokio::test]