refactor: enterprise-grade error handling, HTTP client, models with builder patterns

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-20 19:57:51 -03:00
parent b7b313c2e9
commit 7928c0ef14
6 changed files with 662 additions and 144 deletions

View file

@ -252,9 +252,7 @@ impl From<ProductFile> for BrandingConfig {
}
}
// ============================================================================
// Global Access Functions
// ============================================================================
/// Initialize branding at application startup
pub fn init_branding() {

View file

@ -1,91 +1,144 @@
//! Common error types for BotLib
//!
//! Provides unified error handling across botserver and botui.
use thiserror::Error;
/// Result type alias using BotError
pub type BotResult<T> = Result<T, BotError>;
/// Common error types across the bot ecosystem
#[derive(Error, Debug)]
pub enum BotError {
/// Configuration errors
#[error("Configuration error: {0}")]
Config(String),
/// Database errors
#[error("Database error: {0}")]
Database(String),
/// HTTP/Network errors
#[error("HTTP error: {0}")]
Http(String),
#[error("HTTP error: {status} - {message}")]
Http { status: u16, message: String },
/// Authentication/Authorization errors
#[error("Auth error: {0}")]
Auth(String),
/// Validation errors
#[error("Validation error: {0}")]
Validation(String),
/// Not found errors
#[error("{0} not found")]
NotFound(String),
#[error("{entity} not found")]
NotFound { entity: String },
#[error("Conflict: {0}")]
Conflict(String),
#[error("Rate limited: retry after {retry_after_secs}s")]
RateLimited { retry_after_secs: u64 },
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
#[error("Timeout after {duration_ms}ms")]
Timeout { duration_ms: u64 },
/// Internal errors
#[error("Internal error: {0}")]
Internal(String),
/// IO errors
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
/// JSON serialization errors
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Generic error wrapper
#[error("{0}")]
Other(String),
}
impl BotError {
/// Create a config error
pub fn config(msg: impl Into<String>) -> Self {
Self::Config(msg.into())
}
/// Create a database error
pub fn database(msg: impl Into<String>) -> Self {
Self::Database(msg.into())
}
/// Create an HTTP error
pub fn http(msg: impl Into<String>) -> Self {
Self::Http(msg.into())
pub fn http(status: u16, msg: impl Into<String>) -> Self {
Self::Http {
status,
message: msg.into(),
}
}
pub fn http_msg(msg: impl Into<String>) -> Self {
Self::Http {
status: 500,
message: msg.into(),
}
}
/// Create an auth error
pub fn auth(msg: impl Into<String>) -> Self {
Self::Auth(msg.into())
}
/// Create a validation error
pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into())
}
/// Create a not found error
pub fn not_found(entity: impl Into<String>) -> Self {
Self::NotFound(entity.into())
Self::NotFound {
entity: entity.into(),
}
}
pub fn conflict(msg: impl Into<String>) -> Self {
Self::Conflict(msg.into())
}
pub fn rate_limited(retry_after_secs: u64) -> Self {
Self::RateLimited { retry_after_secs }
}
pub fn service_unavailable(msg: impl Into<String>) -> Self {
Self::ServiceUnavailable(msg.into())
}
pub fn timeout(duration_ms: u64) -> Self {
Self::Timeout { duration_ms }
}
/// Create an internal error
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
pub fn status_code(&self) -> u16 {
match self {
Self::Config(_) => 500,
Self::Database(_) => 500,
Self::Http { status, .. } => *status,
Self::Auth(_) => 401,
Self::Validation(_) => 400,
Self::NotFound { .. } => 404,
Self::Conflict(_) => 409,
Self::RateLimited { .. } => 429,
Self::ServiceUnavailable(_) => 503,
Self::Timeout { .. } => 504,
Self::Internal(_) => 500,
Self::Io(_) => 500,
Self::Json(_) => 400,
Self::Other(_) => 500,
}
}
pub fn is_retryable(&self) -> bool {
match self {
Self::RateLimited { .. } | Self::ServiceUnavailable(_) | Self::Timeout { .. } => true,
Self::Http { status, .. } => *status >= 500,
_ => false,
}
}
pub fn is_client_error(&self) -> bool {
let code = self.status_code();
(400..500).contains(&code)
}
pub fn is_server_error(&self) -> bool {
self.status_code() >= 500
}
}
impl From<anyhow::Error> for BotError {
@ -109,7 +162,11 @@ impl From<&str> for BotError {
#[cfg(feature = "http-client")]
impl From<reqwest::Error> for BotError {
fn from(err: reqwest::Error) -> Self {
Self::Http(err.to_string())
let status = err.status().map(|s| s.as_u16()).unwrap_or(500);
Self::Http {
status,
message: err.to_string(),
}
}
}
@ -127,5 +184,44 @@ mod tests {
fn test_not_found_error() {
let err = BotError::not_found("User");
assert_eq!(err.to_string(), "User not found");
assert_eq!(err.status_code(), 404);
}
#[test]
fn test_http_error_with_status() {
let err = BotError::http(503, "Service down");
assert_eq!(err.status_code(), 503);
assert!(err.is_server_error());
assert!(!err.is_client_error());
}
#[test]
fn test_validation_error() {
let err = BotError::validation("Invalid email format");
assert_eq!(err.status_code(), 400);
assert!(err.is_client_error());
}
#[test]
fn test_retryable_errors() {
assert!(BotError::rate_limited(60).is_retryable());
assert!(BotError::service_unavailable("down").is_retryable());
assert!(BotError::timeout(5000).is_retryable());
assert!(!BotError::validation("bad input").is_retryable());
assert!(!BotError::not_found("User").is_retryable());
}
#[test]
fn test_rate_limited_display() {
let err = BotError::rate_limited(30);
assert_eq!(err.to_string(), "Rate limited: retry after 30s");
assert_eq!(err.status_code(), 429);
}
#[test]
fn test_timeout_display() {
let err = BotError::timeout(5000);
assert_eq!(err.to_string(), "Timeout after 5000ms");
assert_eq!(err.status_code(), 504);
}
}

View file

@ -1,14 +1,12 @@
//! HTTP client for communicating with botserver
//!
//! Provides a reusable HTTP client for API calls.
use crate::error::BotError;
use log::{debug, error};
use serde::{de::DeserializeOwned, Serialize};
use std::sync::Arc;
use std::time::Duration;
/// HTTP client for communicating with botserver
const DEFAULT_BOTSERVER_URL: &str = "https://localhost:8088";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Clone)]
pub struct BotServerClient {
client: Arc<reqwest::Client>,
@ -16,34 +14,19 @@ pub struct BotServerClient {
}
impl BotServerClient {
/// Create new botserver HTTP client
pub fn new(base_url: Option<String>) -> Self {
let url = base_url.unwrap_or_else(|| {
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8088".to_string())
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(format!("BotLib/{}", env!("CARGO_PKG_VERSION")))
.danger_accept_invalid_certs(true) // Accept self-signed certs for local dev
.build()
.expect("Failed to create HTTP client");
Self {
client: Arc::new(client),
base_url: url,
}
Self::with_timeout(base_url, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
}
/// Create with custom timeout
pub fn with_timeout(base_url: Option<String>, timeout: Duration) -> Self {
let url = base_url.unwrap_or_else(|| {
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "http://localhost:8080".to_string())
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| DEFAULT_BOTSERVER_URL.to_string())
});
let client = reqwest::Client::builder()
.timeout(timeout)
.user_agent(format!("BotLib/{}", env!("CARGO_PKG_VERSION")))
.danger_accept_invalid_certs(true)
.build()
.expect("Failed to create HTTP client");
@ -53,12 +36,10 @@ impl BotServerClient {
}
}
/// Get the base URL
pub fn base_url(&self) -> &str {
&self.base_url
}
/// GET request
pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("GET {}", url);
@ -67,7 +48,6 @@ impl BotServerClient {
self.handle_response(response).await
}
/// POST request with body
pub async fn post<T: Serialize, R: DeserializeOwned>(
&self,
endpoint: &str,
@ -80,7 +60,6 @@ impl BotServerClient {
self.handle_response(response).await
}
/// PUT request with body
pub async fn put<T: Serialize, R: DeserializeOwned>(
&self,
endpoint: &str,
@ -93,7 +72,6 @@ impl BotServerClient {
self.handle_response(response).await
}
/// PATCH request with body
pub async fn patch<T: Serialize, R: DeserializeOwned>(
&self,
endpoint: &str,
@ -106,7 +84,6 @@ impl BotServerClient {
self.handle_response(response).await
}
/// DELETE request
pub async fn delete<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint);
debug!("DELETE {}", url);
@ -115,40 +92,6 @@ impl BotServerClient {
self.handle_response(response).await
}
/// Handle response and deserialize
async fn handle_response<T: DeserializeOwned>(
&self,
response: reqwest::Response,
) -> Result<T, BotError> {
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
error!("HTTP {} error: {}", status, error_text);
return Err(BotError::http(format!("HTTP {}: {}", status, error_text)));
}
response.json().await.map_err(|e| {
error!("Failed to parse response: {}", e);
BotError::http(format!("Failed to parse response: {}", e))
})
}
/// Check if botserver is healthy
pub async fn health_check(&self) -> bool {
match self.get::<serde_json::Value>("/health").await {
Ok(_) => true,
Err(e) => {
error!("Health check failed: {}", e);
false
}
}
}
/// GET with bearer token authorization
pub async fn get_authorized<T: DeserializeOwned>(
&self,
endpoint: &str,
@ -161,7 +104,6 @@ impl BotServerClient {
self.handle_response(response).await
}
/// POST with bearer token authorization
pub async fn post_authorized<T: Serialize, R: DeserializeOwned>(
&self,
endpoint: &str,
@ -181,7 +123,6 @@ impl BotServerClient {
self.handle_response(response).await
}
/// DELETE with bearer token authorization
pub async fn delete_authorized<T: DeserializeOwned>(
&self,
endpoint: &str,
@ -193,6 +134,38 @@ impl BotServerClient {
let response = self.client.delete(&url).bearer_auth(token).send().await?;
self.handle_response(response).await
}
pub async fn health_check(&self) -> bool {
match self.get::<serde_json::Value>("/health").await {
Ok(_) => true,
Err(e) => {
error!("Health check failed: {}", e);
false
}
}
}
async fn handle_response<T: DeserializeOwned>(
&self,
response: reqwest::Response,
) -> Result<T, BotError> {
let status = response.status();
let status_code = status.as_u16();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
error!("HTTP {} error: {}", status_code, error_text);
return Err(BotError::http(status_code, error_text));
}
response.json().await.map_err(|e| {
error!("Failed to parse response: {}", e);
BotError::http(status_code, format!("Failed to parse response: {}", e))
})
}
}
impl std::fmt::Debug for BotServerClient {
@ -217,7 +190,7 @@ mod tests {
fn test_client_default_url() {
std::env::remove_var("BOTSERVER_URL");
let client = BotServerClient::new(None);
assert_eq!(client.base_url(), "http://localhost:8080");
assert_eq!(client.base_url(), DEFAULT_BOTSERVER_URL);
}
#[test]
@ -229,6 +202,13 @@ mod tests {
assert_eq!(client.base_url(), "http://test:9000");
}
#[test]
fn test_client_with_timeout_default_url() {
std::env::remove_var("BOTSERVER_URL");
let client = BotServerClient::with_timeout(None, Duration::from_secs(60));
assert_eq!(client.base_url(), DEFAULT_BOTSERVER_URL);
}
#[test]
fn test_client_debug() {
let client = BotServerClient::new(Some("http://debug-test".to_string()));

View file

@ -1,13 +1,8 @@
//! Common models shared across bot ecosystem
//!
//! Contains DTOs, API response types, and common structures.
use crate::message_types::MessageType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Standard API response wrapper
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub success: bool,
@ -17,38 +12,68 @@ pub struct ApiResponse<T> {
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
impl<T> ApiResponse<T> {
/// Create a success response with data
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
message: None,
code: None,
}
}
/// Create a success response with message
pub fn success_message(data: T, message: impl Into<String>) -> Self {
pub fn success_with_message(data: T, message: impl Into<String>) -> Self {
Self {
success: true,
data: Some(data),
error: None,
message: Some(message.into()),
code: None,
}
}
/// Create an error response
pub fn error(message: impl Into<String>) -> Self {
Self {
success: false,
data: None,
error: Some(message.into()),
message: None,
code: None,
}
}
pub fn error_with_code(message: impl Into<String>, code: impl Into<String>) -> Self {
Self {
success: false,
data: None,
error: Some(message.into()),
message: None,
code: Some(code.into()),
}
}
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> ApiResponse<U> {
ApiResponse {
success: self.success,
data: self.data.map(f),
error: self.error,
message: self.message,
code: self.code,
}
}
pub fn is_success(&self) -> bool {
self.success
}
pub fn is_error(&self) -> bool {
!self.success
}
}
impl<T: Default> Default for ApiResponse<T> {
@ -57,7 +82,6 @@ impl<T: Default> Default for ApiResponse<T> {
}
}
/// User session information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: Uuid,
@ -71,17 +95,37 @@ pub struct Session {
}
impl Session {
/// Check if session is expired
pub fn is_expired(&self) -> bool {
if let Some(expires) = self.expires_at {
Utc::now() > expires
} else {
false
pub fn new(user_id: Uuid, bot_id: Uuid, title: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
user_id,
bot_id,
title: title.into(),
created_at: now,
updated_at: now,
expires_at: None,
}
}
pub fn with_expiry(mut self, expires_at: DateTime<Utc>) -> Self {
self.expires_at = Some(expires_at);
self
}
pub fn is_expired(&self) -> bool {
self.expires_at.map(|exp| Utc::now() > exp).unwrap_or(false)
}
pub fn is_active(&self) -> bool {
!self.is_expired()
}
pub fn remaining_time(&self) -> Option<chrono::Duration> {
self.expires_at.map(|exp| exp - Utc::now())
}
}
/// User message sent to bot
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserMessage {
pub bot_id: String,
@ -98,7 +142,6 @@ pub struct UserMessage {
}
impl UserMessage {
/// Create a new text message
pub fn text(
bot_id: impl Into<String>,
user_id: impl Into<String>,
@ -118,14 +161,31 @@ impl UserMessage {
context_name: None,
}
}
pub fn with_media(mut self, url: impl Into<String>) -> Self {
self.media_url = Some(url.into());
self
}
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context_name = Some(context.into());
self
}
pub fn has_media(&self) -> bool {
self.media_url.is_some()
}
}
/// Suggestion for user
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suggestion {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
impl Suggestion {
@ -133,18 +193,33 @@ impl Suggestion {
Self {
text: text.into(),
context: None,
action: None,
icon: None,
}
}
pub fn with_context(text: impl Into<String>, context: impl Into<String>) -> Self {
Self {
text: text.into(),
context: Some(context.into()),
}
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
}
impl<S: Into<String>> From<S> for Suggestion {
fn from(text: S) -> Self {
Self::new(text)
}
}
/// Bot response to user
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotResponse {
pub bot_id: String,
@ -156,7 +231,7 @@ pub struct BotResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_token: Option<String>,
pub is_complete: bool,
#[serde(default)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggestions: Vec<Suggestion>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_name: Option<String>,
@ -167,7 +242,6 @@ pub struct BotResponse {
}
impl BotResponse {
/// Create a new bot response
pub fn new(
bot_id: impl Into<String>,
session_id: impl Into<String>,
@ -191,7 +265,6 @@ impl BotResponse {
}
}
/// Create a streaming response
pub fn streaming(
bot_id: impl Into<String>,
session_id: impl Into<String>,
@ -215,11 +288,48 @@ impl BotResponse {
}
}
/// Add suggestions to response
pub fn with_suggestions(mut self, suggestions: Vec<Suggestion>) -> Self {
self.suggestions = suggestions;
pub fn with_suggestions<I, S>(mut self, suggestions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Suggestion>,
{
self.suggestions = suggestions.into_iter().map(Into::into).collect();
self
}
pub fn add_suggestion(mut self, suggestion: impl Into<Suggestion>) -> Self {
self.suggestions.push(suggestion.into());
self
}
pub fn with_context(
mut self,
name: impl Into<String>,
length: usize,
max_length: usize,
) -> Self {
self.context_name = Some(name.into());
self.context_length = length;
self.context_max_length = max_length;
self
}
pub fn append_content(&mut self, chunk: &str) {
self.content.push_str(chunk);
}
pub fn complete(mut self) -> Self {
self.is_complete = true;
self
}
pub fn is_streaming(&self) -> bool {
self.stream_token.is_some() && !self.is_complete
}
pub fn has_suggestions(&self) -> bool {
!self.suggestions.is_empty()
}
}
impl Default for BotResponse {
@ -241,22 +351,92 @@ impl Default for BotResponse {
}
}
/// Attachment for media files in messages
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
/// Type of attachment (image, audio, video, file, etc.)
pub attachment_type: String,
/// URL or path to the attachment
pub attachment_type: AttachmentType,
pub url: String,
/// MIME type of the attachment
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// File name if available
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
/// File size in bytes
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AttachmentType {
Image,
Audio,
Video,
Document,
File,
}
impl Attachment {
pub fn new(attachment_type: AttachmentType, url: impl Into<String>) -> Self {
Self {
attachment_type,
url: url.into(),
mime_type: None,
filename: None,
size: None,
thumbnail_url: None,
}
}
pub fn image(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Image, url)
}
pub fn audio(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Audio, url)
}
pub fn video(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Video, url)
}
pub fn document(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Document, url)
}
pub fn file(url: impl Into<String>) -> Self {
Self::new(AttachmentType::File, url)
}
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
pub fn with_thumbnail(mut self, thumbnail_url: impl Into<String>) -> Self {
self.thumbnail_url = Some(thumbnail_url.into());
self
}
pub fn is_image(&self) -> bool {
self.attachment_type == AttachmentType::Image
}
pub fn is_media(&self) -> bool {
matches!(
self.attachment_type,
AttachmentType::Image | AttachmentType::Audio | AttachmentType::Video
)
}
}
#[cfg(test)]
@ -266,7 +446,8 @@ mod tests {
#[test]
fn test_api_response_success() {
let response: ApiResponse<String> = ApiResponse::success("test".to_string());
assert!(response.success);
assert!(response.is_success());
assert!(!response.is_error());
assert_eq!(response.data, Some("test".to_string()));
assert!(response.error.is_none());
}
@ -274,22 +455,86 @@ mod tests {
#[test]
fn test_api_response_error() {
let response: ApiResponse<String> = ApiResponse::error("something went wrong");
assert!(!response.success);
assert!(!response.is_success());
assert!(response.is_error());
assert!(response.data.is_none());
assert_eq!(response.error, Some("something went wrong".to_string()));
}
#[test]
fn test_api_response_map() {
let response: ApiResponse<i32> = ApiResponse::success(42);
let mapped = response.map(|n| n.to_string());
assert_eq!(mapped.data, Some("42".to_string()));
}
#[test]
fn test_session_creation() {
let user_id = Uuid::new_v4();
let bot_id = Uuid::new_v4();
let session = Session::new(user_id, bot_id, "Test Session");
assert_eq!(session.user_id, user_id);
assert_eq!(session.bot_id, bot_id);
assert_eq!(session.title, "Test Session");
assert!(session.is_active());
assert!(!session.is_expired());
}
#[test]
fn test_user_message_creation() {
let msg = UserMessage::text("bot1", "user1", "sess1", "web", "Hello!");
let msg =
UserMessage::text("bot1", "user1", "sess1", "web", "Hello!").with_context("greeting");
assert_eq!(msg.content, "Hello!");
assert_eq!(msg.message_type, MessageType::USER);
assert_eq!(msg.context_name, Some("greeting".to_string()));
}
#[test]
fn test_bot_response_creation() {
let response = BotResponse::new("bot1", "sess1", "user1", "Hi there!", "web");
let response = BotResponse::new("bot1", "sess1", "user1", "Hi there!", "web")
.add_suggestion("Option 1")
.add_suggestion("Option 2");
assert!(response.is_complete);
assert_eq!(response.message_type, MessageType::BOT_RESPONSE);
assert!(!response.is_streaming());
assert!(response.has_suggestions());
assert_eq!(response.suggestions.len(), 2);
}
#[test]
fn test_bot_response_streaming() {
let mut response = BotResponse::streaming("bot1", "sess1", "user1", "web", "token123");
assert!(response.is_streaming());
assert!(!response.is_complete);
response.append_content("Hello ");
response.append_content("World!");
assert_eq!(response.content, "Hello World!");
let response = response.complete();
assert!(!response.is_streaming());
assert!(response.is_complete);
}
#[test]
fn test_attachment_creation() {
let attachment = Attachment::image("https://example.com/photo.jpg")
.with_filename("photo.jpg")
.with_size(1024)
.with_mime_type("image/jpeg");
assert!(attachment.is_image());
assert!(attachment.is_media());
assert_eq!(attachment.filename, Some("photo.jpg".to_string()));
assert_eq!(attachment.size, Some(1024));
}
#[test]
fn test_suggestion_from_string() {
let suggestion: Suggestion = "Click here".into();
assert_eq!(suggestion.text, "Click here");
assert!(suggestion.context.is_none());
}
}

201
src/resilience.rs Normal file
View file

@ -0,0 +1,201 @@
//! Resilience Module - Production-grade fault tolerance primitives
//!
//! This module provides battle-tested resilience patterns:
//! - Retry with exponential backoff and jitter
//! - Circuit breaker with half-open state
//! - Timeout wrappers
//! - Bulkhead isolation
//! - Fallback chains
//!
//! # Design Principles
//! - Zero-cost abstractions where possible
//! - No panics - all errors are recoverable
//! - Composable patterns
//! - Observable state for metrics
use std::future::Future;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{RwLock, Semaphore, SemaphorePermit};
use tokio::time::{sleep, timeout};
/// Errors that can occur during resilient operations
#[derive(Debug, Clone)]
pub enum ResilienceError {
/// Operation timed out
Timeout { duration: Duration },
/// Circuit breaker is open, rejecting requests
CircuitOpen { until: Option<Duration> },
/// All retry attempts exhausted
RetriesExhausted {
attempts: u32,
last_error: String,
},
/// Bulkhead rejected request (too many concurrent)
BulkheadFull { max_concurrent: usize },
/// Wrapped error from the underlying operation
Operation(String),
}
impl std::fmt::Display for ResilienceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Timeout { duration } => {
write!(f, "Operation timed out after {:?}", duration)
}
Self::CircuitOpen { until } => {
if let Some(d) = until {
write!(f, "Circuit breaker open, retry after {:?}", d)
} else {
write!(f, "Circuit breaker open")
}
}
Self::RetriesExhausted {
attempts,
last_error,
} => {
write!(
f,
"All {} retry attempts exhausted. Last error: {}",
attempts, last_error
)
}
Self::BulkheadFull { max_concurrent } => {
write!(
f,
"Bulkhead full, max {} concurrent requests",
max_concurrent
)
}
Self::Operation(msg) => write!(f, "Operation failed: {}", msg),
}
}
}
impl std::error::Error for ResilienceError {}
// ============================================================================
// Retry Configuration and Execution
// ============================================================================
/// Retry strategy configuration
#[derive(Debug, Clone)]
pub struct RetryConfig {
/// Maximum number of attempts (including the first one)
pub max_attempts: u32,
/// Initial delay between retries
pub initial_delay: Duration,
/// Maximum delay between retries
pub max_delay: Duration,
/// Multiplier for exponential backoff (typically 2.0)
pub backoff_multiplier: f64,
/// Add random jitter to prevent thundering herd (0.0 to 1.0)
pub jitter_factor: f64,
/// Predicate to determine if error is retryable
retryable: Option<Arc<dyn Fn(&str) -> bool + Send + Sync>>,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 3,
initial_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(30),
backoff_multiplier: 2.0,
jitter_factor: 0.2,
retryable: None,
}
}
}
impl RetryConfig {
/// Create a new retry config with custom max attempts
pub fn with_max_attempts(mut self, attempts: u32) -> Self {
self.max_attempts = attempts.max(1);
self
}
/// Set initial delay
pub fn with_initial_delay(mut self, delay: Duration) -> Self {
self.initial_delay = delay;
self
}
/// Set maximum delay cap
pub fn with_max_delay(mut self, delay: Duration) -> Self {
self.max_delay = delay;
self
}
/// Set backoff multiplier
pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
self.backoff_multiplier = multiplier.max(1.0);
self
}
/// Set jitter factor (0.0 to 1.0)
pub fn with_jitter(mut self, jitter: f64) -> Self {
self.jitter_factor = jitter.clamp(0.0, 1.0);
self
}
/// Set custom retryable predicate
pub fn with_retryable<F>(mut self, predicate: F) -> Self
where
F: Fn(&str) -> bool + Send + Sync + 'static,
{
self.retryable = Some(Arc::new(predicate));
self
}
/// Aggressive retry for critical operations
pub fn aggressive() -> Self {
Self {
max_attempts: 5,
initial_delay: Duration::from_millis(50),
max_delay: Duration::from_secs(10),
backoff_multiplier: 1.5,
jitter_factor: 0.3,
retryable: None,
}
}
/// Conservative retry for non-critical operations
pub fn conservative() -> Self {
Self {
max_attempts: 2,
initial_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(5),
backoff_multiplier: 2.0,
jitter_factor: 0.1,
retryable: None,
}
}
/// Calculate delay for a given attempt number
fn calculate_delay(&self, attempt: u32) -> Duration {
let base_delay = self.initial_delay.as_secs_f64()
* self.backoff_multiplier.powi(attempt.saturating_sub(1) as i32);
let capped_delay = base_delay.min(self.max_delay.as_secs_f64());
// Add jitter
let jitter = if self.jitter_factor > 0.0 {
let jitter_range = capped_delay * self.jitter_factor;
// Simple deterministic "random" based on attempt number
let pseudo_random = ((attempt as f64 * 1.618033988749895) % 1.0) * 2.0 - 1.0;
jitter_range * pseudo_random
} else {
0.0
};
Duration::from_secs_f64((capped_delay + jitter).max(0.001))
}
/// Check if an error is retryable
fn is_retryable(&self, error: &str) -> bool {
if let Some(ref predicate) = self.retryable {
predicate(error)
} else {
//

View file

@ -224,9 +224,7 @@ impl VersionRegistry {
}
}
// ============================================================================
// Global Access Functions
// ============================================================================
/// Initialize version registry at startup
pub fn init_version_registry() {