fix: preserve workdir failure diagnostics across transport
This commit is contained in:
@@ -525,6 +525,8 @@ pub struct ExternalWorkdirOperationError {
|
||||
code: crate::http::WorkdirTransportErrorCode,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
denial_reason: Option<crate::WorkdirDenialReason>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
failure_reason: Option<crate::WorkdirFailureReason>,
|
||||
}
|
||||
|
||||
impl ExternalWorkdirOperationError {
|
||||
@@ -534,11 +536,13 @@ impl ExternalWorkdirOperationError {
|
||||
Self {
|
||||
code: error.code,
|
||||
denial_reason: error.denial_reason,
|
||||
failure_reason: error.failure_reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_transport_error(self) -> WorkdirTransportError {
|
||||
WorkdirTransportError {
|
||||
failure_reason: self.failure_reason,
|
||||
code: self.code,
|
||||
denial_reason: self.denial_reason,
|
||||
message: format!("External Workdir provider reported {}", self.code.as_str()),
|
||||
@@ -762,6 +766,7 @@ mod tests {
|
||||
] {
|
||||
let external =
|
||||
ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
failure_reason: None,
|
||||
code: Code::Denied,
|
||||
message: "/home/operator/private provider-secret".repeat(4096),
|
||||
denial_reason: reason,
|
||||
@@ -788,6 +793,7 @@ mod tests {
|
||||
#[test]
|
||||
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
|
||||
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
failure_reason: None,
|
||||
code: crate::http::WorkdirTransportErrorCode::Internal,
|
||||
denial_reason: None,
|
||||
message: "/home/operator/private provider-secret\ncontrol".to_string(),
|
||||
|
||||
@@ -275,6 +275,8 @@ pub struct WorkdirTransportError {
|
||||
/// Internal provider diagnostics. Public response boundaries may omit this.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub denial_reason: Option<crate::WorkdirDenialReason>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub failure_reason: Option<crate::WorkdirFailureReason>,
|
||||
}
|
||||
|
||||
impl WorkdirTransportError {
|
||||
@@ -282,6 +284,11 @@ impl WorkdirTransportError {
|
||||
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
let (code, message) = match error {
|
||||
WorkdirError::FailureContext { reason, source } => {
|
||||
let mut transport = Self::from_workdir_error(source);
|
||||
transport.failure_reason = Some(*reason);
|
||||
return transport;
|
||||
}
|
||||
WorkdirError::DenialContext { reason, source } => {
|
||||
let mut transport = Self::from_workdir_error(source);
|
||||
transport.denial_reason = Some(*reason);
|
||||
@@ -301,6 +308,7 @@ impl WorkdirTransportError {
|
||||
code: Code::Unsupported,
|
||||
message: format!("Workdir capability {capability:?} is not available"),
|
||||
denial_reason: None,
|
||||
failure_reason: None,
|
||||
};
|
||||
}
|
||||
WorkdirError::UnsupportedOperation(_) => {
|
||||
@@ -359,6 +367,7 @@ impl WorkdirTransportError {
|
||||
code,
|
||||
message: message.to_string(),
|
||||
denial_reason: error.denial_reason(),
|
||||
failure_reason: error.failure_reason(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +417,11 @@ impl WorkdirTransportError {
|
||||
Code::Transport => WorkdirError::Transport(self.message),
|
||||
Code::Internal => WorkdirError::OperationFailed,
|
||||
};
|
||||
let error = if let Some(reason) = self.failure_reason {
|
||||
error.with_failure_reason(reason)
|
||||
} else {
|
||||
error
|
||||
};
|
||||
// Preserve any supplied reason independently of the public classification.
|
||||
// Without a reason, retain the existing classification's deliberate fallback.
|
||||
if let Some(reason) = self.denial_reason {
|
||||
@@ -557,9 +571,10 @@ mod client {
|
||||
operation: WorkdirSessionOperation,
|
||||
) -> Result<WorkdirSessionOperationResult, WorkdirError> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(WorkdirError::Unavailable(
|
||||
"Workdir session is closed".to_string(),
|
||||
));
|
||||
return Err(
|
||||
WorkdirError::Unavailable("Workdir session is closed".to_string())
|
||||
.with_failure_reason(crate::WorkdirFailureReason::SessionClosed),
|
||||
);
|
||||
}
|
||||
let url = endpoint(
|
||||
&self.base_url,
|
||||
@@ -767,6 +782,10 @@ mod client {
|
||||
let outcome = WorkdirError::OutcomeUnknown(
|
||||
"checkout transport failed; effects may have occurred".into(),
|
||||
);
|
||||
let outcome = match error.failure_reason() {
|
||||
Some(reason) => outcome.with_failure_reason(reason),
|
||||
None => outcome,
|
||||
};
|
||||
Err(match error.denial_reason() {
|
||||
Some(reason) => WorkdirError::DenialContext {
|
||||
reason,
|
||||
@@ -966,8 +985,10 @@ mod client {
|
||||
) -> Result<T, WorkdirError> {
|
||||
if response.status().is_success() {
|
||||
let bytes = bounded_response_bytes(response).await?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|_| WorkdirError::Transport("Runtime Workdir response was invalid".into()))
|
||||
serde_json::from_slice(&bytes).map_err(|_| {
|
||||
WorkdirError::Transport("Runtime Workdir response was invalid".into())
|
||||
.with_failure_reason(crate::WorkdirFailureReason::HttpResponseInvalid)
|
||||
})
|
||||
} else {
|
||||
Err(decode_error(response).await)
|
||||
}
|
||||
@@ -983,14 +1004,16 @@ mod client {
|
||||
{
|
||||
return Err(WorkdirError::Transport(
|
||||
"Runtime Workdir response exceeded transport bound".into(),
|
||||
));
|
||||
)
|
||||
.with_failure_reason(crate::WorkdirFailureReason::HttpResponseTooLarge));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_unavailable)? {
|
||||
if chunk.len() > MAX_RESPONSE_BYTES.saturating_sub(bytes.len()) {
|
||||
return Err(WorkdirError::Transport(
|
||||
"Runtime Workdir response exceeded transport bound".into(),
|
||||
));
|
||||
)
|
||||
.with_failure_reason(crate::WorkdirFailureReason::HttpResponseTooLarge));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
@@ -1003,13 +1026,22 @@ mod client {
|
||||
.map(WorkdirTransportError::into_workdir_error)
|
||||
.unwrap_or_else(|_| {
|
||||
WorkdirError::Transport("Runtime Workdir error response was invalid".into())
|
||||
.with_failure_reason(crate::WorkdirFailureReason::HttpResponseInvalid)
|
||||
}),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_unavailable(_error: reqwest::Error) -> WorkdirError {
|
||||
fn http_unavailable(error: reqwest::Error) -> WorkdirError {
|
||||
let reason = if error.is_timeout() {
|
||||
crate::WorkdirFailureReason::HttpTimeout
|
||||
} else if error.is_connect() {
|
||||
crate::WorkdirFailureReason::HttpConnect
|
||||
} else {
|
||||
crate::WorkdirFailureReason::HttpTransfer
|
||||
};
|
||||
WorkdirError::Transport("Runtime Workdir HTTP request failed".to_string())
|
||||
.with_failure_reason(reason)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1017,6 +1049,40 @@ mod client {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_failure_reasons_do_not_copy_urls_or_credentials() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let client = Client::builder().no_proxy().build().unwrap();
|
||||
// A listening socket that never replies makes a real HTTP timeout.
|
||||
let error = client
|
||||
.get(format!("http://{address}/private?token=secret"))
|
||||
.timeout(std::time::Duration::from_millis(25))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.is_timeout());
|
||||
let error = http_unavailable(error);
|
||||
assert_eq!(
|
||||
error.failure_reason(),
|
||||
Some(crate::WorkdirFailureReason::HttpTimeout)
|
||||
);
|
||||
assert!(!format!("{error:?}").contains("secret"));
|
||||
assert!(!format!("{error:?}").contains("/private"));
|
||||
drop(listener);
|
||||
let error = client
|
||||
.get(format!("http://{address}/private?token=secret"))
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.is_connect());
|
||||
assert_eq!(
|
||||
http_unavailable(error).failure_reason(),
|
||||
Some(crate::WorkdirFailureReason::HttpConnect)
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RejectAuthorization {
|
||||
requests: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
|
||||
@@ -1193,6 +1259,7 @@ mod tests {
|
||||
(WorkdirTransportErrorCode::Internal, 500, "operation failed"),
|
||||
] {
|
||||
let transport = WorkdirTransportError {
|
||||
failure_reason: None,
|
||||
code,
|
||||
denial_reason: None,
|
||||
message: if code == WorkdirTransportErrorCode::Conflict {
|
||||
|
||||
@@ -401,8 +401,44 @@ impl From<&str> for WorkdirDenial {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded operational diagnostics, never provider text, paths or credentials.
|
||||
/// Reasons describe an observation, not retry permission or proof of no effects.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkdirFailureReason {
|
||||
SessionClosed,
|
||||
ProviderUnavailable,
|
||||
CommandTerminalMissing,
|
||||
HttpTimeout,
|
||||
HttpConnect,
|
||||
HttpTransfer,
|
||||
HttpResponseInvalid,
|
||||
HttpResponseTooLarge,
|
||||
}
|
||||
|
||||
impl WorkdirFailureReason {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SessionClosed => "session_closed",
|
||||
Self::ProviderUnavailable => "provider_unavailable",
|
||||
Self::CommandTerminalMissing => "command_terminal_missing",
|
||||
Self::HttpTimeout => "http_timeout",
|
||||
Self::HttpConnect => "http_connect",
|
||||
Self::HttpTransfer => "http_transfer",
|
||||
Self::HttpResponseInvalid => "http_response_invalid",
|
||||
Self::HttpResponseTooLarge => "http_response_too_large",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkdirError {
|
||||
#[error("{source}")]
|
||||
FailureContext {
|
||||
reason: WorkdirFailureReason,
|
||||
#[source]
|
||||
source: Box<WorkdirError>,
|
||||
},
|
||||
#[error("Workdir operation denied: {0}")]
|
||||
Denied(WorkdirDenial),
|
||||
|
||||
@@ -521,12 +557,29 @@ impl WorkdirError {
|
||||
/// and `denial_reason`: enrichment must not alter behavior or retry policy.
|
||||
pub fn classification_source(&self) -> &Self {
|
||||
let mut error = self;
|
||||
while let Self::DenialContext { source, .. } = error {
|
||||
while let Self::DenialContext { source, .. } | Self::FailureContext { source, .. } = error {
|
||||
error = source;
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
pub fn with_failure_reason(self, reason: WorkdirFailureReason) -> Self {
|
||||
Self::FailureContext {
|
||||
reason,
|
||||
source: Box::new(self),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failure_reason(&self) -> Option<WorkdirFailureReason> {
|
||||
match self {
|
||||
Self::FailureContext { reason, .. } => Some(*reason),
|
||||
Self::DenialContext { source, .. } => source.failure_reason(),
|
||||
Self::SessionClosed => Some(WorkdirFailureReason::SessionClosed),
|
||||
Self::Unavailable(_) => Some(WorkdirFailureReason::ProviderUnavailable),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn denied(reason: WorkdirDenialReason, message: impl Into<String>) -> Self {
|
||||
Self::Denied(WorkdirDenial {
|
||||
reason: Some(reason),
|
||||
@@ -540,6 +593,7 @@ impl WorkdirError {
|
||||
match self {
|
||||
Self::Denied(denial) => denial.reason,
|
||||
Self::DenialContext { reason, .. } => Some(*reason),
|
||||
Self::FailureContext { source, .. } => source.denial_reason(),
|
||||
Self::OutOfScope(_) => Some(Reason::PathOutOfScope),
|
||||
Self::SymlinkOutOfScope { .. } => Some(Reason::SymlinkTargetOutOfScope),
|
||||
Self::ReadOnly(_) => Some(Reason::PathReadOnly),
|
||||
|
||||
@@ -1210,7 +1210,8 @@ impl LocalWorkdirSession {
|
||||
Err(WorkdirError::Unavailable(format!(
|
||||
"Workdir session for {} is closed",
|
||||
self.inner.workdir.id()
|
||||
)))
|
||||
))
|
||||
.with_failure_reason(crate::WorkdirFailureReason::SessionClosed))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -2546,11 +2547,18 @@ mod tests {
|
||||
.expect("close should wake command output waiters")
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(waiter_error, WorkdirError::Unavailable(_)));
|
||||
assert!(matches!(
|
||||
waiter_error.classification_source(),
|
||||
WorkdirError::Unavailable(_)
|
||||
));
|
||||
assert_eq!(
|
||||
waiter_error.failure_reason(),
|
||||
Some(crate::WorkdirFailureReason::SessionClosed)
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
WorkdirSession::command_status(&session, command).await,
|
||||
Err(WorkdirError::Unavailable(_))
|
||||
Err(ref error) if matches!(error.classification_source(), WorkdirError::Unavailable(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
WorkdirSession::read(
|
||||
@@ -2563,7 +2571,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.await,
|
||||
Err(WorkdirError::Unavailable(_))
|
||||
Err(ref error) if matches!(error.classification_source(), WorkdirError::Unavailable(_))
|
||||
));
|
||||
|
||||
let restored = LocalWorkdirSession::materialized_bound(
|
||||
@@ -3531,7 +3539,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Err(WorkdirError::Unavailable(message)) if message.contains("is closed")
|
||||
Err(ref error) if matches!(error.classification_source(), WorkdirError::Unavailable(message) if message.contains("is closed"))
|
||||
));
|
||||
assert!(
|
||||
session.command_snapshot().is_empty(),
|
||||
|
||||
@@ -113,6 +113,7 @@ impl Transport {
|
||||
WorkdirError::Unavailable(
|
||||
"command transport ended before terminal fence".into(),
|
||||
)
|
||||
.with_failure_reason(crate::WorkdirFailureReason::CommandTerminalMissing)
|
||||
})?;
|
||||
self.terminal = Some(terminal.clone());
|
||||
page.terminal = Some(terminal);
|
||||
|
||||
@@ -4,6 +4,38 @@
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_terminal_is_diagnosed_without_changing_error_classification() {
|
||||
let (sender, receiver) = mpsc::channel(1);
|
||||
let (_status, terminal_status) = watch::channel(None);
|
||||
drop(sender);
|
||||
let mut transport = Transport {
|
||||
receiver,
|
||||
pending: None,
|
||||
cursor: 0,
|
||||
replay: None,
|
||||
terminal: None,
|
||||
terminal_status,
|
||||
};
|
||||
let error = transport
|
||||
.read(&CommandOutputRequest {
|
||||
handle: CommandHandle("diagnostic-test".into()),
|
||||
cursor: 0,
|
||||
limit: 16,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.failure_reason(),
|
||||
Some(crate::WorkdirFailureReason::CommandTerminalMissing)
|
||||
);
|
||||
assert!(matches!(
|
||||
error.classification_source(),
|
||||
WorkdirError::Unavailable(_)
|
||||
));
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
session: LocalWorkdirSession,
|
||||
_root: TempDir,
|
||||
|
||||
@@ -842,7 +842,7 @@ mod tests {
|
||||
assert!(router.is_empty());
|
||||
assert!(matches!(
|
||||
underlying.command_status(handle).await,
|
||||
Err(WorkdirError::Unavailable(_))
|
||||
Err(ref error) if matches!(error.classification_source(), WorkdirError::Unavailable(_))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use workdir::external::ExternalWorkdirOperationError;
|
||||
use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode as Code};
|
||||
use workdir::{WorkdirError, WorkdirFailureReason as Reason};
|
||||
|
||||
#[test]
|
||||
fn failure_reasons_survive_internal_transports_without_provider_text() {
|
||||
for reason in [
|
||||
Reason::SessionClosed,
|
||||
Reason::ProviderUnavailable,
|
||||
Reason::CommandTerminalMissing,
|
||||
Reason::HttpTimeout,
|
||||
Reason::HttpConnect,
|
||||
Reason::HttpTransfer,
|
||||
Reason::HttpResponseInvalid,
|
||||
Reason::HttpResponseTooLarge,
|
||||
] {
|
||||
let source = WorkdirError::Unavailable("secret-token /private/path".into())
|
||||
.with_failure_reason(reason);
|
||||
let transport = WorkdirTransportError::from_workdir_error(&source);
|
||||
let wire = serde_json::to_string(&transport).unwrap();
|
||||
assert!(!wire.contains("secret-token"));
|
||||
assert!(!wire.contains("/private/path"));
|
||||
assert_eq!(transport.code, Code::Unavailable);
|
||||
assert_eq!(transport.code.http_status(), 503);
|
||||
assert_eq!(transport.message, "Workdir session is unavailable");
|
||||
assert_eq!(serde_json::to_value(reason).unwrap(), reason.as_str());
|
||||
let decoded: WorkdirTransportError = serde_json::from_str(&wire).unwrap();
|
||||
let external = ExternalWorkdirOperationError::from_transport_error(decoded);
|
||||
let external_wire = serde_json::to_string(&external).unwrap();
|
||||
let external: ExternalWorkdirOperationError = serde_json::from_str(&external_wire).unwrap();
|
||||
let error = external.into_transport_error().into_workdir_error();
|
||||
assert_eq!(error.failure_reason(), Some(reason));
|
||||
assert!(matches!(
|
||||
error.classification_source(),
|
||||
WorkdirError::Unavailable(_)
|
||||
));
|
||||
assert_eq!(
|
||||
WorkdirTransportError::from_workdir_error(&error).failure_reason,
|
||||
Some(reason)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_errors_and_nested_context_preserve_classification() {
|
||||
let wire = r#"{"code":"unavailable","message":"Workdir session is unavailable"}"#;
|
||||
let old: WorkdirTransportError = serde_json::from_str(wire).unwrap();
|
||||
assert!(old.failure_reason.is_none());
|
||||
assert!(matches!(
|
||||
old.into_workdir_error(),
|
||||
WorkdirError::Unavailable(_)
|
||||
));
|
||||
let closed = WorkdirTransportError::from_workdir_error(&WorkdirError::SessionClosed);
|
||||
assert_eq!(closed.failure_reason, Some(Reason::SessionClosed));
|
||||
// Reason-looking text never becomes a typed diagnosis.
|
||||
let generic = WorkdirError::Unavailable("http_timeout".into());
|
||||
assert_eq!(generic.failure_reason(), Some(Reason::ProviderUnavailable));
|
||||
let error = WorkdirError::DenialContext {
|
||||
reason: workdir::WorkdirDenialReason::OsPermissionDenied,
|
||||
source: Box::new(
|
||||
WorkdirError::OutcomeUnknown("do not retry".into())
|
||||
.with_failure_reason(Reason::HttpTransfer),
|
||||
),
|
||||
};
|
||||
let error = WorkdirTransportError::from_workdir_error(&error).into_workdir_error();
|
||||
assert!(matches!(
|
||||
error.classification_source(),
|
||||
WorkdirError::OutcomeUnknown(_)
|
||||
));
|
||||
assert_eq!(
|
||||
error.denial_reason(),
|
||||
Some(workdir::WorkdirDenialReason::OsPermissionDenied)
|
||||
);
|
||||
assert_eq!(error.failure_reason(), Some(Reason::HttpTransfer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_cannot_inject_free_text_failure_reason() {
|
||||
assert!(
|
||||
serde_json::from_str::<WorkdirTransportError>(
|
||||
r#"{"code":"unavailable","message":"x","failure_reason":"secret /path"}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user