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>,
}
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: v
.get("message")
.and_then(|x| x.as_str())
.map(std::string::ToString::to_string),
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);
Self::Http {
status,
name: info.as_ref().and_then(|i| i.name.clone()),
message: info
.as_ref()
.and_then(|i| i.message.clone())
.unwrap_or_else(|| format!("HTTP {status}")),
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","message":"Session not found","data":{"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, "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, "HTTP 500");
}
_ => 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());
}
}