use thiserror::Error;
pub type Result<T> = std::result::Result<T, OpencodeError>;
#[derive(Debug, Error)]
pub enum OpencodeError {
#[error("HTTP error {status}: {message}")]
Http {
status: u16,
name: Option<String>,
message: String,
data: Option<serde_json::Value>,
},
#[error("Network error: {0}")]
Network(String),
#[error("SSE error: {0}")]
Sse(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("URL error: {0}")]
Url(#[from] url::ParseError),
#[error("Failed to spawn server: {message}")]
SpawnServer {
message: String,
},
#[error("Server not ready within {timeout_ms}ms")]
ServerTimeout {
timeout_ms: u64,
},
#[error("Process error: {0}")]
Process(String),
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Stream closed unexpectedly")]
StreamClosed,
#[error("Session not found: {0}")]
SessionNotFound(String),
#[error("Internal state error: {0}")]
State(String),
#[error("Version mismatch: expected {expected}, got {actual}")]
VersionMismatch {
expected: String,
actual: String,
},
}
#[derive(Debug, Clone)]
pub struct HttpErrorBody {
pub name: Option<String>,
pub message: Option<String>,
pub data: Option<serde_json::Value>,
}
fn format_error_path(path: &[serde_json::Value]) -> Option<String> {
let formatted: Vec<String> = path
.iter()
.map(|segment| match segment {
serde_json::Value::String(value) => value.clone(),
other => other.to_string(),
})
.collect();
if formatted.is_empty() {
None
} else {
Some(formatted.join("."))
}
}
fn format_validator_entry(entry: &serde_json::Value) -> String {
let path = entry
.get("path")
.and_then(serde_json::Value::as_array)
.and_then(|segments| format_error_path(segments));
let message = entry
.get("message")
.and_then(serde_json::Value::as_str)
.map(std::string::ToString::to_string);
match (path, message) {
(Some(path), Some(message)) => format!("{path}: {message}"),
(Some(path), None) => format!("{path}: {entry}"),
(None, Some(message)) => message,
(None, None) => entry.to_string(),
}
}
fn extract_http_error_message(v: &serde_json::Value) -> Option<String> {
if matches!(v.get("success"), Some(serde_json::Value::Bool(false)))
&& let Some(errors) = v.get("error").and_then(serde_json::Value::as_array)
{
let messages: Vec<String> = errors.iter().map(format_validator_entry).collect();
if !messages.is_empty() {
return Some(messages.join("; "));
}
}
if let (Some(name), Some(message)) = (
v.get("name").and_then(serde_json::Value::as_str),
v.get("data")
.and_then(|data| data.get("message"))
.and_then(serde_json::Value::as_str),
) {
return Some(format!("{name}: {message}"));
}
v.get("message")
.and_then(serde_json::Value::as_str)
.map(std::string::ToString::to_string)
}
fn truncate_body_text(body_text: &str, max_chars: usize) -> String {
match body_text.char_indices().nth(max_chars) {
Some((idx, _)) => format!("{}…", &body_text[..idx]),
None => body_text.to_string(),
}
}
impl HttpErrorBody {
pub fn from_json(v: &serde_json::Value) -> Self {
Self {
name: v
.get("name")
.and_then(|x| x.as_str())
.map(std::string::ToString::to_string),
message: extract_http_error_message(v),
data: v.get("data").cloned(),
}
}
}
impl OpencodeError {
pub fn http(status: u16, body_text: &str) -> Self {
let parsed: Option<serde_json::Value> = serde_json::from_str(body_text).ok();
let info = parsed.as_ref().map(HttpErrorBody::from_json);
let message = info
.as_ref()
.and_then(|i| i.message.clone())
.unwrap_or_else(|| {
let truncated = truncate_body_text(body_text, 1024);
if truncated.trim().is_empty() {
format!("HTTP {status}")
} else {
truncated
}
});
Self::Http {
status,
name: info.as_ref().and_then(|i| i.name.clone()),
message,
data: info.and_then(|i| i.data),
}
}
pub fn is_not_found(&self) -> bool {
matches!(self, Self::Http { status: 404, .. })
}
pub fn is_validation_error(&self) -> bool {
matches!(self, Self::Http { status: 400, .. })
}
pub fn is_server_error(&self) -> bool {
matches!(self, Self::Http { status, .. } if *status >= 500)
}
pub fn error_name(&self) -> Option<&str> {
match self {
Self::Http { name, .. } => name.as_deref(),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_error_from_named_error() {
let body = r#"{"name":"NotFound","data":{"message":"Session not found","id":"123"}}"#;
let err = OpencodeError::http(404, body);
match err {
OpencodeError::Http {
status,
name,
message,
data,
} => {
assert_eq!(status, 404);
assert_eq!(name, Some("NotFound".to_string()));
assert_eq!(message, "NotFound: Session not found");
assert!(data.is_some());
}
_ => panic!("Expected Http error"),
}
}
#[test]
fn test_http_error_from_plain_text() {
let err = OpencodeError::http(500, "Internal Server Error");
match err {
OpencodeError::Http {
status,
name,
message,
..
} => {
assert_eq!(status, 500);
assert!(name.is_none());
assert_eq!(message, "Internal Server Error");
}
_ => panic!("Expected Http error"),
}
}
#[test]
fn test_http_error_from_empty_body_uses_status_fallback() {
let err = OpencodeError::http(502, "");
match err {
OpencodeError::Http {
status,
name,
message,
..
} => {
assert_eq!(status, 502);
assert!(name.is_none());
assert_eq!(message, "HTTP 502");
}
_ => panic!("Expected Http error"),
}
}
#[test]
fn test_http_error_from_whitespace_body_uses_status_fallback() {
let err = OpencodeError::http(503, " \n");
match err {
OpencodeError::Http {
status,
name,
message,
..
} => {
assert_eq!(status, 503);
assert!(name.is_none());
assert_eq!(message, "HTTP 503");
}
_ => panic!("Expected Http error"),
}
}
#[test]
fn test_http_error_from_legacy_top_level_message() {
let body = r#"{"name":"ValidationError","message":"Legacy message"}"#;
let err = OpencodeError::http(400, body);
match err {
OpencodeError::Http { name, message, .. } => {
assert_eq!(name, Some("ValidationError".to_string()));
assert_eq!(message, "Legacy message");
}
_ => panic!("Expected Http error"),
}
}
#[test]
fn test_http_error_from_validator_messageid_invalid() {
let body = r#"{
"data": {"command":"linear","arguments":"hello","messageID":"550e8400-e29b-41d4-a716-446655440000"},
"error": [
{
"origin": "string",
"code": "invalid_format",
"format": "starts_with",
"prefix": "msg",
"path": ["messageID"],
"message": "Invalid string: must start with \"msg\""
}
],
"success": false
}"#;
let err = OpencodeError::http(400, body);
match err {
OpencodeError::Http { message, .. } => {
assert!(message.contains("messageID"), "message was: {message}");
assert!(
message.contains("must start with"),
"message was: {message}"
);
}
_ => panic!("Expected Http error"),
}
}
#[test]
fn test_http_error_from_named_error_unknown_command() {
let body = r#"{
"name": "UnknownError",
"data": {
"message": "Command not found: \"___definitely_not_a_real_command___\". Available commands: init, review, ..."
}
}"#;
let err = OpencodeError::http(400, body);
match err {
OpencodeError::Http { message, .. } => {
assert!(
message.contains("Command not found"),
"message was: {message}"
);
assert!(
message.contains("___definitely_not_a_real_command___"),
"message was: {message}"
);
}
_ => panic!("Expected Http error"),
}
}
#[test]
fn test_http_error_from_unknown_shape_preserves_body() {
let body = r#"{ "weird": "shape" }"#;
let err = OpencodeError::http(418, body);
match err {
OpencodeError::Http { message, .. } => {
assert!(message.contains(body), "message was: {message}");
}
_ => panic!("Expected Http error"),
}
}
#[test]
fn test_is_not_found() {
let err = OpencodeError::http(404, "{}");
assert!(err.is_not_found());
let err = OpencodeError::http(200, "{}");
assert!(!err.is_not_found());
}
#[test]
fn test_is_validation_error() {
let err = OpencodeError::http(400, r#"{"name":"ValidationError"}"#);
assert!(err.is_validation_error());
assert_eq!(err.error_name(), Some("ValidationError"));
}
#[test]
fn test_is_server_error() {
let err = OpencodeError::http(500, "{}");
assert!(err.is_server_error());
let err = OpencodeError::http(503, "{}");
assert!(err.is_server_error());
let err = OpencodeError::http(400, "{}");
assert!(!err.is_server_error());
}
}