use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
pub type LettaResult<T> = Result<T, LettaError>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnauthorizedError {
pub message: String,
pub details: String,
pub ownership: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DetailError {
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageError {
pub message: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ErrorBody {
Text(String),
Unauthorized(UnauthorizedError),
Detail(DetailError),
Message(MessageError),
Json(serde_json::Value),
}
impl ErrorBody {
pub fn from_response(body: &str) -> Self {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(body) {
if let Ok(unauthorized) = serde_json::from_value::<UnauthorizedError>(json.clone()) {
return Self::Unauthorized(unauthorized);
}
if let Ok(detail) = serde_json::from_value::<DetailError>(json.clone()) {
return Self::Detail(detail);
}
if let Ok(message) = serde_json::from_value::<MessageError>(json.clone()) {
return Self::Message(message);
}
Self::Json(json)
} else {
let text = if body.contains("<pre>") && body.contains("</pre>") {
if let Some(start) = body.find("<pre>") {
if let Some(end) = body.find("</pre>") {
let content = &body[start + 5..end];
content.to_string()
} else {
body.to_string()
}
} else {
body.to_string()
}
} else {
body.to_string()
};
Self::Text(text)
}
}
pub fn message(&self) -> Option<String> {
match self {
Self::Text(text) => {
if text.trim().is_empty() {
None
} else {
Some(text.clone())
}
}
Self::Unauthorized(err) => Some(err.message.clone()),
Self::Detail(err) => Some(err.detail.clone()),
Self::Message(err) => Some(err.message.clone()),
Self::Json(json) => {
json.get("message")
.or_else(|| json.get("error"))
.or_else(|| json.get("detail"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
}
}
pub fn code(&self) -> Option<String> {
match self {
Self::Json(json) => json
.get("code")
.or_else(|| json.get("error_code"))
.or_else(|| json.get("type"))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
_ => None,
}
}
pub fn is_validation_error(&self) -> bool {
match self {
Self::Detail(detail) => detail.detail.contains("validation error"),
_ => false,
}
}
pub fn as_str(&self) -> String {
match self {
Self::Text(text) => text.clone(),
Self::Json(json) => serde_json::to_string(json).unwrap_or_else(|_| json.to_string()),
Self::Unauthorized(err) => {
serde_json::to_string(err).unwrap_or_else(|_| format!("{:?}", err))
}
Self::Detail(err) => {
serde_json::to_string(err).unwrap_or_else(|_| format!("{:?}", err))
}
Self::Message(err) => {
serde_json::to_string(err).unwrap_or_else(|_| format!("{:?}", err))
}
}
}
}
impl Serialize for ErrorBody {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Text(text) => serializer.serialize_str(text),
Self::Json(json) => json.serialize(serializer),
Self::Unauthorized(err) => err.serialize(serializer),
Self::Detail(err) => err.serialize(serializer),
Self::Message(err) => err.serialize(serializer),
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum LettaError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Authentication failed: {message}")]
Auth {
message: String,
},
#[error("API error {status}: {message}")]
Api {
status: u16,
message: String,
code: Option<String>,
body: ErrorBody,
url: Option<url::Url>,
method: Option<String>,
},
#[error("Serialization error")]
Serde(#[from] serde_json::Error),
#[error("Streaming error: {message}")]
Streaming {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("Configuration error: {message}")]
Config {
message: String,
},
#[error("Invalid URL")]
Url(#[from] url::ParseError),
#[error("I/O error")]
Io(#[from] std::io::Error),
#[error("URL encoding error")]
UrlEncoding(#[from] serde_urlencoded::ser::Error),
#[error("Request timed out after {seconds} seconds")]
RequestTimeout {
seconds: u64,
},
#[error("Rate limit exceeded. Retry after {retry_after:?} seconds")]
RateLimit {
retry_after: Option<u64>,
},
#[error("Resource not found: {resource_type} with ID {id}")]
NotFound {
resource_type: String,
id: String,
},
#[error("Validation error: {message}")]
Validation {
message: String,
field: Option<String>,
},
}
impl miette::Diagnostic for LettaError {
fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
match self {
Self::Http(_) => Some(Box::new("letta::http")),
Self::Auth { .. } => Some(Box::new("letta::auth")),
Self::Api {
code: Some(code), ..
} => Some(Box::new(format!("letta::api::{code}"))),
Self::Api { .. } => Some(Box::new("letta::api")),
Self::Serde(_) => Some(Box::new("letta::serde")),
Self::Streaming { .. } => Some(Box::new("letta::streaming")),
Self::Config { .. } => Some(Box::new("letta::config")),
Self::Url(_) => Some(Box::new("letta::url")),
Self::Io(_) => Some(Box::new("letta::io")),
Self::UrlEncoding(_) => Some(Box::new("letta::url_encoding")),
Self::RequestTimeout { .. } => Some(Box::new("letta::timeout")),
Self::RateLimit { .. } => Some(Box::new("letta::rate_limit")),
Self::NotFound { .. } => Some(Box::new("letta::not_found")),
Self::Validation { .. } => Some(Box::new("letta::validation")),
}
}
fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
match self {
Self::Auth { .. } => Some(Box::new(
"Check your API key or authentication configuration. \
For local servers, ensure the server is running and accessible.",
)),
Self::Api { status: 401, url, method, .. } => {
let mut help = String::from("Your API key is invalid or expired. Please check your authentication credentials.");
if let Some(u) = url {
help.push_str(&format!("\nFailed request: {} {}", method.as_deref().unwrap_or("?"), u));
}
Some(Box::new(help))
}
Self::Api { status: 403, .. } => Some(Box::new(
"You don't have permission to access this resource. \
Check your API key permissions.",
)),
Self::Api { status: 404, url, .. } => {
let mut help = String::from("The requested resource was not found. Verify the resource ID and that it exists.");
if let Some(u) = url {
help.push_str(&format!("\nRequested URL: {}", u));
}
Some(Box::new(help))
}
Self::Api { status: 429, .. } => Some(Box::new(
"You're being rate limited. Please wait before making more requests.",
)),
Self::Api { status: 500..=599, url, method, .. } => {
let mut help = String::from("The server encountered an error. Please try again later or contact support.");
if let (Some(u), Some(m)) = (url, method) {
help.push_str(&format!("\nFailed request: {} {}", m, u));
}
Some(Box::new(help))
}
Self::Config { .. } => Some(Box::new(
"Check your client configuration, including base URL and authentication settings.",
)),
Self::RequestTimeout { .. } => Some(Box::new(
"The request took too long. Try increasing the timeout or check your network connection.",
)),
Self::RateLimit { retry_after: Some(seconds) } => Some(Box::new(format!(
"Wait {seconds} seconds before making another request."
))),
Self::Validation { field: Some(field), .. } => Some(Box::new(format!(
"Check the '{field}' field value and ensure it meets the API requirements."
))),
_ => None,
}
}
fn severity(&self) -> Option<miette::Severity> {
match self {
Self::Config { .. } | Self::Validation { .. } => Some(miette::Severity::Warning),
Self::Auth { .. }
| Self::Api {
status: 401 | 403, ..
} => Some(miette::Severity::Error),
Self::Api {
status: 500..=599, ..
} => Some(miette::Severity::Error),
_ => None,
}
}
}
impl LettaError {
pub fn auth(message: impl Into<String>) -> Self {
Self::Auth {
message: message.into(),
}
}
pub fn api(status: u16, message: impl Into<String>) -> Self {
Self::Api {
status,
message: message.into(),
code: None,
body: ErrorBody::Text(String::new()),
url: None,
method: None,
}
}
pub fn api_with_code(status: u16, message: impl Into<String>, code: impl Into<String>) -> Self {
Self::Api {
status,
message: message.into(),
code: Some(code.into()),
body: ErrorBody::Text(String::new()),
url: None,
method: None,
}
}
pub fn from_response(status: u16, body_str: String) -> Self {
Self::from_response_with_context(status, body_str, None, None, None)
}
pub fn from_response_with_headers(
status: u16,
body_str: String,
headers: Option<&reqwest::header::HeaderMap>,
) -> Self {
Self::from_response_with_context(status, body_str, headers, None, None)
}
pub fn from_response_with_context(
status: u16,
body_str: String,
headers: Option<&reqwest::header::HeaderMap>,
url: Option<url::Url>,
method: Option<String>,
) -> Self {
let body = ErrorBody::from_response(&body_str);
let message = body
.message()
.unwrap_or_else(|| Self::default_message_for_status(status));
let code = body.code();
match status {
401 => {
if let ErrorBody::Unauthorized(_) = &body {
Self::Api {
status,
message,
code,
body,
url,
method,
}
} else {
Self::Auth { message }
}
}
404 => {
if let Some(resource_info) = Self::extract_resource_info(&message) {
Self::NotFound {
resource_type: resource_info.0,
id: resource_info.1,
}
} else {
Self::Api {
status,
message,
code,
body,
url,
method,
}
}
}
422 => {
if let Some(field) = Self::extract_validation_field(&message, &body) {
Self::Validation {
message,
field: Some(field),
}
} else {
Self::Validation {
message,
field: None,
}
}
}
429 => {
let retry_after = if let Some(headers) = headers {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.or_else(|| Self::extract_retry_after(&body))
} else {
Self::extract_retry_after(&body)
};
Self::RateLimit { retry_after }
}
408 | 504 => {
Self::RequestTimeout { seconds: 60 } }
_ => {
Self::Api {
status,
message,
code,
body,
url,
method,
}
}
}
}
pub fn api_with_body(status: u16, message: impl Into<String>, body: ErrorBody) -> Self {
Self::Api {
status,
message: message.into(),
code: body.code(),
body,
url: None,
method: None,
}
}
fn default_message_for_status(status: u16) -> String {
match status {
400 => "Bad Request".to_string(),
401 => "Unauthorized".to_string(),
403 => "Forbidden".to_string(),
404 => "Not Found".to_string(),
408 => "Request Timeout".to_string(),
422 => "Unprocessable Entity".to_string(),
429 => "Too Many Requests".to_string(),
500 => "Internal Server Error".to_string(),
502 => "Bad Gateway".to_string(),
503 => "Service Unavailable".to_string(),
504 => "Gateway Timeout".to_string(),
_ => format!("HTTP {status}"),
}
}
pub fn streaming(message: impl Into<String>) -> Self {
Self::Streaming {
message: message.into(),
source: None,
}
}
pub fn streaming_with_source(
message: impl Into<String>,
source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
) -> Self {
Self::Streaming {
message: message.into(),
source: Some(source.into()),
}
}
pub fn config(message: impl Into<String>) -> Self {
Self::Config {
message: message.into(),
}
}
pub fn not_found(resource_type: impl Into<String>, id: impl Into<String>) -> Self {
Self::NotFound {
resource_type: resource_type.into(),
id: id.into(),
}
}
pub fn validation(message: impl Into<String>) -> Self {
Self::Validation {
message: message.into(),
field: None,
}
}
pub fn validation_field(message: impl Into<String>, field: impl Into<String>) -> Self {
Self::Validation {
message: message.into(),
field: Some(field.into()),
}
}
pub fn request_timeout(seconds: u64) -> Self {
Self::RequestTimeout { seconds }
}
pub fn rate_limit(retry_after: Option<u64>) -> Self {
Self::RateLimit { retry_after }
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RequestTimeout { .. }
| Self::RateLimit { .. }
| Self::Api {
status: 500..=599,
..
}
)
}
pub fn status_code(&self) -> Option<u16> {
match self {
Self::Api { status, .. } => Some(*status),
_ => None,
}
}
pub fn response_body(&self) -> Option<&ErrorBody> {
match self {
Self::Api { body, .. } => Some(body),
_ => None,
}
}
pub fn error_code(&self) -> Option<&str> {
match self {
Self::Api { code, .. } => code.as_deref(),
_ => None,
}
}
pub fn is_unauthorized(&self) -> bool {
matches!(
self,
Self::Api {
body: ErrorBody::Unauthorized(_),
..
}
)
}
pub fn is_validation_error(&self) -> bool {
match self {
Self::Api { body, .. } => body.is_validation_error(),
Self::Validation { .. } => true,
_ => false,
}
}
pub fn unauthorized_details(&self) -> Option<(&str, &str, &str)> {
match self {
Self::Api {
body: ErrorBody::Unauthorized(err),
..
} => Some((&err.message, &err.details, &err.ownership)),
_ => None,
}
}
fn extract_resource_info(message: &str) -> Option<(String, String)> {
let lower = message.to_lowercase();
if let Some(not_found_pos) = lower.find(" not found") {
let prefix = &message[..not_found_pos];
if let Some(id_start) = prefix.find(" with ID ") {
let id_part = &prefix[id_start + 9..];
let id = id_part
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
let resource = prefix[..id_start].trim().to_string();
return Some((resource, id));
} else if let Some(quote_start) = prefix.rfind('\'') {
if let Some(prev_quote) = prefix[..quote_start].rfind('\'') {
let id = prefix[prev_quote + 1..quote_start].to_string();
let resource = prefix[..prev_quote].trim().to_string();
return Some((resource, id));
}
} else {
let resource = prefix.trim().to_string();
return Some((resource, String::new()));
}
}
if lower.contains(" found ") && lower.contains(" with id:") {
if let Some(colon_pos) = message.find(':') {
let id = message[colon_pos + 1..].trim().to_string();
let before_colon = &message[..colon_pos];
let words: Vec<&str> = before_colon.split_whitespace().collect();
for (i, word) in words.iter().enumerate() {
if word.to_lowercase() == "found" && i > 0 {
return Some((words[i - 1].to_string(), id));
}
}
}
}
None
}
fn extract_validation_field(message: &str, body: &ErrorBody) -> Option<String> {
if let ErrorBody::Json(json) = body {
if let Some(field) = json.get("field").and_then(|v| v.as_str()) {
return Some(field.to_string());
}
if let Some(errors) = json.get("validation_errors").and_then(|v| v.as_object()) {
if let Some(field) = errors.keys().next() {
return Some(field.clone());
}
}
}
if message.contains("Field '") {
if let Some(start) = message.find("Field '") {
let after_field = &message[start + 7..];
if let Some(end) = after_field.find('\'') {
return Some(after_field[..end].to_string());
}
}
}
None
}
fn extract_retry_after(body: &ErrorBody) -> Option<u64> {
if let ErrorBody::Json(json) = body {
json.get("retry_after")
.or_else(|| json.get("retryAfter"))
.or_else(|| json.get("retry-after"))
.and_then(|v| v.as_u64())
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use miette::Diagnostic;
use super::*;
#[test]
fn test_error_creation() {
let err = LettaError::auth("Invalid token");
assert!(matches!(err, LettaError::Auth { .. }));
assert_eq!(err.to_string(), "Authentication failed: Invalid token");
}
#[test]
fn test_api_error() {
let err = LettaError::api(404, "Not found");
assert!(matches!(err, LettaError::Api { .. }));
assert_eq!(err.status_code(), Some(404));
}
#[test]
fn test_retryable_errors() {
assert!(LettaError::request_timeout(30).is_retryable());
assert!(LettaError::rate_limit(Some(60)).is_retryable());
assert!(LettaError::api(500, "Server error").is_retryable());
assert!(!LettaError::auth("Invalid token").is_retryable());
}
#[test]
fn test_diagnostic_codes() {
let err = LettaError::auth("test");
assert!(err.code().is_some());
let err = LettaError::api_with_code(400, "Bad request", "invalid_params");
let code = err.code().unwrap();
assert_eq!(code.to_string(), "letta::api::invalid_params");
}
#[test]
fn test_error_body_parsing() {
let unauthorized_json = r#"{"message": "Unauthorized", "details": "You are attempting to access a resource...", "ownership": "This is api.letta.com..."}"#;
let body = ErrorBody::from_response(unauthorized_json);
assert!(matches!(body, ErrorBody::Unauthorized(_)));
assert_eq!(body.message(), Some("Unauthorized".to_string()));
let detail_json = r#"{"detail": "Not Found"}"#;
let body = ErrorBody::from_response(detail_json);
assert!(matches!(body, ErrorBody::Detail(_)));
assert_eq!(body.message(), Some("Not Found".to_string()));
let validation_json = r#"{"detail": "1 validation error for Tool..."}"#;
let body = ErrorBody::from_response(validation_json);
assert!(matches!(body, ErrorBody::Detail(_)));
assert!(body.is_validation_error());
let message_json = r#"{"message": "Simple error"}"#;
let body = ErrorBody::from_response(message_json);
assert!(matches!(body, ErrorBody::Message(_)));
assert_eq!(body.message(), Some("Simple error".to_string()));
let body = ErrorBody::from_response("Server error");
assert!(matches!(body, ErrorBody::Text(_)));
assert_eq!(body.message(), Some("Server error".to_string()));
}
#[test]
fn test_api_error_from_responses() {
let unauthorized_json = r#"{"message": "Unauthorized", "details": "You are attempting to access a resource...", "ownership": "This is api.letta.com..."}"#;
let err = LettaError::from_response(401, unauthorized_json.to_string());
assert_eq!(err.status_code(), Some(401));
assert!(err.is_unauthorized());
assert_eq!(err.to_string(), "API error 401: Unauthorized");
let (message, details, ownership) = err.unauthorized_details().unwrap();
assert_eq!(message, "Unauthorized");
assert!(details.contains("You are attempting to access"));
assert!(ownership.contains("api.letta.com"));
let validation_json = r#"{"detail": "1 validation error for Tool"}"#;
let err = LettaError::from_response(422, validation_json.to_string());
assert!(err.is_validation_error());
assert_eq!(
err.to_string(),
"Validation error: 1 validation error for Tool"
);
let err = LettaError::from_response(500, "Internal Server Error".to_string());
assert_eq!(err.to_string(), "API error 500: Internal Server Error");
}
#[test]
fn test_error_body_round_trip() {
let unauthorized = ErrorBody::Unauthorized(UnauthorizedError {
message: "Unauthorized".to_string(),
details: "Access denied".to_string(),
ownership: "api.letta.com".to_string(),
});
let json = serde_json::to_string(&unauthorized).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["message"], "Unauthorized");
assert_eq!(parsed["details"], "Access denied");
assert_eq!(parsed["ownership"], "api.letta.com");
}
#[test]
fn test_default_status_messages() {
let err = LettaError::from_response(404, "".to_string());
assert_eq!(err.to_string(), "API error 404: Not Found");
let err = LettaError::from_response(999, "".to_string());
assert_eq!(err.to_string(), "API error 999: HTTP 999");
}
#[test]
fn test_real_letta_api_responses() {
let unauthorized_response = r#"{"message":"Unauthorized","details":"You are attempting to access a resource that you don't have permission to access, this could be because you have not provided an appropriate API key or are connecting to the wrong server","ownership":"This is api.letta.com, which is used to access Letta Cloud resources. If you are self hosting, you should connect to your own server, usually http://localhost:8283"}"#;
let err = LettaError::from_response(401, unauthorized_response.to_string());
assert!(err.is_unauthorized());
assert_eq!(err.to_string(), "API error 401: Unauthorized");
let (message, details, ownership) = err.unauthorized_details().unwrap();
assert_eq!(message, "Unauthorized");
assert!(details.contains("you don't have permission"));
assert!(ownership.contains("api.letta.com"));
let html_404 = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Not Found</pre>
</body>
</html>"#;
let err = LettaError::from_response(404, html_404.to_string());
assert_eq!(err.to_string(), "API error 404: Not Found");
assert!(matches!(err.response_body(), Some(ErrorBody::Text(_))));
let json_404 = r#"{"detail":"Not Found"}"#;
let err = LettaError::from_response(404, json_404.to_string());
assert_eq!(err.to_string(), "API error 404: Not Found");
assert!(matches!(err.response_body(), Some(ErrorBody::Detail(_))));
let html_400 = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Bad Request</pre>
</body>
</html>"#;
let err = LettaError::from_response(400, html_400.to_string());
assert_eq!(err.to_string(), "API error 400: Bad Request");
let validation_error = r#"{"detail":"1 validation error for Tool\n Value error, 'close_file' is not a user-defined function in module 'letta.functions.function_sets.files' [type=value_error, input_value=<letta.orm.tool.Tool object at 0x7ac389d9c860>, input_type=Tool]\n For further information visit https://errors.pydantic.dev/2.11/v/value_error"}"#;
let err = LettaError::from_response(500, validation_error.to_string());
assert!(matches!(err, LettaError::Api { .. }));
assert!(err.is_validation_error());
assert!(err.to_string().contains("validation error"));
}
#[test]
fn test_status_specific_error_mapping() {
let err = LettaError::from_response(401, "Invalid API key".to_string());
assert!(matches!(err, LettaError::Auth { .. }));
let err = LettaError::from_response(404, "Agent with ID agent-123 not found".to_string());
match err {
LettaError::NotFound { resource_type, id } => {
assert_eq!(resource_type, "Agent");
assert_eq!(id, "agent-123");
}
_ => panic!("Expected NotFound error"),
}
let err = LettaError::from_response(404, "Tool 'calculator' not found".to_string());
match err {
LettaError::NotFound { resource_type, id } => {
assert_eq!(resource_type, "Tool");
assert_eq!(id, "calculator");
}
_ => panic!("Expected NotFound error"),
}
let err = LettaError::from_response(422, "Field 'name' is required".to_string());
match err {
LettaError::Validation { message, field } => {
assert!(message.contains("Field 'name' is required"));
assert_eq!(field, Some("name".to_string()));
}
_ => panic!("Expected Validation error"),
}
let err = LettaError::from_response(429, r#"{"retry_after": 60}"#.to_string());
match err {
LettaError::RateLimit { retry_after } => {
assert_eq!(retry_after, Some(60));
}
_ => panic!("Expected RateLimit error"),
}
let err = LettaError::from_response(408, "Request timeout".to_string());
assert!(matches!(err, LettaError::RequestTimeout { .. }));
let err = LettaError::from_response(504, "Gateway timeout".to_string());
assert!(matches!(err, LettaError::RequestTimeout { .. }));
}
#[test]
fn test_extract_resource_info() {
let cases = vec![
("Agent not found", Some(("Agent", ""))),
(
"Agent with ID agent-123 not found",
Some(("Agent", "agent-123")),
),
("Tool 'my_tool' not found", Some(("Tool", "my_tool"))),
(
"No source found with ID: source-456",
Some(("source", "source-456")),
),
("Something else entirely", None),
];
for (message, expected) in cases {
let result = LettaError::extract_resource_info(message);
match (result.as_ref(), expected) {
(Some((res_type, id)), Some((exp_type, exp_id))) => {
assert_eq!(
res_type, exp_type,
"Resource type mismatch for: {}",
message
);
assert_eq!(id, exp_id, "ID mismatch for: {}", message);
}
(None, None) => {}
_ => {
eprintln!("Result: {:?}, Expected: {:?}", result, expected);
panic!("Mismatch for message: {}", message);
}
}
}
}
#[test]
fn test_extract_validation_field() {
let body = ErrorBody::Text("".to_string());
assert_eq!(
LettaError::extract_validation_field("Field 'name' is required", &body),
Some("name".to_string())
);
let json_body = ErrorBody::Json(serde_json::json!({
"field": "email",
"message": "Invalid email format"
}));
assert_eq!(
LettaError::extract_validation_field("Invalid email format", &json_body),
Some("email".to_string())
);
let json_body = ErrorBody::Json(serde_json::json!({
"validation_errors": {
"password": ["Too short"],
"username": ["Already taken"]
}
}));
let field = LettaError::extract_validation_field("Validation failed", &json_body);
assert!(field.is_some());
assert!(field == Some("password".to_string()) || field == Some("username".to_string()));
}
#[test]
fn test_error_context_trait() {
use super::ErrorContext;
let result: LettaResult<()> = Err(LettaError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file not found",
)));
let err = result.context_file("/path/to/file.txt").unwrap_err();
match &err {
LettaError::Io(io_err) => {
assert!(io_err.to_string().contains("/path/to/file.txt"));
}
_ => panic!("Expected IO error"),
}
let result: LettaResult<()> = Err(LettaError::Api {
status: 404,
message: "Not found".to_string(),
code: None,
body: ErrorBody::Text("Not found".to_string()),
url: None,
method: None,
});
let err = result.context_resource("agent", "agent-123").unwrap_err();
assert!(matches!(err, LettaError::NotFound { .. }));
let result: LettaResult<()> = Err(LettaError::Api {
status: 500,
message: "Internal error".to_string(),
code: None,
body: ErrorBody::Text("Internal error".to_string()),
url: None,
method: None,
});
let err = result.context_operation("uploading file").unwrap_err();
assert!(err.to_string().contains("while uploading file"));
let result: LettaResult<()> = Err(LettaError::Config {
message: "Invalid config".to_string(),
});
let err = result.context_msg("during initialization").unwrap_err();
assert!(err.to_string().contains("during initialization"));
}
}
pub trait ErrorContext<T> {
fn context_file<P: AsRef<Path>>(self, path: P) -> LettaResult<T>;
fn context_resource(self, resource_type: &str, id: &str) -> LettaResult<T>;
fn context_operation(self, operation: &str) -> LettaResult<T>;
fn context_msg(self, msg: &str) -> LettaResult<T>;
}
impl<T> ErrorContext<T> for LettaResult<T> {
fn context_file<P: AsRef<Path>>(self, path: P) -> LettaResult<T> {
self.map_err(|e| {
let path_str = path.as_ref().display().to_string();
match e {
LettaError::Io(io_err) => LettaError::Io(std::io::Error::new(
io_err.kind(),
format!("{}: {}", path_str, io_err),
)),
other => LettaError::Config {
message: format!("Error with file '{}': {}", path_str, other),
},
}
})
}
fn context_resource(self, resource_type: &str, id: &str) -> LettaResult<T> {
self.map_err(|e| match e {
LettaError::Api { status, .. } if status == 404 => LettaError::NotFound {
resource_type: resource_type.to_string(),
id: id.to_string(),
},
other => LettaError::Config {
message: format!("Error with {} '{}': {}", resource_type, id, other),
},
})
}
fn context_operation(self, operation: &str) -> LettaResult<T> {
self.map_err(|e| match e {
LettaError::Api {
status,
message,
code,
body,
url,
method,
} => LettaError::Api {
status,
message: format!("{} (while {})", message, operation),
code,
body,
url,
method,
},
other => LettaError::Config {
message: format!("Error while {}: {}", operation, other),
},
})
}
fn context_msg(self, msg: &str) -> LettaResult<T> {
self.map_err(|e| LettaError::Config {
message: format!("{}: {}", msg, e),
})
}
}