refactor: standardize crate error types

This commit is contained in:
Bryan Helmkamp 2026-04-11 11:53:53 -04:00
parent 3f878cdc85
commit e190c098ab
No known key found for this signature in database
26 changed files with 661 additions and 428 deletions

1
Cargo.lock generated
View file

@ -1927,6 +1927,7 @@ dependencies = [
"serde_yaml",
"sha2",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-stream",

View file

@ -1,4 +1,4 @@
use fabro_llm::error::SdkError;
use fabro_llm::Error as LlmError;
/// Why a session was interrupted.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@ -19,9 +19,9 @@ impl std::fmt::Display for InterruptReason {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum AgentError {
pub enum Error {
#[error("LLM error: {0}")]
Llm(#[from] SdkError),
Llm(#[from] LlmError),
#[error("Session is closed")]
SessionClosed,
@ -36,50 +36,53 @@ pub enum AgentError {
Interrupted(InterruptReason),
}
pub type Result<T> = std::result::Result<T, Error>;
pub type AgentError = Error;
#[cfg(test)]
mod tests {
use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind};
use fabro_llm::{ProviderErrorDetail, ProviderErrorKind};
use super::*;
#[test]
fn agent_error_from_sdk_error() {
let sdk_err = SdkError::Network {
let sdk_err = LlmError::Network {
message: "connection refused".into(),
source: None,
source: None,
};
let agent_err = AgentError::from(sdk_err);
assert!(matches!(agent_err, AgentError::Llm(_)));
let agent_err = Error::from(sdk_err);
assert!(matches!(agent_err, Error::Llm(_)));
assert!(agent_err.to_string().contains("connection refused"));
}
#[test]
fn session_closed_display() {
let err = AgentError::SessionClosed;
let err = Error::SessionClosed;
assert_eq!(err.to_string(), "Session is closed");
}
#[test]
fn invalid_state_display() {
let err = AgentError::InvalidState("bad state".into());
let err = Error::InvalidState("bad state".into());
assert_eq!(err.to_string(), "Invalid state: bad state");
}
#[test]
fn tool_execution_display() {
let err = AgentError::ToolExecution("command failed".into());
let err = Error::ToolExecution("command failed".into());
assert_eq!(err.to_string(), "Tool execution error: command failed");
}
#[test]
fn interrupted_display() {
let err = AgentError::Interrupted(InterruptReason::Cancelled);
let err = Error::Interrupted(InterruptReason::Cancelled);
assert_eq!(err.to_string(), "Interrupted: cancelled");
}
#[test]
fn interrupted_wall_clock_timeout_display() {
let err = AgentError::Interrupted(InterruptReason::WallClockTimeout);
let err = Error::Interrupted(InterruptReason::WallClockTimeout);
assert_eq!(err.to_string(), "Interrupted: wall clock timeout");
}
@ -87,62 +90,62 @@ mod tests {
#[test]
fn serde_roundtrip_llm_network() {
let err = AgentError::Llm(SdkError::Network {
let err = Error::Llm(LlmError::Network {
message: "connection refused".into(),
source: None,
source: None,
});
let json = serde_json::to_string(&err).unwrap();
let deserialized: AgentError = serde_json::from_str(&json).unwrap();
let deserialized: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.to_string(), deserialized.to_string());
}
#[test]
fn serde_roundtrip_llm_provider() {
let err = AgentError::Llm(SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
let err = Error::Llm(LlmError::Provider {
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail {
message: "too fast".into(),
provider: "openai".into(),
message: "too fast".into(),
provider: "openai".into(),
status_code: Some(429),
error_code: None,
error_code: None,
retry_after: Some(2.0),
raw: None,
raw: None,
}),
});
let json = serde_json::to_string(&err).unwrap();
let deserialized: AgentError = serde_json::from_str(&json).unwrap();
let deserialized: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.to_string(), deserialized.to_string());
}
#[test]
fn serde_roundtrip_session_closed() {
let err = AgentError::SessionClosed;
let err = Error::SessionClosed;
let json = serde_json::to_string(&err).unwrap();
let deserialized: AgentError = serde_json::from_str(&json).unwrap();
let deserialized: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.to_string(), deserialized.to_string());
}
#[test]
fn serde_roundtrip_invalid_state() {
let err = AgentError::InvalidState("bad".into());
let err = Error::InvalidState("bad".into());
let json = serde_json::to_string(&err).unwrap();
let deserialized: AgentError = serde_json::from_str(&json).unwrap();
let deserialized: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.to_string(), deserialized.to_string());
}
#[test]
fn serde_roundtrip_tool_execution() {
let err = AgentError::ToolExecution("cmd failed".into());
let err = Error::ToolExecution("cmd failed".into());
let json = serde_json::to_string(&err).unwrap();
let deserialized: AgentError = serde_json::from_str(&json).unwrap();
let deserialized: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.to_string(), deserialized.to_string());
}
#[test]
fn serde_roundtrip_interrupted() {
let err = AgentError::Interrupted(InterruptReason::Cancelled);
let err = Error::Interrupted(InterruptReason::Cancelled);
let json = serde_json::to_string(&err).unwrap();
let deserialized: AgentError = serde_json::from_str(&json).unwrap();
let deserialized: Error = serde_json::from_str(&json).unwrap();
assert_eq!(err.to_string(), deserialized.to_string());
}
@ -150,15 +153,15 @@ mod tests {
#[test]
fn clone_all_variants() {
let errors: Vec<AgentError> = vec![
AgentError::Llm(SdkError::Network {
let errors: Vec<Error> = vec![
Error::Llm(LlmError::Network {
message: "refused".into(),
source: None,
source: None,
}),
AgentError::SessionClosed,
AgentError::InvalidState("reason".into()),
AgentError::ToolExecution("reason".into()),
AgentError::Interrupted(InterruptReason::Cancelled),
Error::SessionClosed,
Error::InvalidState("reason".into()),
Error::ToolExecution("reason".into()),
Error::Interrupted(InterruptReason::Cancelled),
];
for err in &errors {
assert_eq!(err.to_string(), err.clone().to_string());
@ -169,9 +172,9 @@ mod tests {
#[test]
fn serde_tag_format_llm() {
let err = AgentError::Llm(SdkError::Network {
let err = Error::Llm(LlmError::Network {
message: "refused".into(),
source: None,
source: None,
});
let json = serde_json::to_string(&err).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
@ -180,7 +183,7 @@ mod tests {
#[test]
fn serde_tag_format_session_closed() {
let err = AgentError::SessionClosed;
let err = Error::SessionClosed;
let json = serde_json::to_string(&err).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["type"], "session_closed");
@ -188,7 +191,7 @@ mod tests {
#[test]
fn serde_tag_format_invalid_state() {
let err = AgentError::InvalidState("x".into());
let err = Error::InvalidState("x".into());
let json = serde_json::to_string(&err).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["type"], "invalid_state");
@ -196,7 +199,7 @@ mod tests {
#[test]
fn serde_tag_format_tool_execution() {
let err = AgentError::ToolExecution("x".into());
let err = Error::ToolExecution("x".into());
let json = serde_json::to_string(&err).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["type"], "tool_execution");
@ -204,7 +207,7 @@ mod tests {
#[test]
fn serde_tag_format_interrupted() {
let err = AgentError::Interrupted(InterruptReason::WallClockTimeout);
let err = Error::Interrupted(InterruptReason::WallClockTimeout);
let json = serde_json::to_string(&err).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["type"], "interrupted");

View file

@ -30,7 +30,7 @@ pub use agent_profile::AgentProfile;
pub use config::{SessionOptions, ToolApprovalAdapter, ToolHookCallback, ToolHookDecision};
#[cfg(feature = "docker")]
pub use docker_sandbox::{DockerSandbox, DockerSandboxOptions};
pub use error::{AgentError, InterruptReason};
pub use error::{AgentError, Error, InterruptReason, Result};
pub use event::Emitter;
pub use fabro_mcp::config::McpServerSettings;
pub use history::History;

View file

@ -60,11 +60,11 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result<SettingsLayer> {
let base_ctx = CommandContext::base()?;
let layers = config_layers(&base_ctx, args.workflow.as_deref())?;
if args.local {
return effective_settings::resolve_settings(
return Ok(effective_settings::resolve_settings(
layers,
None,
EffectiveSettingsMode::LocalOnly,
);
)?);
}
let ctx = CommandContext::for_target(&args.target)?;
@ -75,7 +75,11 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result<SettingsLayer> {
user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon,
};
effective_settings::resolve_settings(layers, Some(&server_settings), mode)
Ok(effective_settings::resolve_settings(
layers,
Some(&server_settings),
mode,
)?)
}
pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> {

View file

@ -145,7 +145,7 @@ async fn main_inner() -> (String, Result<()>) {
false,
)
}
Err(err) => return (command_name, Err(err)),
Err(err) => return (command_name, Err(err.into())),
}
} else {
match user_config::load_settings() {

View file

@ -7,12 +7,12 @@
//! `~/.fabro/settings.toml` plus explicit process-local overrides — their
//! stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert.
use anyhow::{Result, anyhow};
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::run::{RunExecutionLayer, RunLayer};
use fabro_types::settings::server::ServerLayer;
use crate::merge::combine_files;
use crate::{Error, Result};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EffectiveSettingsMode {
@ -23,10 +23,10 @@ pub enum EffectiveSettingsMode {
#[derive(Clone, Debug, Default)]
pub struct EffectiveSettingsLayers {
pub args: SettingsLayer,
pub args: SettingsLayer,
pub workflow: SettingsLayer,
pub project: SettingsLayer,
pub user: SettingsLayer,
pub project: SettingsLayer,
pub user: SettingsLayer,
}
impl EffectiveSettingsLayers {
@ -65,9 +65,7 @@ pub fn resolve_settings(
args,
)),
EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => {
let server_settings = server_settings.ok_or_else(|| {
anyhow!("server settings are required for server-targeted settings resolution")
})?;
let server_settings = server_settings.ok_or(Error::MissingServerSettings)?;
// Owner-specific domains (cli, server) may only come from the
// local ~/.fabro/settings.toml, never from fabro.toml or
// workflow.toml. The user layer keeps its cli/server fields.

View file

@ -0,0 +1,89 @@
use std::path::{Path, PathBuf};
use crate::parse::ParseError;
use crate::resolve::ResolveError;
fn format_resolve_errors(errors: &[ResolveError]) -> String {
errors
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("reading config file {path}: {source}")]
ReadFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{context}: {source}")]
ParseSettings {
context: &'static str,
#[source]
source: ParseError,
},
#[error("parsing TOML config at {path}: {source}")]
TomlParse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("{context}:\n{}", format_resolve_errors(.errors))]
Resolve {
context: &'static str,
errors: Vec<ResolveError>,
},
#[error("missing required environment variable {var} for {field}")]
MissingEnvVar {
field: String,
var: String,
#[source]
source: std::env::VarError,
},
#[error("workflow not found: {0}")]
WorkflowNotFound(String),
#[error("server settings are required for server-targeted settings resolution")]
MissingServerSettings,
#[error("{0}")]
Other(String),
}
impl Error {
pub fn read_file(path: &Path, source: std::io::Error) -> Self {
Self::ReadFile {
path: path.to_path_buf(),
source,
}
}
pub fn parse(context: &'static str, source: ParseError) -> Self {
Self::ParseSettings { context, source }
}
pub fn toml_parse(path: &Path, source: toml::de::Error) -> Self {
Self::TomlParse {
path: path.to_path_buf(),
source,
}
}
pub fn resolve(context: &'static str, errors: Vec<ResolveError>) -> Self {
Self::Resolve { context, errors }
}
pub fn other(message: impl Into<String>) -> Self {
Self::Other(message.into())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -1,6 +1,7 @@
extern crate self as fabro_config;
pub mod effective_settings;
pub mod error;
pub mod home;
pub mod legacy_env;
pub mod load;
@ -14,6 +15,7 @@ pub mod user;
use std::path::Path;
pub use error::{Error, Result};
use fabro_types::settings::{Settings, SettingsLayer};
pub use fabro_util::path::expand_tilde;
pub use home::Home;
@ -34,39 +36,33 @@ pub fn load_and_resolve(
layers: effective_settings::EffectiveSettingsLayers,
server_settings: Option<&SettingsLayer>,
mode: effective_settings::EffectiveSettingsMode,
) -> anyhow::Result<Settings> {
) -> Result<Settings> {
let layer = effective_settings::resolve_settings(layers, server_settings, mode)?;
resolve(&layer).map_err(|errors| {
anyhow::anyhow!(
"failed to resolve settings:\n{}",
errors
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n")
)
})
resolve(&layer).map_err(|errors| Error::resolve("failed to resolve settings", errors))
}
/// Load a TOML config from an explicit path or `~/.fabro/{filename}`.
///
/// Returns `T::default()` when no explicit path is given and the default file
/// doesn't exist. An explicit path that doesn't exist is an error.
pub fn load_config_file<T>(path: Option<&Path>, filename: &str) -> anyhow::Result<T>
pub fn load_config_file<T>(path: Option<&Path>, filename: &str) -> Result<T>
where
T: Default + DeserializeOwned,
{
if let Some(explicit) = path {
tracing::debug!(path = %explicit.display(), "Loading config from explicit path");
let contents = std::fs::read_to_string(explicit)?;
return Ok(toml::from_str(&contents)?);
let contents = std::fs::read_to_string(explicit)
.map_err(|source| Error::read_file(explicit, source))?;
return toml::from_str(&contents).map_err(|source| Error::toml_parse(explicit, source));
}
let default_path = Home::from_env().root().join(filename);
tracing::debug!(path = %default_path.display(), "Loading config");
match std::fs::read_to_string(&default_path) {
Ok(contents) => Ok(toml::from_str(&contents)?),
Ok(contents) => {
toml::from_str(&contents).map_err(|source| Error::toml_parse(&default_path, source))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(e) => Err(e.into()),
Err(e) => Err(Error::read_file(&default_path, e)),
}
}

View file

@ -1,31 +1,27 @@
use std::path::{Path, PathBuf};
use anyhow::Context;
use fabro_types::settings::run::RunGoalLayer;
use fabro_types::settings::{InterpString, SettingsLayer};
use crate::merge::combine_files;
use crate::parse::parse_settings_layer;
use crate::{project, user};
use crate::{Error, Result, project, user};
pub fn load_settings_path(path: &Path) -> anyhow::Result<SettingsLayer> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
pub fn load_settings_path(path: &Path) -> Result<SettingsLayer> {
let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?;
let mut layer = parse_settings_layer(&content)
.map_err(|err| anyhow::anyhow!("{err}"))
.context("Failed to parse settings file")?;
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
resolve_goal_file_paths(&mut layer, base_dir);
Ok(layer)
}
pub fn load_settings_for_workflow(path: &Path, cwd: &Path) -> anyhow::Result<SettingsLayer> {
pub fn load_settings_for_workflow(path: &Path, cwd: &Path) -> Result<SettingsLayer> {
let resolution = project::resolve_workflow_path(path, cwd)?;
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
anyhow::bail!(
"Workflow not found: {}",
resolution.resolved_workflow_path.display()
);
return Err(Error::WorkflowNotFound(
resolution.resolved_workflow_path.display().to_string(),
));
}
let workflow_config = resolution.workflow_config.unwrap_or_default();
@ -41,13 +37,13 @@ pub fn load_settings_for_workflow(path: &Path, cwd: &Path) -> anyhow::Result<Set
Ok(combine_files(project_config, workflow_config))
}
pub fn load_settings_project(start: &Path) -> anyhow::Result<SettingsLayer> {
pub fn load_settings_project(start: &Path) -> Result<SettingsLayer> {
Ok(project::discover_project_config(start)?
.map(|(_, config)| config)
.unwrap_or_default())
}
pub fn load_settings_user() -> anyhow::Result<SettingsLayer> {
pub fn load_settings_user() -> Result<SettingsLayer> {
user::load_settings_config(None)
}

View file

@ -7,39 +7,39 @@
use std::fmt::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use fabro_types::settings::SettingsLayer;
use serde::Serialize;
use crate::load::load_settings_path;
use crate::parse::parse_settings_layer;
use crate::{resolve_project_from_file, resolve_run_from_file, resolve_workflow_from_file, run};
use crate::{
Error, Result, resolve_project_from_file, resolve_run_from_file, resolve_workflow_from_file,
run,
};
const CONFIG_FILENAME: &str = "fabro.toml";
#[derive(Clone, Debug)]
pub struct WorkflowPathResolution {
pub resolved_workflow_path: PathBuf,
pub dot_path: PathBuf,
pub workflow_config: Option<SettingsLayer>,
pub workflow_toml_path: Option<PathBuf>,
pub workflow_slug: Option<String>,
pub dot_path: PathBuf,
pub workflow_config: Option<SettingsLayer>,
pub workflow_toml_path: Option<PathBuf>,
pub workflow_slug: Option<String>,
}
/// Parse a project config from a TOML string.
pub fn parse_project_config(content: &str) -> anyhow::Result<SettingsLayer> {
parse_settings_layer(content)
.map_err(|err| anyhow::anyhow!("{err}"))
.context("Failed to parse project config")
pub fn parse_project_config(content: &str) -> Result<SettingsLayer> {
parse_settings_layer(content).map_err(|err| Error::parse("Failed to parse project config", err))
}
/// Load a project config from a file path.
///
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub fn load_project_config(path: &Path) -> anyhow::Result<SettingsLayer> {
let config = load_settings_path(path).context("Failed to parse project config")?;
pub fn load_project_config(path: &Path) -> Result<SettingsLayer> {
let config = load_settings_path(path)?;
let root = resolve_project_from_file(&config)
.map_err(|errors| anyhow::anyhow!("Failed to resolve project settings: {errors:?}"))?
.map_err(|errors| Error::resolve("Failed to resolve project settings", errors))?
.directory;
tracing::debug!(path = %path.display(), root = %root, "Loaded project config");
Ok(config)
@ -47,7 +47,7 @@ pub fn load_project_config(path: &Path) -> anyhow::Result<SettingsLayer> {
/// Walk ancestor directories from `start` looking for `fabro.toml`.
/// Returns the config file path and parsed config, or `None` if not found.
pub fn discover_project_config(start: &Path) -> anyhow::Result<Option<(PathBuf, SettingsLayer)>> {
pub fn discover_project_config(start: &Path) -> Result<Option<(PathBuf, SettingsLayer)>> {
for ancestor in start.ancestors() {
let candidate = ancestor.join(CONFIG_FILENAME);
if candidate.is_file() {
@ -78,22 +78,19 @@ fn workflow_slug_from_path(workflow_path: &Path) -> Option<String> {
}
/// Resolve a workflow argument to a path.
pub fn resolve_workflow_arg(arg: &Path) -> anyhow::Result<PathBuf> {
pub fn resolve_workflow_arg(arg: &Path) -> Result<PathBuf> {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
resolve_workflow_arg_from(arg, &start)
}
pub fn resolve_workflow_path(
workflow_path: &Path,
cwd: &Path,
) -> anyhow::Result<WorkflowPathResolution> {
pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result<WorkflowPathResolution> {
let path = resolve_workflow_arg_from(workflow_path, cwd)?;
let workflow_slug = workflow_slug_from_path(&path);
if path.extension().is_some_and(|ext| ext == "toml") {
match run::load_run_config(&path) {
Ok(cfg) => {
let workflow = resolve_workflow_from_file(&cfg).map_err(|errors| {
anyhow::anyhow!("Failed to resolve workflow settings: {errors:?}")
Error::resolve("Failed to resolve workflow settings", errors)
})?;
let dot_path = run::resolve_graph_path(&path, &workflow.graph);
Ok(WorkflowPathResolution {
@ -104,7 +101,7 @@ pub fn resolve_workflow_path(
workflow_slug,
})
}
Err(_) if !path.exists() => anyhow::bail!("Workflow not found: {}", path.display()),
Err(_) if !path.exists() => Err(Error::WorkflowNotFound(path.display().to_string())),
Err(err) => Err(err),
}
} else {
@ -134,7 +131,7 @@ pub fn resolve_working_directory(settings: &SettingsLayer, caller_cwd: &Path) ->
}
}
fn resolve_workflow_arg_from(arg: &Path, start_dir: &Path) -> anyhow::Result<PathBuf> {
fn resolve_workflow_arg_from(arg: &Path, start_dir: &Path) -> Result<PathBuf> {
resolve_workflow_arg_impl(arg, start_dir, Some(&user_workflows_dir()))
}
@ -142,7 +139,7 @@ fn resolve_workflow_arg_impl(
arg: &Path,
start_dir: &Path,
user_workflows: Option<&Path>,
) -> anyhow::Result<PathBuf> {
) -> Result<PathBuf> {
if arg.extension().is_some() {
let resolved = if arg.is_absolute() {
arg.to_path_buf()
@ -177,10 +174,10 @@ fn resolve_workflow_arg_impl(
let project_wf_dir = fabro_root.join("workflows");
let available = list_available_workflows(Some(&project_wf_dir), user_workflows);
if available.is_empty() {
bail!(
return Err(Error::other(format!(
"Unknown workflow '{name}'\n\nNo workflows found in {}",
project_wf_dir.display()
);
)));
}
let mut msg = format!(
"Unknown workflow '{name}'\n\nAvailable workflows: {}",
@ -189,7 +186,7 @@ fn resolve_workflow_arg_impl(
if let Some(suggestion) = find_closest_match(&name, &available) {
let _ = write!(msg, "\n\nDid you mean '{suggestion}'?");
}
bail!("{msg}");
Err(Error::other(msg))
}
Ok(None) => {
if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) {
@ -225,8 +222,8 @@ fn user_workflows_dir() -> PathBuf {
/// Metadata about a discovered workflow.
#[derive(Clone, Debug, Serialize)]
pub struct WorkflowInfo {
pub name: String,
pub goal: Option<String>,
pub name: String,
pub goal: Option<String>,
pub source: WorkflowSource,
}
@ -342,7 +339,7 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
}
/// Resolve a workflow argument to a DOT path and optional run config.
pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option<SettingsLayer>)> {
pub fn resolve_workflow(arg: &Path) -> Result<(PathBuf, Option<SettingsLayer>)> {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let resolution = resolve_workflow_path(arg, &start)?;
Ok((resolution.dot_path, resolution.workflow_config))
@ -442,11 +439,7 @@ retros = true
#[test]
fn parse_higher_version_errors() {
let err = parse_project_config("_version = 2\n").unwrap_err();
let chain: String = err
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
let chain = format!("{err:#}");
assert!(
chain.contains("Upgrade") || chain.to_lowercase().contains("version"),
"Expected version hint in chain: {chain}"

View file

@ -7,27 +7,25 @@
use std::path::{Path, PathBuf};
use anyhow::Context;
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoalLayer};
use crate::load::{load_settings_path, resolve_goal_file_path};
use crate::parse::parse_settings_layer;
use crate::{Error, Result};
/// Load and parse a run config from a TOML file.
pub fn parse_run_config(contents: &str) -> anyhow::Result<SettingsLayer> {
pub fn parse_run_config(contents: &str) -> Result<SettingsLayer> {
parse_settings_layer(contents)
.map_err(|err| anyhow::anyhow!("{err}"))
.context("Failed to parse run config TOML")
.map_err(|err| Error::parse("Failed to parse run config TOML", err))
}
/// Load and parse a run config from a TOML file.
///
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub fn load_run_config(path: &Path) -> anyhow::Result<SettingsLayer> {
pub fn load_run_config(path: &Path) -> Result<SettingsLayer> {
load_settings_path(path)
.with_context(|| format!("Failed to parse workflow config at {}", path.display()))
}
/// Resolve a graph path relative to a workflow.toml.
@ -45,7 +43,7 @@ pub enum ResolveRunGoalError {
var: String,
},
Io {
path: PathBuf,
path: PathBuf,
source: std::io::Error,
},
}
@ -76,14 +74,14 @@ impl std::error::Error for ResolveRunGoalError {
pub fn resolve_run_goal(
settings: &SettingsLayer,
base_dir: &Path,
) -> Result<Option<ResolvedRunGoal>, ResolveRunGoalError> {
) -> std::result::Result<Option<ResolvedRunGoal>, ResolveRunGoalError> {
let Some(goal) = settings.run.as_ref().and_then(|run| run.goal.as_ref()) else {
return Ok(None);
};
match goal {
RunGoalLayer::Inline(text) => Ok(Some(ResolvedRunGoal {
text: text.as_source(),
text: text.as_source(),
source: ResolvedGoalSource::Inline,
})),
RunGoalLayer::File { file } => {

View file

@ -10,6 +10,7 @@ use std::sync::{Mutex, OnceLock};
use fabro_types::settings::SettingsLayer;
use crate::Result;
use crate::home::Home;
use crate::load::load_settings_path;
@ -77,7 +78,7 @@ fn should_warn_about_legacy_user_config(path: &Path) -> bool {
/// returning defaults if the default file doesn't exist. An explicit path that
/// doesn't exist is an error.
#[allow(clippy::print_stderr)]
pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<SettingsLayer> {
pub fn load_settings_config(path: Option<&Path>) -> Result<SettingsLayer> {
if let Some(explicit) = path
.map(Path::to_path_buf)
.or_else(|| std::env::var_os(FABRO_CONFIG_ENV).map(PathBuf::from))
@ -111,7 +112,7 @@ pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<SettingsLayer
}
}
fn load_v2_layer_from_path(path: &Path) -> anyhow::Result<SettingsLayer> {
fn load_v2_layer_from_path(path: &Path) -> Result<SettingsLayer> {
load_settings_path(path)
}

View file

@ -17,14 +17,14 @@ impl fmt::Display for VisitLimitSource {
}
}
/// Structured failure data on handler errors. Maps to FabroError's
/// Structured failure data on handler errors. Maps to workflow error's
/// is_retryable(), failure_class(), failure_signature_hint(),
/// to_fail_outcome().
#[derive(Debug, Clone)]
pub struct HandlerErrorDetail {
pub message: String,
pub message: String,
pub retryable: bool,
pub category: Option<FailureCategory>,
pub category: Option<FailureCategory>,
pub signature: Option<String>,
}
@ -35,7 +35,7 @@ impl fmt::Display for HandlerErrorDetail {
}
#[derive(Debug, thiserror::Error)]
pub enum CoreError {
pub enum Error {
#[error("node not found: {id}")]
NodeNotFound { id: String },
#[error("no start node found in graph")]
@ -48,9 +48,9 @@ pub enum CoreError {
"node \"{node_id}\" visited {visits} times ({limit_source} limit {limit}); run is stuck in a cycle"
)]
VisitLimitExceeded {
node_id: String,
visits: usize,
limit: usize,
node_id: String,
visits: usize,
limit: usize,
limit_source: VisitLimitSource,
},
#[error("stall timeout on node \"{node_id}\"")]
@ -61,7 +61,7 @@ pub enum CoreError {
Other(String),
}
impl CoreError {
impl Error {
pub fn handler(detail: HandlerErrorDetail) -> Self {
Self::Handler { detail }
}
@ -81,8 +81,8 @@ impl CoreError {
Self::Handler { detail } => Outcome {
status: StageStatus::Fail,
failure: Some(FailureDetail {
message: detail.message.clone(),
category: detail.category.unwrap_or(FailureCategory::Deterministic),
message: detail.message.clone(),
category: detail.category.unwrap_or(FailureCategory::Deterministic),
signature: detail.signature.clone(),
}),
..Outcome::default()
@ -92,7 +92,8 @@ impl CoreError {
}
}
pub type Result<T> = std::result::Result<T, CoreError>;
pub type Result<T> = std::result::Result<T, Error>;
pub type CoreError = Error;
#[cfg(test)]
mod tests {
@ -101,58 +102,58 @@ mod tests {
#[test]
fn core_error_display() {
assert_eq!(
CoreError::NodeNotFound { id: "n1".into() }.to_string(),
Error::NodeNotFound { id: "n1".into() }.to_string(),
"node not found: n1"
);
assert_eq!(
CoreError::NoStartNode.to_string(),
Error::NoStartNode.to_string(),
"no start node found in graph"
);
assert_eq!(CoreError::Cancelled.to_string(), "run cancelled");
assert_eq!(Error::Cancelled.to_string(), "run cancelled");
assert_eq!(
CoreError::Blocked {
Error::Blocked {
message: "hook denied".into(),
}
.to_string(),
"blocked: hook denied"
);
assert_eq!(
CoreError::VisitLimitExceeded {
node_id: "n1".into(),
visits: 5,
limit: 3,
Error::VisitLimitExceeded {
node_id: "n1".into(),
visits: 5,
limit: 3,
limit_source: VisitLimitSource::Node,
}
.to_string(),
"node \"n1\" visited 5 times (node limit 3); run is stuck in a cycle"
);
assert_eq!(
CoreError::StallTimeout {
Error::StallTimeout {
node_id: "work".into(),
}
.to_string(),
"stall timeout on node \"work\""
);
assert_eq!(
CoreError::Other("something broke".into()).to_string(),
Error::Other("something broke".into()).to_string(),
"something broke"
);
}
#[test]
fn core_error_handler_is_retryable() {
let retryable = CoreError::handler(HandlerErrorDetail {
message: "timeout".into(),
let retryable = Error::handler(HandlerErrorDetail {
message: "timeout".into(),
retryable: true,
category: None,
category: None,
signature: None,
});
assert!(retryable.is_retryable());
let not_retryable = CoreError::handler(HandlerErrorDetail {
message: "bad input".into(),
let not_retryable = Error::handler(HandlerErrorDetail {
message: "bad input".into(),
retryable: false,
category: None,
category: None,
signature: None,
});
assert!(!not_retryable.is_retryable());
@ -161,10 +162,10 @@ mod tests {
#[test]
fn core_error_handler_to_fail_outcome() {
use crate::outcome::FailureCategory;
let err = CoreError::handler(HandlerErrorDetail {
message: "api down".into(),
let err = Error::handler(HandlerErrorDetail {
message: "api down".into(),
retryable: true,
category: Some(FailureCategory::TransientInfra),
category: Some(FailureCategory::TransientInfra),
signature: Some("sig123".into()),
});
let outcome: Outcome = err.to_fail_outcome();
@ -177,15 +178,15 @@ mod tests {
#[test]
fn core_error_non_handler_not_retryable() {
assert!(!CoreError::NodeNotFound { id: "x".into() }.is_retryable());
assert!(!CoreError::Cancelled.is_retryable());
assert!(!CoreError::NoStartNode.is_retryable());
assert!(!Error::NodeNotFound { id: "x".into() }.is_retryable());
assert!(!Error::Cancelled.is_retryable());
assert!(!Error::NoStartNode.is_retryable());
assert!(
!CoreError::Blocked {
!Error::Blocked {
message: "no".into(),
}
.is_retryable()
);
assert!(!CoreError::Other("err".into()).is_retryable());
assert!(!Error::Other("err".into()).is_retryable());
}
}

View file

@ -13,7 +13,7 @@ pub mod state;
pub mod test_fixtures;
pub use context::Context;
pub use error::{CoreError, HandlerErrorDetail, Result, VisitLimitSource};
pub use error::{CoreError, Error, HandlerErrorDetail, Result, VisitLimitSource};
pub use executor::{Executor, ExecutorBuilder, ExecutorOptions};
pub use graph::{EdgeSelection, EdgeSpec, Graph, NodeSpec};
pub use handler::NodeHandler;

View file

@ -1,10 +1,13 @@
use thiserror::Error;
use thiserror::Error as ThisError;
#[derive(Debug, Error)]
pub enum GraphvizError {
#[derive(Debug, ThisError)]
pub enum Error {
#[error("Parse error: {0}")]
Parse(String),
#[error("Stylesheet error: {0}")]
Stylesheet(String),
}
pub type Result<T> = std::result::Result<T, Error>;
pub type GraphvizError = Error;

View file

@ -6,4 +6,5 @@ pub mod parser;
pub mod render;
pub mod stylesheet;
pub use error::{Error, GraphvizError, Result};
pub use fidelity::Fidelity;

View file

@ -30,23 +30,23 @@ impl std::fmt::Display for ProviderErrorKind {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProviderErrorDetail {
pub message: String,
pub provider: String,
pub message: String,
pub provider: String,
pub status_code: Option<u16>,
pub error_code: Option<String>,
pub error_code: Option<String>,
pub retry_after: Option<f64>,
pub raw: Option<serde_json::Value>,
pub raw: Option<serde_json::Value>,
}
impl ProviderErrorDetail {
pub fn new(message: impl Into<String>, provider: impl Into<String>) -> Self {
Self {
message: message.into(),
provider: provider.into(),
message: message.into(),
provider: provider.into(),
status_code: None,
error_code: None,
error_code: None,
retry_after: None,
raw: None,
raw: None,
}
}
}
@ -55,10 +55,10 @@ use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SdkError {
pub enum Error {
#[error("{kind} {}: {}", .detail.provider, .detail.message)]
Provider {
kind: ProviderErrorKind,
kind: ProviderErrorKind,
detail: Box<ProviderErrorDetail>,
},
@ -67,7 +67,7 @@ pub enum SdkError {
message: String,
#[source]
#[serde(skip)]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
#[error("Request interrupted: {message}")]
@ -78,7 +78,7 @@ pub enum SdkError {
message: String,
#[source]
#[serde(skip)]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
#[error("Stream error: {message}")]
@ -86,7 +86,7 @@ pub enum SdkError {
message: String,
#[source]
#[serde(skip)]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
#[error("Invalid tool call: {message}")]
@ -100,21 +100,21 @@ pub enum SdkError {
message: String,
#[source]
#[serde(skip)]
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
source: Option<Arc<dyn std::error::Error + Send + Sync>>,
},
#[error("Unsupported tool choice: {message}")]
UnsupportedToolChoice { message: String },
}
impl SdkError {
impl Error {
pub fn network(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Network {
message: message.into(),
source: Some(Arc::new(source)),
source: Some(Arc::new(source)),
}
}
@ -124,7 +124,7 @@ impl SdkError {
) -> Self {
Self::RequestTimeout {
message: message.into(),
source: Some(Arc::new(source)),
source: Some(Arc::new(source)),
}
}
@ -134,7 +134,7 @@ impl SdkError {
) -> Self {
Self::Stream {
message: message.into(),
source: Some(Arc::new(source)),
source: Some(Arc::new(source)),
}
}
@ -144,7 +144,7 @@ impl SdkError {
) -> Self {
Self::Configuration {
message: message.into(),
source: Some(Arc::new(source)),
source: Some(Arc::new(source)),
}
}
@ -289,9 +289,9 @@ pub fn error_from_status_code(
403 => ProviderErrorKind::AccessDenied,
404 => ProviderErrorKind::NotFound,
408 => {
return SdkError::RequestTimeout {
return Error::RequestTimeout {
message: detail.message,
source: None,
source: None,
};
}
413 => ProviderErrorKind::ContextLength,
@ -315,7 +315,7 @@ pub fn error_from_status_code(
}
};
SdkError::Provider {
Error::Provider {
kind,
detail: Box::new(detail),
}
@ -347,20 +347,23 @@ pub fn error_from_grpc_status(
"PERMISSION_DENIED" => ProviderErrorKind::AccessDenied,
"RESOURCE_EXHAUSTED" => ProviderErrorKind::RateLimit,
"DEADLINE_EXCEEDED" => {
return SdkError::RequestTimeout {
return Error::RequestTimeout {
message: detail.message,
source: None,
source: None,
};
}
_ => ProviderErrorKind::Server,
};
SdkError::Provider {
Error::Provider {
kind,
detail: Box::new(detail),
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub type SdkError = Error;
#[cfg(test)]
mod tests {
use std::error::Error as _;
@ -370,7 +373,7 @@ mod tests {
#[test]
fn retryable_classification() {
let auth_err = SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail {
status_code: Some(401),
..ProviderErrorDetail::new("bad key", "openai")
@ -379,7 +382,7 @@ mod tests {
assert!(!auth_err.retryable());
let rate_err = SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail {
status_code: Some(429),
retry_after: Some(2.0),
@ -390,7 +393,7 @@ mod tests {
assert_eq!(rate_err.retry_after(), Some(2.0));
let server_err = SdkError::Provider {
kind: ProviderErrorKind::Server,
kind: ProviderErrorKind::Server,
detail: Box::new(ProviderErrorDetail {
status_code: Some(500),
..ProviderErrorDetail::new("internal error", "anthropic")
@ -400,19 +403,19 @@ mod tests {
let timeout = SdkError::RequestTimeout {
message: "timed out".into(),
source: None,
source: None,
};
assert!(!timeout.retryable());
let network = SdkError::Network {
message: "connection refused".into(),
source: None,
source: None,
};
assert!(network.retryable());
let config = SdkError::Configuration {
message: "missing provider".into(),
source: None,
source: None,
};
assert!(!config.retryable());
}
@ -422,37 +425,37 @@ mod tests {
let detail = || Box::new(ProviderErrorDetail::new("error", "openai"));
let access_denied = SdkError::Provider {
kind: ProviderErrorKind::AccessDenied,
kind: ProviderErrorKind::AccessDenied,
detail: detail(),
};
assert!(!access_denied.retryable());
let not_found = SdkError::Provider {
kind: ProviderErrorKind::NotFound,
kind: ProviderErrorKind::NotFound,
detail: detail(),
};
assert!(!not_found.retryable());
let invalid_req = SdkError::Provider {
kind: ProviderErrorKind::InvalidRequest,
kind: ProviderErrorKind::InvalidRequest,
detail: detail(),
};
assert!(!invalid_req.retryable());
let ctx_length = SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
kind: ProviderErrorKind::ContextLength,
detail: detail(),
};
assert!(!ctx_length.retryable());
let quota = SdkError::Provider {
kind: ProviderErrorKind::QuotaExceeded,
kind: ProviderErrorKind::QuotaExceeded,
detail: detail(),
};
assert!(!quota.retryable());
let content_filter = SdkError::Provider {
kind: ProviderErrorKind::ContentFilter,
kind: ProviderErrorKind::ContentFilter,
detail: detail(),
};
assert!(!content_filter.retryable());
@ -486,32 +489,44 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::Authentication,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::Authentication,
..
}
));
assert!(!err.retryable());
let err =
error_from_status_code(403, "forbidden".into(), "openai".into(), None, None, None);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::AccessDenied,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::AccessDenied,
..
}
));
let err =
error_from_status_code(404, "not found".into(), "openai".into(), None, None, None);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::NotFound,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::NotFound,
..
}
));
let err =
error_from_status_code(400, "bad request".into(), "openai".into(), None, None, None);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::InvalidRequest,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::InvalidRequest,
..
}
));
let err = error_from_status_code(
422,
@ -521,20 +536,26 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::InvalidRequest,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::InvalidRequest,
..
}
));
let err = error_from_status_code(408, "timeout".into(), "openai".into(), None, None, None);
assert!(matches!(err, SdkError::RequestTimeout { .. }));
let err =
error_from_status_code(413, "too large".into(), "openai".into(), None, None, None);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
..
}
));
let err = error_from_status_code(
429,
@ -544,26 +565,35 @@ mod tests {
None,
Some(5.0),
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
..
}
));
assert!(err.retryable());
assert_eq!(err.retry_after(), Some(5.0));
let err = error_from_status_code(500, "internal".into(), "openai".into(), None, None, None);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::Server,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::Server,
..
}
));
assert!(err.retryable());
let err =
error_from_status_code(502, "bad gateway".into(), "openai".into(), None, None, None);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::Server,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::Server,
..
}
));
let err = error_from_status_code(
529,
@ -573,10 +603,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::Server,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::Server,
..
}
));
assert!(err.retryable());
}
@ -590,10 +623,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
..
}
));
}
#[test]
@ -606,10 +642,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
..
}
));
}
#[test]
@ -622,10 +661,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::ContentFilter,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::ContentFilter,
..
}
));
}
#[test]
@ -638,10 +680,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::ContentFilter,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::ContentFilter,
..
}
));
}
#[test]
@ -654,10 +699,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::NotFound,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::NotFound,
..
}
));
}
#[test]
@ -670,10 +718,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::NotFound,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::NotFound,
..
}
));
}
#[test]
@ -686,10 +737,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::Authentication,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::Authentication,
..
}
));
}
#[test]
@ -702,10 +756,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::Authentication,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::Authentication,
..
}
));
}
#[test]
@ -718,10 +775,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::NotFound,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::NotFound,
..
}
));
let err = error_from_grpc_status(
"RESOURCE_EXHAUSTED",
@ -731,10 +791,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
..
}
));
assert!(err.retryable());
let err = error_from_grpc_status(
@ -745,10 +808,13 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::Authentication,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::Authentication,
..
}
));
let err = error_from_grpc_status(
"DEADLINE_EXCEEDED",
@ -768,16 +834,19 @@ mod tests {
None,
None,
);
assert!(matches!(err, SdkError::Provider {
kind: ProviderErrorKind::Server,
..
}));
assert!(matches!(
err,
SdkError::Provider {
kind: ProviderErrorKind::Server,
..
}
));
}
#[test]
fn error_display_messages() {
let err = SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail {
status_code: Some(401),
..ProviderErrorDetail::new("invalid api key", "openai")
@ -790,7 +859,7 @@ mod tests {
let err = SdkError::Configuration {
message: "no provider".into(),
source: None,
source: None,
};
assert_eq!(err.to_string(), "Configuration error: no provider");
}
@ -798,7 +867,7 @@ mod tests {
#[test]
fn status_code_accessor() {
let err = SdkError::Provider {
kind: ProviderErrorKind::Server,
kind: ProviderErrorKind::Server,
detail: Box::new(ProviderErrorDetail {
status_code: Some(503),
..ProviderErrorDetail::new("error", "openai")
@ -808,7 +877,7 @@ mod tests {
let err = SdkError::Network {
message: "refused".into(),
source: None,
source: None,
};
assert_eq!(err.status_code(), None);
}
@ -816,7 +885,7 @@ mod tests {
#[test]
fn provider_name_from_provider_variant() {
let err = SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("bad key", "openai")),
};
assert_eq!(err.provider_name(), "openai");
@ -826,7 +895,7 @@ mod tests {
fn provider_name_defaults_to_unknown() {
let err = SdkError::Network {
message: "refused".into(),
source: None,
source: None,
};
assert_eq!(err.provider_name(), "unknown");
}
@ -837,7 +906,7 @@ mod tests {
assert!(
SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
kind: ProviderErrorKind::RateLimit,
detail: detail(),
}
.failover_eligible()
@ -845,7 +914,7 @@ mod tests {
assert!(
SdkError::Provider {
kind: ProviderErrorKind::Server,
kind: ProviderErrorKind::Server,
detail: detail(),
}
.failover_eligible()
@ -853,7 +922,7 @@ mod tests {
assert!(
SdkError::Provider {
kind: ProviderErrorKind::QuotaExceeded,
kind: ProviderErrorKind::QuotaExceeded,
detail: detail(),
}
.failover_eligible()
@ -865,7 +934,7 @@ mod tests {
assert!(
SdkError::RequestTimeout {
message: "timed out".into(),
source: None,
source: None,
}
.failover_eligible()
);
@ -873,7 +942,7 @@ mod tests {
assert!(
SdkError::Network {
message: "refused".into(),
source: None,
source: None,
}
.failover_eligible()
);
@ -881,7 +950,7 @@ mod tests {
assert!(
SdkError::Stream {
message: "broken".into(),
source: None,
source: None,
}
.failover_eligible()
);
@ -893,7 +962,7 @@ mod tests {
assert!(
!SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: detail(),
}
.failover_eligible()
@ -901,7 +970,7 @@ mod tests {
assert!(
!SdkError::Provider {
kind: ProviderErrorKind::InvalidRequest,
kind: ProviderErrorKind::InvalidRequest,
detail: detail(),
}
.failover_eligible()
@ -909,7 +978,7 @@ mod tests {
assert!(
!SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
kind: ProviderErrorKind::ContextLength,
detail: detail(),
}
.failover_eligible()
@ -917,7 +986,7 @@ mod tests {
assert!(
!SdkError::Provider {
kind: ProviderErrorKind::ContentFilter,
kind: ProviderErrorKind::ContentFilter,
detail: detail(),
}
.failover_eligible()
@ -929,7 +998,7 @@ mod tests {
assert!(
!SdkError::Configuration {
message: "bad".into(),
source: None,
source: None,
}
.failover_eligible()
);
@ -966,7 +1035,7 @@ mod tests {
#[test]
fn failure_signature_hint_provider_transient() {
let err = SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail::new("too fast", "openai")),
};
assert_eq!(
@ -975,7 +1044,7 @@ mod tests {
);
let err = SdkError::Provider {
kind: ProviderErrorKind::Server,
kind: ProviderErrorKind::Server,
detail: Box::new(ProviderErrorDetail::new("500", "anthropic")),
};
assert_eq!(
@ -987,7 +1056,7 @@ mod tests {
#[test]
fn failure_signature_hint_provider_deterministic() {
let err = SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("bad key", "openai")),
};
assert_eq!(
@ -996,7 +1065,7 @@ mod tests {
);
let err = SdkError::Provider {
kind: ProviderErrorKind::AccessDenied,
kind: ProviderErrorKind::AccessDenied,
detail: Box::new(ProviderErrorDetail::new("denied", "anthropic")),
};
assert_eq!(
@ -1005,7 +1074,7 @@ mod tests {
);
let err = SdkError::Provider {
kind: ProviderErrorKind::NotFound,
kind: ProviderErrorKind::NotFound,
detail: Box::new(ProviderErrorDetail::new("missing", "openai")),
};
assert_eq!(
@ -1014,7 +1083,7 @@ mod tests {
);
let err = SdkError::Provider {
kind: ProviderErrorKind::InvalidRequest,
kind: ProviderErrorKind::InvalidRequest,
detail: Box::new(ProviderErrorDetail::new("bad", "openai")),
};
assert_eq!(
@ -1023,7 +1092,7 @@ mod tests {
);
let err = SdkError::Provider {
kind: ProviderErrorKind::ContentFilter,
kind: ProviderErrorKind::ContentFilter,
detail: Box::new(ProviderErrorDetail::new("blocked", "openai")),
};
assert_eq!(
@ -1032,7 +1101,7 @@ mod tests {
);
let err = SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
kind: ProviderErrorKind::ContextLength,
detail: Box::new(ProviderErrorDetail::new("too long", "openai")),
};
assert_eq!(
@ -1041,7 +1110,7 @@ mod tests {
);
let err = SdkError::Provider {
kind: ProviderErrorKind::QuotaExceeded,
kind: ProviderErrorKind::QuotaExceeded,
detail: Box::new(ProviderErrorDetail::new("out of quota", "openai")),
};
assert_eq!(
@ -1055,7 +1124,7 @@ mod tests {
assert_eq!(
SdkError::RequestTimeout {
message: "timed out".into(),
source: None,
source: None,
}
.failure_signature_hint(),
"api_transient|unknown|timeout"
@ -1063,7 +1132,7 @@ mod tests {
assert_eq!(
SdkError::Network {
message: "refused".into(),
source: None,
source: None,
}
.failure_signature_hint(),
"api_transient|unknown|network"
@ -1071,7 +1140,7 @@ mod tests {
assert_eq!(
SdkError::Stream {
message: "broken".into(),
source: None,
source: None,
}
.failure_signature_hint(),
"api_transient|unknown|stream"
@ -1086,7 +1155,7 @@ mod tests {
assert_eq!(
SdkError::Configuration {
message: "bad".into(),
source: None,
source: None,
}
.failure_signature_hint(),
"api_deterministic|unknown|configuration"

View file

@ -10,5 +10,6 @@ pub mod tools;
pub mod types;
// Re-export module-level default client helpers (Section 2.5).
pub use error::{Error, ProviderErrorDetail, ProviderErrorKind, Result, SdkError};
pub use fabro_model::{ModelHandle, Provider};
pub use generate::set_default_client;

View file

@ -75,6 +75,7 @@ regex.workspace = true
semver.workspace = true
walkdir.workspace = true
multer = "3"
thiserror.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -3,10 +3,57 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Workflow(#[from] fabro_workflow::Error),
#[error(transparent)]
Agent(#[from] fabro_agent::Error),
#[error(transparent)]
Llm(#[from] fabro_llm::Error),
#[error(transparent)]
Store(#[from] fabro_store::Error),
#[error(transparent)]
Config(#[from] fabro_config::Error),
#[error(transparent)]
SecretStore(#[from] crate::secret_store::SecretStoreError),
#[error("bad request: {0}")]
BadRequest(String),
#[error("authentication required")]
Unauthorized,
#[error("access denied")]
Forbidden,
#[error("not found: {0}")]
NotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("service unavailable: {0}")]
ServiceUnavailable(String),
#[error("bad gateway: {0}")]
BadGateway(String),
#[error("internal server error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Serialize)]
struct ErrorEntry {
status: String,
title: String,
title: String,
detail: String,
}
@ -49,6 +96,29 @@ impl ApiError {
}
}
impl From<Error> for ApiError {
fn from(err: Error) -> Self {
match err {
Error::BadRequest(msg) => Self::bad_request(msg),
Error::Unauthorized => Self::unauthorized(),
Error::Forbidden => Self::forbidden(),
Error::NotFound(msg) => Self::not_found(msg),
Error::Conflict(msg) => Self::new(StatusCode::CONFLICT, msg),
Error::ServiceUnavailable(msg) => Self::new(StatusCode::SERVICE_UNAVAILABLE, msg),
Error::BadGateway(msg) => Self::new(StatusCode::BAD_GATEWAY, msg),
Error::Workflow(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
Error::Agent(err) => Self::new(StatusCode::BAD_GATEWAY, err.to_string()),
Error::Llm(err) => Self::new(StatusCode::BAD_GATEWAY, err.to_string()),
Error::Store(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
Error::Config(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
Error::SecretStore(err) => {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
Error::Internal(msg) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, msg),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let title = self

View file

@ -18,3 +18,5 @@ mod settings_view;
pub mod static_files;
pub mod tls;
pub mod web_auth;
pub use error::{ApiError, Error, Result};

View file

@ -84,7 +84,7 @@ pub struct ServeArgs {
}
fn load_settings(path: Option<&Path>) -> anyhow::Result<SettingsLayer> {
load_settings_config(path)
Ok(load_settings_config(path)?)
}
fn resolved_config_path(path: Option<&Path>) -> PathBuf {
@ -707,15 +707,15 @@ mod tests {
fn apply_runtime_settings_preserves_storage_dir() {
let base = SettingsLayer::default();
let args = ServeArgs {
bind: None,
model: None,
provider: None,
dry_run: false,
sandbox: None,
web: false,
no_web: false,
bind: None,
model: None,
provider: None,
dry_run: false,
sandbox: None,
web: false,
no_web: false,
max_concurrent_runs: None,
config: None,
config: None,
};
let resolved =
@ -741,15 +741,15 @@ enabled = false
",
);
let args = ServeArgs {
bind: None,
model: None,
provider: None,
dry_run: false,
sandbox: None,
web: true,
no_web: false,
bind: None,
model: None,
provider: None,
dry_run: false,
sandbox: None,
web: true,
no_web: false,
max_concurrent_runs: None,
config: None,
config: None,
};
let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro"));
@ -768,15 +768,15 @@ enabled = false
fn apply_runtime_settings_disables_web_from_cli_flag() {
let base = SettingsLayer::default();
let args = ServeArgs {
bind: None,
model: None,
provider: None,
dry_run: false,
sandbox: None,
web: false,
no_web: true,
bind: None,
model: None,
provider: None,
dry_run: false,
sandbox: None,
web: false,
no_web: true,
max_concurrent_runs: None,
config: None,
config: None,
};
let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro"));

View file

@ -1,7 +1,8 @@
pub type Result<T, E = StoreError> = std::result::Result<T, E>;
pub type Result<T> = std::result::Result<T, Error>;
pub type StoreError = Error;
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
pub enum Error {
#[error("SlateDB error: {0}")]
Slate(#[from] slatedb::Error),
#[error("Object store error: {0}")]

View file

@ -8,7 +8,7 @@ mod slate;
mod types;
pub use artifact_store::{ArtifactStore, NodeArtifact};
pub use error::{Result, StoreError};
pub use error::{Error, Result, StoreError};
pub use fabro_types::{RunBlobId, StageId};
pub use run_state::{NodeState, PendingInterviewRecord, RunProjection};
pub use slate::{Database, RunDatabase, Runs};
@ -17,5 +17,5 @@ pub use types::{EventEnvelope, EventPayload, RunSummary};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ListRunsQuery {
pub start: Option<DateTime<Utc>>,
pub end: Option<DateTime<Utc>>,
pub end: Option<DateTime<Utc>>,
}

View file

@ -1,18 +1,18 @@
use fabro_graphviz::error::GraphvizError;
use fabro_llm::error::{ProviderErrorKind, SdkError};
use fabro_graphviz::Error as GraphvizError;
use fabro_llm::{Error as LlmError, ProviderErrorKind};
pub use fabro_types::failure_signature::FailureSignature;
pub use fabro_types::outcome::FailureCategory;
use fabro_validate::Diagnostic;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use thiserror::Error as ThisError;
use crate::outcome::{FailureDetail, Outcome, StageStatus};
/// Classify an `SdkError` into a `FailureCategory` based on its structure.
/// Classify an LLM error into a `FailureCategory` based on its structure.
#[must_use]
pub fn classify_sdk_error(err: &SdkError) -> FailureCategory {
pub fn classify_sdk_error(err: &LlmError) -> FailureCategory {
match err {
SdkError::Provider { kind, .. } => match kind {
LlmError::Provider { kind, .. } => match kind {
ProviderErrorKind::RateLimit | ProviderErrorKind::Server => {
FailureCategory::TransientInfra
}
@ -25,14 +25,14 @@ pub fn classify_sdk_error(err: &SdkError) -> FailureCategory {
| ProviderErrorKind::InvalidRequest
| ProviderErrorKind::ContentFilter => FailureCategory::Deterministic,
},
SdkError::RequestTimeout { .. } | SdkError::Network { .. } | SdkError::Stream { .. } => {
LlmError::RequestTimeout { .. } | LlmError::Network { .. } | LlmError::Stream { .. } => {
FailureCategory::TransientInfra
}
SdkError::Interrupt { .. } => FailureCategory::Canceled,
SdkError::InvalidToolCall { .. }
| SdkError::NoObjectGenerated { .. }
| SdkError::Configuration { .. }
| SdkError::UnsupportedToolChoice { .. } => FailureCategory::Deterministic,
LlmError::Interrupt { .. } => FailureCategory::Canceled,
LlmError::InvalidToolCall { .. }
| LlmError::NoObjectGenerated { .. }
| LlmError::Configuration { .. }
| LlmError::UnsupportedToolChoice { .. } => FailureCategory::Deterministic,
}
}
@ -188,9 +188,9 @@ impl FailureSignatureExt for FailureSignature {
}
}
#[derive(Error, Debug, Clone, Serialize, Deserialize)]
#[derive(ThisError, Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum FabroError {
pub enum Error {
#[error("Parse error: {0}")]
Parse(String),
@ -202,18 +202,18 @@ pub enum FabroError {
#[error("Engine error: {message}")]
Engine {
message: String,
message: String,
failure_class: FailureCategory,
},
#[error("Handler error: {message}")]
Handler {
message: String,
message: String,
failure_class: FailureCategory,
},
#[error("LLM error: {0}")]
Llm(SdkError),
Llm(LlmError),
#[error("Checkpoint error: {0}")]
Checkpoint(String),
@ -231,7 +231,7 @@ pub enum FabroError {
Cancelled,
}
impl FabroError {
impl Error {
/// Smart constructor for Handler errors. Classifies the failure reason
/// eagerly.
pub fn handler(message: impl Into<String>) -> Self {
@ -308,8 +308,8 @@ impl FabroError {
/// Build a fail `Outcome` with structured `FailureDetail`.
pub fn to_fail_outcome(&self) -> Outcome {
let failure = FailureDetail {
message: self.to_string(),
category: self.failure_category(),
message: self.to_string(),
category: self.failure_category(),
signature: self.failure_signature_hint(),
};
Outcome {
@ -320,19 +320,19 @@ impl FabroError {
}
}
impl From<std::io::Error> for FabroError {
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Self::Io(err.to_string())
}
}
impl From<SdkError> for FabroError {
fn from(err: SdkError) -> Self {
impl From<LlmError> for Error {
fn from(err: LlmError) -> Self {
Self::Llm(err)
}
}
impl From<GraphvizError> for FabroError {
impl From<GraphvizError> for Error {
fn from(e: GraphvizError) -> Self {
match e {
GraphvizError::Parse(msg) => Self::Parse(msg),
@ -341,19 +341,19 @@ impl From<GraphvizError> for FabroError {
}
}
impl From<fabro_template::TemplateError> for FabroError {
impl From<fabro_template::TemplateError> for Error {
fn from(err: fabro_template::TemplateError) -> Self {
Self::Validation(err.to_string())
}
}
impl From<fabro_validate::ValidationError> for FabroError {
impl From<fabro_validate::ValidationError> for Error {
fn from(e: fabro_validate::ValidationError) -> Self {
Self::Validation(e.0)
}
}
impl From<fabro_checkpoint::MetadataError> for FabroError {
impl From<fabro_checkpoint::MetadataError> for Error {
fn from(err: fabro_checkpoint::MetadataError) -> Self {
let message = err.to_string();
match err {
@ -366,12 +366,13 @@ impl From<fabro_checkpoint::MetadataError> for FabroError {
}
}
pub type Result<T> = std::result::Result<T, FabroError>;
pub type Result<T> = std::result::Result<T, Error>;
pub type FabroError = Error;
#[cfg(test)]
mod tests {
use fabro_checkpoint::MetadataError;
use fabro_llm::error::ProviderErrorDetail;
use fabro_llm::{Error as SdkError, ProviderErrorDetail};
use super::*;
use crate::outcome::OutcomeExt;
@ -392,12 +393,12 @@ mod tests {
fn validation_failed_display() {
let err = FabroError::ValidationFailed {
diagnostics: vec![Diagnostic {
rule: "test".to_string(),
rule: "test".to_string(),
severity: fabro_validate::Severity::Error,
message: "missing start node".to_string(),
node_id: None,
edge: None,
fix: None,
message: "missing start node".to_string(),
node_id: None,
edge: None,
fix: None,
}],
};
assert_eq!(err.to_string(), "Validation failed");
@ -676,7 +677,7 @@ mod tests {
fn llm_error_display() {
let sdk_err = SdkError::Network {
message: "connection refused".into(),
source: None,
source: None,
};
let err = FabroError::Llm(sdk_err);
assert_eq!(
@ -689,13 +690,13 @@ mod tests {
fn llm_error_retryable_delegates_to_sdk() {
let retryable = FabroError::Llm(SdkError::Network {
message: "timeout".into(),
source: None,
source: None,
});
assert!(retryable.is_retryable());
let non_retryable = FabroError::Llm(SdkError::Configuration {
message: "bad config".into(),
source: None,
source: None,
});
assert!(!non_retryable.is_retryable());
}
@ -704,7 +705,7 @@ mod tests {
fn llm_error_from_sdk_error() {
let sdk_err = SdkError::Stream {
message: "broken pipe".into(),
source: None,
source: None,
};
let err = FabroError::from(sdk_err);
assert!(matches!(err, FabroError::Llm(_)));
@ -755,7 +756,7 @@ mod tests {
#[test]
fn failure_class_llm_rate_limit() {
let err = FabroError::Llm(SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail::new("too fast", "openai")),
});
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
@ -764,7 +765,7 @@ mod tests {
#[test]
fn failure_class_llm_context_length() {
let err = FabroError::Llm(SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
kind: ProviderErrorKind::ContextLength,
detail: Box::new(ProviderErrorDetail::new("too long", "openai")),
});
assert_eq!(err.failure_category(), FailureCategory::BudgetExhausted);
@ -773,7 +774,7 @@ mod tests {
#[test]
fn failure_class_llm_auth() {
let err = FabroError::Llm(SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("bad key", "openai")),
});
assert_eq!(err.failure_category(), FailureCategory::Deterministic);
@ -791,7 +792,7 @@ mod tests {
fn failure_class_llm_timeout() {
let err = FabroError::Llm(SdkError::RequestTimeout {
message: "timed out".into(),
source: None,
source: None,
});
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
}
@ -801,7 +802,7 @@ mod tests {
#[test]
fn classify_sdk_rate_limit() {
let err = SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail::new("too fast", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureCategory::TransientInfra);
@ -810,7 +811,7 @@ mod tests {
#[test]
fn classify_sdk_server() {
let err = SdkError::Provider {
kind: ProviderErrorKind::Server,
kind: ProviderErrorKind::Server,
detail: Box::new(ProviderErrorDetail::new("500", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureCategory::TransientInfra);
@ -819,7 +820,7 @@ mod tests {
#[test]
fn classify_sdk_context_length() {
let err = SdkError::Provider {
kind: ProviderErrorKind::ContextLength,
kind: ProviderErrorKind::ContextLength,
detail: Box::new(ProviderErrorDetail::new("too long", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureCategory::BudgetExhausted);
@ -828,7 +829,7 @@ mod tests {
#[test]
fn classify_sdk_quota_exceeded() {
let err = SdkError::Provider {
kind: ProviderErrorKind::QuotaExceeded,
kind: ProviderErrorKind::QuotaExceeded,
detail: Box::new(ProviderErrorDetail::new("out of quota", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureCategory::BudgetExhausted);
@ -837,7 +838,7 @@ mod tests {
#[test]
fn classify_sdk_auth() {
let err = SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("bad key", "openai")),
};
assert_eq!(classify_sdk_error(&err), FailureCategory::Deterministic);
@ -847,7 +848,7 @@ mod tests {
fn classify_sdk_request_timeout() {
let err = SdkError::RequestTimeout {
message: "timed out".into(),
source: None,
source: None,
};
assert_eq!(classify_sdk_error(&err), FailureCategory::TransientInfra);
}
@ -1492,7 +1493,7 @@ mod tests {
#[test]
fn failure_signature_hint_llm_returns_some() {
let err = FabroError::Llm(SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("bad key", "openai")),
});
assert_eq!(
@ -1518,7 +1519,7 @@ mod tests {
#[test]
fn to_fail_outcome_llm_has_class_and_signature() {
let err = FabroError::Llm(SdkError::Provider {
kind: ProviderErrorKind::Authentication,
kind: ProviderErrorKind::Authentication,
detail: Box::new(ProviderErrorDetail::new("bad key", "openai")),
});
let outcome = err.to_fail_outcome();
@ -1545,7 +1546,7 @@ mod tests {
fn to_fail_outcome_includes_error_message_as_reason() {
let err = FabroError::Llm(SdkError::Network {
message: "connection refused".into(),
source: None,
source: None,
});
let outcome = err.to_fail_outcome();
assert!(
@ -1560,7 +1561,7 @@ mod tests {
fn to_fail_outcome_no_context_updates() {
let err = FabroError::Llm(SdkError::Network {
message: "refused".into(),
source: None,
source: None,
});
let outcome = err.to_fail_outcome();
assert!(outcome.context_updates.is_empty());
@ -1604,19 +1605,19 @@ mod tests {
FabroError::Validation("bad".into()),
FabroError::ValidationFailed {
diagnostics: vec![Diagnostic {
rule: "test".into(),
rule: "test".into(),
severity: fabro_validate::Severity::Error,
message: "bad".into(),
node_id: None,
edge: None,
fix: None,
message: "bad".into(),
node_id: None,
edge: None,
fix: None,
}],
},
FabroError::engine("engine err"),
FabroError::handler("handler err"),
FabroError::Llm(SdkError::Network {
message: "refused".into(),
source: None,
source: None,
}),
FabroError::Checkpoint("cp err".into()),
FabroError::Stylesheet("style err".into()),
@ -1684,7 +1685,7 @@ mod tests {
// 1. Create SdkError → FabroError
let sdk_err = SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail::new("too fast", "openai")),
};
let arc_err = FabroError::Llm(sdk_err);
@ -1700,10 +1701,10 @@ mod tests {
// 3. Outcome → StageFailed event
let failure = outcome.failure.clone().unwrap();
let event = Event::StageFailed {
node_id: "code".into(),
name: "code".into(),
index: 0,
failure: failure.clone(),
node_id: "code".into(),
name: "code".into(),
index: 0,
failure: failure.clone(),
will_retry: false,
};
@ -1766,10 +1767,10 @@ mod tests {
#[test]
fn e2e_serde_stability_agent_error() {
use fabro_agent::error::AgentError;
use fabro_agent::Error as AgentError;
let err = AgentError::Llm(SdkError::Provider {
kind: ProviderErrorKind::RateLimit,
kind: ProviderErrorKind::RateLimit,
detail: Box::new(ProviderErrorDetail::new("too fast", "openai")),
});
let json = serde_json::to_string(&err).unwrap();

View file

@ -72,15 +72,15 @@ pub fn build_completed_stages(cp: &records::Checkpoint, run_failed: bool) -> Vec
last.failed = true;
} else {
stages.push(CompletedStage {
node_id: "unknown".to_string(),
status: "fail".to_string(),
succeeded: false,
failed: true,
retries: 0,
node_id: "unknown".to_string(),
status: "fail".to_string(),
succeeded: false,
failed: true,
retries: 0,
billing_usd_micros: None,
notes: None,
failure_reason: None,
files_touched: vec![],
notes: None,
failure_reason: None,
files_touched: vec![],
});
}
}
@ -138,6 +138,10 @@ pub mod run_control;
pub(crate) mod run_dir;
pub mod run_dump;
pub mod run_lookup;
pub use error::{
Error, FabroError, FailureCategory, FailureSignature, FailureSignatureExt, Result,
};
pub mod run_materialization;
pub mod run_options;
pub mod run_status;