use std::time::Duration;
use serde::Deserialize;
use serde::de::DeserializeOwned;
pub const DEFAULT_API_URL: &str = "https://api.fallow.cloud";
pub const NETWORK_EXIT_CODE: u8 = 7;
const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 5;
const DEFAULT_TOTAL_TIMEOUT_SECS: u64 = 10;
pub fn api_agent() -> ureq::Agent {
api_agent_with_timeout(DEFAULT_CONNECT_TIMEOUT_SECS, DEFAULT_TOTAL_TIMEOUT_SECS)
}
pub fn api_agent_with_timeout(connect_timeout_secs: u64, total_timeout_secs: u64) -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(connect_timeout_secs)))
.timeout_global(Some(Duration::from_secs(total_timeout_secs)))
.http_status_as_error(false)
.build()
.new_agent()
}
pub fn api_url(path: &str) -> String {
let base = std::env::var("FALLOW_API_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_API_URL.to_owned());
format!("{}{path}", base.trim_end_matches('/'))
}
#[derive(Debug, Deserialize, Default)]
pub struct ErrorEnvelope {
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub message: Option<String>,
}
pub fn actionable_error_hint(operation: &str, code: &str) -> Option<&'static str> {
match (operation, code) {
("refresh", "token_stale") => Some(
"your stored license is too stale to refresh. Reactivate with: fallow license activate --trial --email <addr>",
),
("refresh", "invalid_token") => Some(
"your stored license token is missing required claims. Reactivate with: fallow license activate --trial --email <addr>",
),
("refresh" | "trial", "unauthorized") => Some(
"authentication failed. Reactivate with: fallow license activate --trial --email <addr>",
),
("upload-inventory", "unauthorized") => Some(
"authentication failed. Generate an API key at https://fallow.cloud/settings#api-keys and set FALLOW_API_KEY on the runner. Note: this key is separate from the license JWT; `fallow license activate --trial` will not fix this error.",
),
("trial", "rate_limit_exceeded") => Some(
"trial creation is rate-limited to 5 per hour per IP. Wait an hour or retry from a different network (in CI, start the trial locally and set FALLOW_LICENSE on the runner).",
),
("upload-inventory", "payload_too_large") => Some(
"inventory exceeds the 200,000-function server limit. Scope the walk with --exclude-paths, or open an issue if this is a legitimately large repo.",
),
_ => None,
}
}
pub trait ResponseBodyReader {
fn status(&self) -> u16;
fn read_json<T: DeserializeOwned>(&mut self) -> Result<T, ureq::Error>;
fn read_to_string(&mut self) -> Result<String, ureq::Error>;
}
impl ResponseBodyReader for http::Response<ureq::Body> {
fn status(&self) -> u16 {
self.status().as_u16()
}
fn read_json<T: DeserializeOwned>(&mut self) -> Result<T, ureq::Error> {
self.body_mut().read_json::<T>()
}
fn read_to_string(&mut self) -> Result<String, ureq::Error> {
self.body_mut().read_to_string()
}
}
pub fn http_status_message(response: &mut impl ResponseBodyReader, operation: &str) -> String {
let status = response.status();
let body = response.read_to_string().unwrap_or_default();
let envelope: Option<ErrorEnvelope> = serde_json::from_str(&body).ok();
if let Some(envelope) = envelope.as_ref()
&& let Some(code) = envelope.code.as_deref()
&& let Some(hint) = actionable_error_hint(operation, code)
{
return format!("{hint} (HTTP {status}, code {code})");
}
let body_suffix = match envelope.as_ref().and_then(|e| e.message.as_deref()) {
Some(message) if !message.trim().is_empty() => format!(": {}", message.trim()),
_ if !body.trim().is_empty() => format!(": {}", body.trim()),
_ => String::new(),
};
format!("{operation} request failed with HTTP {status}{body_suffix}")
}
#[cfg(test)]
mod tests {
use super::*;
struct StubResponse {
status: u16,
body: String,
}
impl ResponseBodyReader for StubResponse {
fn status(&self) -> u16 {
self.status
}
fn read_json<T: DeserializeOwned>(&mut self) -> Result<T, ureq::Error> {
unreachable!("error-path tests do not read JSON")
}
fn read_to_string(&mut self) -> Result<String, ureq::Error> {
Ok(std::mem::take(&mut self.body))
}
}
#[test]
fn refresh_token_stale_hint_points_to_reactivation() {
let mut response = StubResponse {
status: 401,
body: r#"{"error":true,"message":"token stale","code":"token_stale"}"#.to_owned(),
};
let message = http_status_message(&mut response, "refresh");
assert!(
message.contains("Reactivate with: fallow license activate --trial"),
"expected reactivation hint, got: {message}"
);
assert!(message.contains("token_stale"));
}
#[test]
fn refresh_invalid_token_hint_points_to_reactivation() {
let mut response = StubResponse {
status: 401,
body: r#"{"error":true,"code":"invalid_token"}"#.to_owned(),
};
let message = http_status_message(&mut response, "refresh");
assert!(message.contains("missing required claims"));
assert!(message.contains("invalid_token"));
}
#[test]
fn upload_inventory_unauthorized_points_to_api_keys_not_trial() {
let mut response = StubResponse {
status: 401,
body: r#"{"error":true,"code":"unauthorized"}"#.to_owned(),
};
let message = http_status_message(&mut response, "upload-inventory");
assert!(
message.contains("https://fallow.cloud/settings#api-keys"),
"expected api-keys URL, got: {message}"
);
assert!(
message.contains("FALLOW_API_KEY"),
"expected FALLOW_API_KEY mention, got: {message}"
);
assert!(
message.contains("will not fix"),
"expected explicit 'will not fix this error' disqualifier so users do not retry via --trial; got: {message}"
);
}
#[test]
fn trial_rate_limit_hint_mentions_five_per_hour() {
let mut response = StubResponse {
status: 429,
body: r#"{"error":true,"code":"rate_limit_exceeded"}"#.to_owned(),
};
let message = http_status_message(&mut response, "trial");
assert!(message.contains("5 per hour per IP"));
assert!(message.contains("FALLOW_LICENSE"));
}
#[test]
fn unknown_code_falls_back_to_backend_message_when_present() {
let mut response = StubResponse {
status: 500,
body: r#"{"error":true,"code":"checkout_error","message":"stripe returned no session url"}"#
.to_owned(),
};
let message = http_status_message(&mut response, "refresh");
assert!(message.starts_with("refresh request failed with HTTP 500"));
assert!(
message.ends_with(": stripe returned no session url"),
"expected backend message on fallback, got: {message}"
);
}
#[test]
fn unknown_code_without_message_falls_back_to_raw_body() {
let mut response = StubResponse {
status: 500,
body: r#"{"error":true,"code":"checkout_error"}"#.to_owned(),
};
let message = http_status_message(&mut response, "refresh");
assert!(message.starts_with("refresh request failed with HTTP 500"));
assert!(message.contains("checkout_error"));
}
#[test]
fn empty_body_still_produces_minimal_message() {
let mut response = StubResponse {
status: 502,
body: String::new(),
};
let message = http_status_message(&mut response, "trial");
assert_eq!(message, "trial request failed with HTTP 502");
}
#[test]
#[expect(unsafe_code, reason = "env var mutation requires unsafe")]
fn api_url_respects_env_override_and_default() {
let prior = std::env::var("FALLOW_API_URL").ok();
unsafe {
std::env::remove_var("FALLOW_API_URL");
}
assert_eq!(
api_url("/v1/coverage/repo/inventory"),
"https://api.fallow.cloud/v1/coverage/repo/inventory",
);
unsafe {
std::env::set_var("FALLOW_API_URL", "http://127.0.0.1:3000/");
}
assert_eq!(
api_url("/v1/coverage/a/inventory"),
"http://127.0.0.1:3000/v1/coverage/a/inventory",
);
unsafe {
if let Some(value) = prior {
std::env::set_var("FALLOW_API_URL", value);
} else {
std::env::remove_var("FALLOW_API_URL");
}
}
}
}