use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fmt;
use thiserror::Error;
use tracing::{error, warn};
#[derive(Error, Debug, Clone)]
pub enum WebError {
#[error("Invalid request: {message}")]
InvalidRequest {
message: String,
},
#[error("Unauthorized: {reason}")]
Unauthorized {
reason: String,
},
#[error("Forbidden: {reason}")]
Forbidden {
reason: String,
},
#[error("Not found: {resource}")]
NotFound {
resource: String,
},
#[error("Content too large: {size} bytes exceeds maximum of {max} bytes")]
ContentTooLarge {
size: usize,
max: usize,
},
#[error("Rate limited: retry after {retry_after_secs} seconds")]
RateLimited {
retry_after_secs: u64,
},
#[error("Processing error: {0}")]
ProcessingError(String),
#[error("Internal error: {message}")]
InternalError {
message: String,
},
#[error("Service unavailable: {reason}")]
ServiceUnavailable {
reason: String,
},
#[error("Gateway timeout after {timeout_ms}ms")]
GatewayTimeout {
timeout_ms: u64,
},
}
impl WebError {
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::InvalidRequest {
message: message.into(),
}
}
pub fn missing_field(field: &str) -> Self {
Self::InvalidRequest {
message: format!("Missing required field: {}", field),
}
}
pub fn invalid_field(field: &str, reason: &str) -> Self {
Self::InvalidRequest {
message: format!("Invalid value for field '{}': {}", field, reason),
}
}
pub fn unauthorized(reason: impl Into<String>) -> Self {
Self::Unauthorized {
reason: reason.into(),
}
}
pub fn forbidden(reason: impl Into<String>) -> Self {
Self::Forbidden {
reason: reason.into(),
}
}
pub fn not_found(resource: impl Into<String>) -> Self {
Self::NotFound {
resource: resource.into(),
}
}
pub fn content_too_large(size: usize, max: usize) -> Self {
Self::ContentTooLarge { size, max }
}
pub fn rate_limited(retry_after_secs: u64) -> Self {
Self::RateLimited { retry_after_secs }
}
pub fn processing<E: std::fmt::Display>(source: E) -> Self {
Self::ProcessingError(source.to_string())
}
pub fn internal(message: impl Into<String>) -> Self {
Self::InternalError {
message: message.into(),
}
}
pub fn internal_from<E: std::error::Error>(err: E) -> Self {
error!(error = %err, "Internal error occurred");
Self::InternalError {
message: "An unexpected error occurred".to_string(),
}
}
pub fn service_unavailable(reason: impl Into<String>) -> Self {
Self::ServiceUnavailable {
reason: reason.into(),
}
}
pub fn gateway_timeout(timeout_ms: u64) -> Self {
Self::GatewayTimeout { timeout_ms }
}
pub fn status_code(&self) -> u16 {
match self {
Self::InvalidRequest { .. } => 400,
Self::Unauthorized { .. } => 401,
Self::Forbidden { .. } => 403,
Self::NotFound { .. } => 404,
Self::ContentTooLarge { .. } => 413,
Self::RateLimited { .. } => 429,
Self::ProcessingError(_) => 500,
Self::InternalError { .. } => 500,
Self::ServiceUnavailable { .. } => 503,
Self::GatewayTimeout { .. } => 504,
}
}
pub fn error_code(&self) -> &'static str {
match self {
Self::InvalidRequest { .. } => "INVALID_REQUEST",
Self::Unauthorized { .. } => "UNAUTHORIZED",
Self::Forbidden { .. } => "FORBIDDEN",
Self::NotFound { .. } => "NOT_FOUND",
Self::ContentTooLarge { .. } => "CONTENT_TOO_LARGE",
Self::RateLimited { .. } => "RATE_LIMITED",
Self::ProcessingError(_) => "PROCESSING_ERROR",
Self::InternalError { .. } => "INTERNAL_ERROR",
Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
Self::GatewayTimeout { .. } => "GATEWAY_TIMEOUT",
}
}
pub fn to_json(&self) -> serde_json::Value {
json!({
"error": self.to_string(),
"code": self.error_code()
})
}
pub fn to_json_with_request_id(&self, request_id: &str) -> serde_json::Value {
json!({
"error": self.to_string(),
"code": self.error_code(),
"request_id": request_id
})
}
pub fn log(&self, request_id: Option<&str>) {
let request_id = request_id.unwrap_or("unknown");
match self {
Self::InvalidRequest { message } => {
warn!(
request_id = %request_id,
error_code = %self.error_code(),
message = %message,
"Invalid request"
);
}
Self::Unauthorized { reason } => {
warn!(
request_id = %request_id,
error_code = %self.error_code(),
reason = %reason,
"Unauthorized access attempt"
);
}
Self::Forbidden { reason } => {
warn!(
request_id = %request_id,
error_code = %self.error_code(),
reason = %reason,
"Forbidden access"
);
}
Self::NotFound { resource } => {
warn!(
request_id = %request_id,
error_code = %self.error_code(),
resource = %resource,
"Resource not found"
);
}
Self::ContentTooLarge { size, max } => {
warn!(
request_id = %request_id,
error_code = %self.error_code(),
size = %size,
max = %max,
"Content too large"
);
}
Self::RateLimited { retry_after_secs } => {
warn!(
request_id = %request_id,
error_code = %self.error_code(),
retry_after_secs = %retry_after_secs,
"Rate limited"
);
}
Self::ProcessingError(err) => {
error!(
request_id = %request_id,
error_code = %self.error_code(),
error = %err,
"Processing error"
);
}
Self::InternalError { message } => {
error!(
request_id = %request_id,
error_code = %self.error_code(),
message = %message,
"Internal error"
);
}
Self::ServiceUnavailable { reason } => {
error!(
request_id = %request_id,
error_code = %self.error_code(),
reason = %reason,
"Service unavailable"
);
}
Self::GatewayTimeout { timeout_ms } => {
error!(
request_id = %request_id,
error_code = %self.error_code(),
timeout_ms = %timeout_ms,
"Gateway timeout"
);
}
}
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimited { .. }
| Self::ServiceUnavailable { .. }
| Self::GatewayTimeout { .. }
)
}
pub fn retry_after(&self) -> Option<u64> {
match self {
Self::RateLimited { retry_after_secs } => Some(*retry_after_secs),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: String,
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl ErrorResponse {
pub fn new(error: impl Into<String>, code: impl Into<String>) -> Self {
Self {
error: error.into(),
code: code.into(),
request_id: None,
details: None,
}
}
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
}
impl From<&WebError> for ErrorResponse {
fn from(err: &WebError) -> Self {
Self {
error: err.to_string(),
code: err.error_code().to_string(),
request_id: None,
details: None,
}
}
}
#[derive(Error, Debug)]
pub enum Error {
#[error("{0}")]
Web(#[from] WebError),
#[error("Browser error: {0}")]
Browser(#[from] BrowserError),
#[error("MCP error: {0}")]
Mcp(#[from] McpError),
#[error("Extraction error: {0}")]
Extraction(#[from] ExtractionError),
#[error("Navigation error: {0}")]
Navigation(#[from] NavigationError),
#[error("Capture error: {0}")]
Capture(#[from] CaptureError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("CDP error: {0}")]
Cdp(String),
#[error("{0}")]
Generic(String),
}
#[derive(Error, Debug)]
pub enum BrowserError {
#[error("Failed to launch browser: {0}")]
LaunchFailed(String),
#[error("Invalid browser configuration: {0}")]
ConfigError(String),
#[error("Browser connection lost")]
ConnectionLost,
#[error("Failed to create page: {0}")]
PageCreationFailed(String),
#[error("Browser already closed")]
AlreadyClosed,
#[error("Browser operation timed out after {0}ms")]
Timeout(u64),
}
#[derive(Error, Debug)]
pub enum McpError {
#[error("Invalid JSON-RPC request: {0}")]
InvalidRequest(String),
#[error("Unknown method: {0}")]
UnknownMethod(String),
#[error("Invalid parameters: {0}")]
InvalidParams(String),
#[error("Tool not found: {0}")]
ToolNotFound(String),
#[error("Tool execution failed: {0}")]
ToolExecutionFailed(String),
#[error("Protocol version mismatch: expected {expected}, got {actual}")]
VersionMismatch {
expected: String,
actual: String,
},
#[error("Parse error: {0}")]
ParseError(String),
}
#[derive(Error, Debug)]
pub enum ExtractionError {
#[error("Element not found: {0}")]
ElementNotFound(String),
#[error("Invalid selector: {0}")]
InvalidSelector(String),
#[error("Extraction failed: {0}")]
ExtractionFailed(String),
#[error("Content parsing failed: {0}")]
ParsingFailed(String),
#[error("JavaScript execution failed: {0}")]
JsExecutionFailed(String),
}
#[derive(Error, Debug)]
pub enum NavigationError {
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("Navigation timed out after {0}ms")]
Timeout(u64),
#[error("Page load failed: {0}")]
LoadFailed(String),
#[error("SSL/TLS error: {0}")]
SslError(String),
#[error("Network error: {0}")]
NetworkError(String),
#[error("HTTP error {status}: {message}")]
HttpError {
status: u16,
message: String,
},
}
#[derive(Error, Debug)]
pub enum CaptureError {
#[error("Screenshot capture failed: {0}")]
ScreenshotFailed(String),
#[error("PDF generation failed: {0}")]
PdfFailed(String),
#[error("MHTML capture failed: {0}")]
MhtmlFailed(String),
#[error("HTML capture failed: {0}")]
HtmlFailed(String),
#[error("Invalid capture format: {0}")]
InvalidFormat(String),
#[error("Capture timed out after {0}ms")]
Timeout(u64),
}
pub type Result<T> = std::result::Result<T, Error>;
pub type WebResult<T> = std::result::Result<T, WebError>;
impl Error {
pub fn generic<S: Into<String>>(msg: S) -> Self {
Error::Generic(msg.into())
}
pub fn cdp<S: Into<String>>(msg: S) -> Self {
Error::Cdp(msg.into())
}
pub fn into_web_error(self) -> WebError {
match self {
Error::Web(e) => e,
Error::Browser(e) => match e {
BrowserError::Timeout(ms) => WebError::GatewayTimeout { timeout_ms: ms },
BrowserError::ConnectionLost => {
WebError::service_unavailable("Browser connection lost")
}
BrowserError::AlreadyClosed => {
WebError::service_unavailable("Browser session closed")
}
_ => WebError::internal(e.to_string()),
},
Error::Mcp(e) => match e {
McpError::InvalidRequest(msg) => WebError::invalid_request(msg),
McpError::InvalidParams(msg) => WebError::invalid_request(msg),
McpError::ToolNotFound(tool) => {
WebError::not_found(format!("Tool not found: {}", tool))
}
_ => WebError::internal(e.to_string()),
},
Error::Navigation(e) => match e {
NavigationError::InvalidUrl(url) => {
WebError::invalid_request(format!("Invalid URL: {}", url))
}
NavigationError::Timeout(ms) => WebError::GatewayTimeout { timeout_ms: ms },
NavigationError::HttpError { status, message } => {
if status == 404 {
WebError::not_found(message)
} else if status == 401 {
WebError::unauthorized(message)
} else if status == 403 {
WebError::forbidden(message)
} else if status == 429 {
WebError::rate_limited(60) } else {
WebError::internal(format!("HTTP {}: {}", status, message))
}
}
_ => WebError::internal(e.to_string()),
},
Error::Extraction(e) => match e {
ExtractionError::ElementNotFound(selector) => {
WebError::not_found(format!("Element not found: {}", selector))
}
ExtractionError::InvalidSelector(sel) => {
WebError::invalid_request(format!("Invalid selector: {}", sel))
}
_ => WebError::internal(e.to_string()),
},
Error::Capture(e) => match e {
CaptureError::Timeout(ms) => WebError::GatewayTimeout { timeout_ms: ms },
CaptureError::InvalidFormat(fmt) => {
WebError::invalid_request(format!("Invalid format: {}", fmt))
}
_ => WebError::internal(e.to_string()),
},
Error::Io(e) => WebError::internal(format!("I/O error: {}", e)),
Error::Json(e) => WebError::invalid_request(format!("JSON error: {}", e)),
Error::Cdp(msg) => WebError::internal(format!("CDP error: {}", msg)),
Error::Generic(msg) => WebError::internal(msg),
}
}
}
impl From<chromiumoxide::error::CdpError> for Error {
fn from(err: chromiumoxide::error::CdpError) -> Self {
Error::Cdp(err.to_string())
}
}
impl From<std::io::Error> for WebError {
fn from(err: std::io::Error) -> Self {
WebError::internal(format!("I/O error: {}", err))
}
}
impl From<serde_json::Error> for WebError {
fn from(err: serde_json::Error) -> Self {
WebError::invalid_request(format!("JSON error: {}", err))
}
}
impl From<anyhow::Error> for WebError {
fn from(err: anyhow::Error) -> Self {
WebError::ProcessingError(err.to_string())
}
}
pub fn generate_request_id() -> String {
use rand::Rng;
let mut rng = rand::rng();
let id: u64 = rng.random();
format!("req_{:016x}", id)
}
#[derive(Debug, Clone)]
pub struct RequestContext {
pub request_id: String,
pub start_time: std::time::Instant,
}
impl RequestContext {
pub fn new() -> Self {
Self {
request_id: generate_request_id(),
start_time: std::time::Instant::now(),
}
}
pub fn with_id(request_id: impl Into<String>) -> Self {
Self {
request_id: request_id.into(),
start_time: std::time::Instant::now(),
}
}
pub fn elapsed_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64
}
pub fn log_error(&self, error: &WebError) {
error.log(Some(&self.request_id));
}
}
impl Default for RequestContext {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for RequestContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}]", self.request_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_web_error_status_codes() {
assert_eq!(WebError::invalid_request("test").status_code(), 400);
assert_eq!(WebError::unauthorized("test").status_code(), 401);
assert_eq!(WebError::forbidden("test").status_code(), 403);
assert_eq!(WebError::not_found("test").status_code(), 404);
assert_eq!(WebError::content_too_large(100, 50).status_code(), 413);
assert_eq!(WebError::rate_limited(60).status_code(), 429);
assert_eq!(WebError::internal("test").status_code(), 500);
assert_eq!(WebError::service_unavailable("test").status_code(), 503);
assert_eq!(WebError::gateway_timeout(5000).status_code(), 504);
}
#[test]
fn test_web_error_codes() {
assert_eq!(
WebError::invalid_request("test").error_code(),
"INVALID_REQUEST"
);
assert_eq!(WebError::unauthorized("test").error_code(), "UNAUTHORIZED");
assert_eq!(WebError::forbidden("test").error_code(), "FORBIDDEN");
assert_eq!(WebError::not_found("test").error_code(), "NOT_FOUND");
assert_eq!(
WebError::content_too_large(100, 50).error_code(),
"CONTENT_TOO_LARGE"
);
assert_eq!(WebError::rate_limited(60).error_code(), "RATE_LIMITED");
assert_eq!(WebError::internal("test").error_code(), "INTERNAL_ERROR");
}
#[test]
fn test_web_error_json() {
let err = WebError::invalid_request("Missing name field");
let json = err.to_json();
assert_eq!(json["code"], "INVALID_REQUEST");
assert!(json["error"]
.as_str()
.unwrap()
.contains("Missing name field"));
}
#[test]
fn test_web_error_json_with_request_id() {
let err = WebError::rate_limited(120);
let json = err.to_json_with_request_id("req_abc123");
assert_eq!(json["code"], "RATE_LIMITED");
assert_eq!(json["request_id"], "req_abc123");
}
#[test]
fn test_web_error_factory_methods() {
let err = WebError::missing_field("email");
assert!(err.to_string().contains("Missing required field: email"));
let err = WebError::invalid_field("age", "must be positive");
assert!(err.to_string().contains("Invalid value for field 'age'"));
}
#[test]
fn test_web_error_retryable() {
assert!(!WebError::invalid_request("test").is_retryable());
assert!(!WebError::unauthorized("test").is_retryable());
assert!(WebError::rate_limited(60).is_retryable());
assert!(WebError::service_unavailable("test").is_retryable());
assert!(WebError::gateway_timeout(5000).is_retryable());
}
#[test]
fn test_web_error_retry_after() {
assert_eq!(WebError::rate_limited(120).retry_after(), Some(120));
assert_eq!(WebError::invalid_request("test").retry_after(), None);
}
#[test]
fn test_error_display() {
let err = Error::Browser(BrowserError::LaunchFailed("no chrome".to_string()));
assert!(err.to_string().contains("Failed to launch browser"));
assert!(err.to_string().contains("no chrome"));
}
#[test]
fn test_mcp_error() {
let err = McpError::ToolNotFound("unknown_tool".to_string());
assert_eq!(err.to_string(), "Tool not found: unknown_tool");
}
#[test]
fn test_extraction_error() {
let err = ExtractionError::ElementNotFound("#missing".to_string());
assert!(err.to_string().contains("Element not found"));
}
#[test]
fn test_navigation_error() {
let err = NavigationError::HttpError {
status: 404,
message: "Not Found".to_string(),
};
assert!(err.to_string().contains("404"));
assert!(err.to_string().contains("Not Found"));
}
#[test]
fn test_generic_error() {
let err = Error::generic("something went wrong");
assert_eq!(err.to_string(), "something went wrong");
}
#[test]
fn test_error_into_web_error() {
let err = Error::Navigation(NavigationError::InvalidUrl("bad-url".to_string()));
let web_err = err.into_web_error();
assert_eq!(web_err.status_code(), 400);
assert!(web_err.to_string().contains("Invalid URL"));
let err = Error::Navigation(NavigationError::Timeout(5000));
let web_err = err.into_web_error();
assert_eq!(web_err.status_code(), 504);
}
#[test]
fn test_error_response_serialization() {
let response = ErrorResponse::new("Test error", "TEST_ERROR")
.with_request_id("req_123")
.with_details(json!({"field": "name"}));
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("TEST_ERROR"));
assert!(json.contains("req_123"));
assert!(json.contains("name"));
}
#[test]
fn test_request_context() {
let ctx = RequestContext::new();
assert!(ctx.request_id.starts_with("req_"));
assert_eq!(ctx.request_id.len(), 20);
let ctx = RequestContext::with_id("custom-id-123");
assert_eq!(ctx.request_id, "custom-id-123");
}
#[test]
fn test_generate_request_id() {
let id1 = generate_request_id();
let id2 = generate_request_id();
assert_ne!(id1, id2);
assert!(id1.starts_with("req_"));
}
#[test]
fn test_web_error_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let web_err: WebError = io_err.into();
assert_eq!(web_err.status_code(), 500);
}
#[test]
fn test_content_too_large_display() {
let err = WebError::content_too_large(1024 * 1024 * 10, 1024 * 1024);
assert!(err.to_string().contains("10485760"));
assert!(err.to_string().contains("1048576"));
}
}