Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::process::ExitCode;

use crate::services;
use services::app_support::{self, RunOutcome};
use services::error::ClassifiedError;
use services::error::CliError;
use services::observability::traits::{
Logger as LoggerTrait, NoopTelemetry, Telemetry as TelemetryTrait,
};
Expand Down Expand Up @@ -310,17 +310,19 @@ where

fn perform_dependency_check<F: FnOnce() -> anyhow::Result<()>>(
dependency_check: F,
) -> Result<(), ClassifiedError> {
) -> Result<(), CliError> {
dependency_check().map_err(|error| {
ClassifiedError::dependency(format!("Failed to initialize dependency checks: {error}"))
CliError::dependency(anyhow::Error::msg(format!(
"Failed to initialize dependency checks: {error}"
)))
})
}

fn build_startup_context() -> Result<StartupContext, ClassifiedError> {
fn build_startup_context() -> Result<StartupContext, CliError> {
let cwd = std::env::current_dir().map_err(|error| {
ClassifiedError::runtime(format!(
CliError::runtime(anyhow::Error::msg(format!(
"Failed to determine current directory for observability config resolution: {error}"
))
)))
})?;
let observability_config = services::config::resolve_observability_runtime_config(&cwd)
.map_err(|error| app_support::classify_observability_configuration_error(&error))?;
Expand All @@ -332,7 +334,7 @@ fn build_startup_context() -> Result<StartupContext, ClassifiedError> {
})
}

fn initialize_runtime(startup: StartupContext) -> Result<AppRuntime, ClassifiedError> {
fn initialize_runtime(startup: StartupContext) -> Result<AppRuntime, CliError> {
let logger =
services::observability::Logger::from_resolved_config(&startup.observability_config)
.map_err(|error| app_support::classify_observability_configuration_error(&error))?;
Expand All @@ -351,7 +353,7 @@ fn run_command_lifecycle<I, StderrW>(
args: I,
runtime: &AppRuntime,
stderr: &mut StderrW,
) -> Result<String, ClassifiedError>
) -> Result<String, CliError>
where
I: IntoIterator<Item = String>,
StderrW: Write,
Expand All @@ -366,7 +368,9 @@ where
None,
);
let Some(command_args) = args.take() else {
return Err(ClassifiedError::runtime(REPEATED_COMMAND_DISPATCH_ERROR));
return Err(CliError::runtime(anyhow::Error::msg(
REPEATED_COMMAND_DISPATCH_ERROR,
)));
};
let command = parse_command_phase(command_args, &runtime.registry, &context)?;
app_support::execute_command_phase(&command, &context, stderr)
Expand All @@ -377,7 +381,7 @@ fn parse_command_phase<I>(
args: I,
registry: &services::command_registry::CommandRegistry,
context: &impl HasLogger,
) -> Result<services::command_registry::RuntimeCommand, ClassifiedError>
) -> Result<services::command_registry::RuntimeCommand, CliError>
where
I: IntoIterator<Item = String>,
{
Expand Down
16 changes: 15 additions & 1 deletion cli/src/services/agent_trace_sync/control_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const STATE_RETRY_INITIAL_BACKOFF_MS: u64 = 250;
const STATE_RETRY_MAX_BACKOFF_MS: u64 = 2_000;

/// Typed failure classification for control-plane HTTP interactions, kept
/// separate from `ClassifiedError` so the sync engine and CLI wiring can
/// separate from `CliError` so the sync engine and CLI wiring can
/// react to each case before deciding how to surface it.
#[derive(Debug)]
pub enum ControlPlaneError {
Expand Down Expand Up @@ -172,6 +172,20 @@ impl fmt::Display for ControlPlaneError {

impl std::error::Error for ControlPlaneError {}

impl ControlPlaneError {
/// True only for the two variants that mean the caller has no usable
/// `WorkOS` credentials: no stored token, or a token the control plane
/// rejected as invalid/expired. Every other variant is a different kind
/// of failure (request shape, ownership, transport, server-side) and
/// must not be classified as an authentication failure.
pub fn is_authentication_failure(&self) -> bool {
matches!(
self,
Self::MissingCredentials | Self::AuthenticationFailed(_)
)
}
}

impl From<TokenStorageError> for ControlPlaneError {
fn from(value: TokenStorageError) -> Self {
Self::Storage(value.to_string())
Expand Down
31 changes: 23 additions & 8 deletions cli/src/services/agent_trace_sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::services::agent_trace_export::{
AgentTraceAgentTraceExportRow, AgentTraceDiffTraceExportRow, AgentTraceMessageExportRow,
AgentTracePartExportRow,
};
use control_plane::ControlPlaneError;

/// Bound on consecutive `409`/ambiguous-batch-failure reconciliation attempts
/// for one stream, matching the order of magnitude of existing retry
Expand Down Expand Up @@ -58,7 +59,7 @@ impl AgentTraceExportRow for AgentTraceAgentTraceExportRow {
/// anything about the failed attempt itself. `Terminal` is different: the
/// attempt is known to have failed in a way that cannot be resolved by
/// `/state`, so the stream stops without invoking its refresh closure.
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug)]
pub enum BatchAttemptOutcome {
/// The batch was accepted. `accepted` and `cursor` are the server
/// response's own fields, validated by the engine before the stream
Expand All @@ -69,9 +70,10 @@ pub enum BatchAttemptOutcome {
/// The batch outcome could not be determined (`5xx`, a transport
/// failure, or an invalid response).
Ambiguous,
/// The batch failed with a terminal control-plane error. The string is
/// already safe to surface as a stream error and is never reconciled.
Terminal(String),
/// The batch failed with a terminal control-plane error. The typed
/// error is already safe to surface as a stream error and is never
/// reconciled.
Terminal(ControlPlaneError),
}

/// Terminal failure of [`sync_stream`].
Expand All @@ -80,14 +82,14 @@ pub enum StreamSyncError {
/// The local-row reader closure failed.
Read(String),
/// The `/state`-refresh closure failed.
Refresh(String),
Refresh(ControlPlaneError),
/// A syntactically successful batch response did not match the rows
/// that were sent (`accepted != rows.len()` or
/// `cursor != rows.last().source_row_id()`).
InvalidResponse(String),
/// The batch failed with a terminal control-plane error. Unlike
/// [`Self::Refresh`], this does not represent a failed `/state` call.
Terminal(String),
Terminal(ControlPlaneError),
/// The reconciliation loop exceeded [`RECONCILIATION_MAX_ATTEMPTS`]
/// without converging.
DidNotConverge,
Expand All @@ -112,6 +114,19 @@ impl fmt::Display for StreamSyncError {

impl std::error::Error for StreamSyncError {}

impl StreamSyncError {
/// True only when the underlying `ControlPlaneError` (from a `Refresh`
/// or `Terminal` failure) means the caller has no usable credentials.
/// `Read`, `InvalidResponse`, and `DidNotConverge` never carry a
/// `ControlPlaneError` and are never authentication failures.
pub fn is_authentication_failure(&self) -> bool {
match self {
Self::Refresh(error) | Self::Terminal(error) => error.is_authentication_failure(),
Self::Read(_) | Self::InvalidResponse(_) | Self::DidNotConverge => false,
}
}
}

/// Outcome of a fully converged [`sync_stream`] run for one stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StreamSyncOutcome {
Expand Down Expand Up @@ -437,7 +452,7 @@ mod tests {
|cursor, limit| ready(Ok(local.after(cursor, limit))),
|_cursor, _rows: &[AgentTraceMessageExportRow]| {
ready(BatchAttemptOutcome::Terminal(
"batch route is not supported".to_string(),
ControlPlaneError::BadRequest("batch route is not supported".to_string()),
))
},
|| {
Expand All @@ -448,7 +463,7 @@ mod tests {

assert!(matches!(
result,
Err(StreamSyncError::Terminal(reason)) if reason == "batch route is not supported"
Err(StreamSyncError::Terminal(ControlPlaneError::BadRequest(reason))) if reason == "batch route is not supported"
));
assert_eq!(*refresh_calls.borrow(), 0);
}
Expand Down
Loading
Loading