refactor: enterprise-grade error handling, HTTP client, models with builder patterns
This commit is contained in:
parent
b7b313c2e9
commit
7928c0ef14
6 changed files with 662 additions and 144 deletions
|
|
@ -252,9 +252,7 @@ impl From<ProductFile> for BrandingConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Access Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Initialize branding at application startup
|
||||
pub fn init_branding() {
|
||||
|
|
|
|||
158
src/error.rs
158
src/error.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
331
src/models.rs
331
src/models.rs
|
|
@ -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
201
src/resilience.rs
Normal 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 {
|
||||
//
|
||||
|
|
@ -224,9 +224,7 @@ impl VersionRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Access Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Initialize version registry at startup
|
||||
pub fn init_version_registry() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue