reasonkit_web/
error.rs

1//! Error types for ReasonKit Web
2//!
3//! This module provides a comprehensive error type hierarchy using `thiserror`
4//! for proper error handling across all components.
5//!
6//! # Error Categories
7//!
8//! - [`WebError`] - HTTP-level errors with status codes and JSON responses
9//! - [`enum@Error`] - Internal operation errors (browser, MCP, extraction)
10//! - Domain-specific errors: [`BrowserError`], [`McpError`], [`ExtractionError`], etc.
11//!
12//! # Example
13//!
14//! ```rust,no_run
15//! use reasonkit_web::error::{WebError, Result};
16//!
17//! fn process_request(data: &str) -> Result<String> {
18//!     if data.is_empty() {
19//!         return Err(WebError::invalid_request("Data cannot be empty").into());
20//!     }
21//!     Ok(data.to_uppercase())
22//! }
23//! ```
24
25use serde::{Deserialize, Serialize};
26use serde_json::json;
27use std::fmt;
28use thiserror::Error;
29use tracing::{error, warn};
30
31// ============================================================================
32// WebError - HTTP-level errors with JSON responses
33// ============================================================================
34
35/// HTTP-level error with status codes and structured JSON responses.
36///
37/// This error type is designed for web API responses, providing:
38/// - Appropriate HTTP status codes
39/// - Structured JSON error bodies
40/// - Request ID correlation for tracing
41/// - Logging integration
42///
43/// # JSON Response Format
44///
45/// ```json
46/// {
47///     "error": "Human-readable error message",
48///     "code": "ERROR_CODE",
49///     "request_id": "optional-request-id"
50/// }
51/// ```
52#[derive(Error, Debug, Clone)]
53pub enum WebError {
54    /// Invalid request - malformed input, missing fields, validation failures
55    /// HTTP Status: 400 Bad Request
56    #[error("Invalid request: {message}")]
57    InvalidRequest {
58        /// Detailed error message
59        message: String,
60    },
61
62    /// Unauthorized - authentication required or invalid credentials
63    /// HTTP Status: 401 Unauthorized
64    #[error("Unauthorized: {reason}")]
65    Unauthorized {
66        /// Reason for authorization failure
67        reason: String,
68    },
69
70    /// Forbidden - authenticated but not permitted
71    /// HTTP Status: 403 Forbidden
72    #[error("Forbidden: {reason}")]
73    Forbidden {
74        /// Reason access was denied
75        reason: String,
76    },
77
78    /// Resource not found
79    /// HTTP Status: 404 Not Found
80    #[error("Not found: {resource}")]
81    NotFound {
82        /// Description of the missing resource
83        resource: String,
84    },
85
86    /// Request content too large
87    /// HTTP Status: 413 Payload Too Large
88    #[error("Content too large: {size} bytes exceeds maximum of {max} bytes")]
89    ContentTooLarge {
90        /// Actual size of the content
91        size: usize,
92        /// Maximum allowed size
93        max: usize,
94    },
95
96    /// Rate limit exceeded
97    /// HTTP Status: 429 Too Many Requests
98    #[error("Rate limited: retry after {retry_after_secs} seconds")]
99    RateLimited {
100        /// Number of seconds to wait before retrying
101        retry_after_secs: u64,
102    },
103
104    /// Processing error - operation failed during execution
105    /// HTTP Status: 500 Internal Server Error
106    #[error("Processing error: {0}")]
107    ProcessingError(String),
108
109    /// Internal server error - unexpected failures
110    /// HTTP Status: 500 Internal Server Error
111    #[error("Internal error: {message}")]
112    InternalError {
113        /// Error message (sanitized for external display)
114        message: String,
115    },
116
117    /// Service unavailable - temporarily unable to handle request
118    /// HTTP Status: 503 Service Unavailable
119    #[error("Service unavailable: {reason}")]
120    ServiceUnavailable {
121        /// Reason the service is unavailable
122        reason: String,
123    },
124
125    /// Gateway timeout - upstream service timed out
126    /// HTTP Status: 504 Gateway Timeout
127    #[error("Gateway timeout after {timeout_ms}ms")]
128    GatewayTimeout {
129        /// Timeout duration in milliseconds
130        timeout_ms: u64,
131    },
132}
133
134impl WebError {
135    // ========================================================================
136    // Factory Methods
137    // ========================================================================
138
139    /// Create an invalid request error with a message
140    pub fn invalid_request(message: impl Into<String>) -> Self {
141        Self::InvalidRequest {
142            message: message.into(),
143        }
144    }
145
146    /// Create an invalid request error for a missing field
147    pub fn missing_field(field: &str) -> Self {
148        Self::InvalidRequest {
149            message: format!("Missing required field: {}", field),
150        }
151    }
152
153    /// Create an invalid request error for an invalid field value
154    pub fn invalid_field(field: &str, reason: &str) -> Self {
155        Self::InvalidRequest {
156            message: format!("Invalid value for field '{}': {}", field, reason),
157        }
158    }
159
160    /// Create an unauthorized error
161    pub fn unauthorized(reason: impl Into<String>) -> Self {
162        Self::Unauthorized {
163            reason: reason.into(),
164        }
165    }
166
167    /// Create a forbidden error
168    pub fn forbidden(reason: impl Into<String>) -> Self {
169        Self::Forbidden {
170            reason: reason.into(),
171        }
172    }
173
174    /// Create a not found error
175    pub fn not_found(resource: impl Into<String>) -> Self {
176        Self::NotFound {
177            resource: resource.into(),
178        }
179    }
180
181    /// Create a content too large error
182    pub fn content_too_large(size: usize, max: usize) -> Self {
183        Self::ContentTooLarge { size, max }
184    }
185
186    /// Create a rate limited error
187    pub fn rate_limited(retry_after_secs: u64) -> Self {
188        Self::RateLimited { retry_after_secs }
189    }
190
191    /// Create a processing error from any error type
192    pub fn processing<E: std::fmt::Display>(source: E) -> Self {
193        Self::ProcessingError(source.to_string())
194    }
195
196    /// Create an internal error with a message
197    pub fn internal(message: impl Into<String>) -> Self {
198        Self::InternalError {
199            message: message.into(),
200        }
201    }
202
203    /// Create an internal error from any error, sanitizing the message
204    pub fn internal_from<E: std::error::Error>(err: E) -> Self {
205        // Log the full error for debugging
206        error!(error = %err, "Internal error occurred");
207        Self::InternalError {
208            message: "An unexpected error occurred".to_string(),
209        }
210    }
211
212    /// Create a service unavailable error
213    pub fn service_unavailable(reason: impl Into<String>) -> Self {
214        Self::ServiceUnavailable {
215            reason: reason.into(),
216        }
217    }
218
219    /// Create a gateway timeout error
220    pub fn gateway_timeout(timeout_ms: u64) -> Self {
221        Self::GatewayTimeout { timeout_ms }
222    }
223
224    // ========================================================================
225    // HTTP Status Code Mapping
226    // ========================================================================
227
228    /// Get the HTTP status code for this error
229    pub fn status_code(&self) -> u16 {
230        match self {
231            Self::InvalidRequest { .. } => 400,
232            Self::Unauthorized { .. } => 401,
233            Self::Forbidden { .. } => 403,
234            Self::NotFound { .. } => 404,
235            Self::ContentTooLarge { .. } => 413,
236            Self::RateLimited { .. } => 429,
237            Self::ProcessingError(_) => 500,
238            Self::InternalError { .. } => 500,
239            Self::ServiceUnavailable { .. } => 503,
240            Self::GatewayTimeout { .. } => 504,
241        }
242    }
243
244    /// Get the error code string for this error
245    pub fn error_code(&self) -> &'static str {
246        match self {
247            Self::InvalidRequest { .. } => "INVALID_REQUEST",
248            Self::Unauthorized { .. } => "UNAUTHORIZED",
249            Self::Forbidden { .. } => "FORBIDDEN",
250            Self::NotFound { .. } => "NOT_FOUND",
251            Self::ContentTooLarge { .. } => "CONTENT_TOO_LARGE",
252            Self::RateLimited { .. } => "RATE_LIMITED",
253            Self::ProcessingError(_) => "PROCESSING_ERROR",
254            Self::InternalError { .. } => "INTERNAL_ERROR",
255            Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
256            Self::GatewayTimeout { .. } => "GATEWAY_TIMEOUT",
257        }
258    }
259
260    /// Convert error to JSON response body
261    pub fn to_json(&self) -> serde_json::Value {
262        json!({
263            "error": self.to_string(),
264            "code": self.error_code()
265        })
266    }
267
268    /// Convert error to JSON response body with request ID
269    pub fn to_json_with_request_id(&self, request_id: &str) -> serde_json::Value {
270        json!({
271            "error": self.to_string(),
272            "code": self.error_code(),
273            "request_id": request_id
274        })
275    }
276
277    /// Log the error with appropriate level and optional request ID
278    pub fn log(&self, request_id: Option<&str>) {
279        let request_id = request_id.unwrap_or("unknown");
280
281        match self {
282            Self::InvalidRequest { message } => {
283                warn!(
284                    request_id = %request_id,
285                    error_code = %self.error_code(),
286                    message = %message,
287                    "Invalid request"
288                );
289            }
290            Self::Unauthorized { reason } => {
291                warn!(
292                    request_id = %request_id,
293                    error_code = %self.error_code(),
294                    reason = %reason,
295                    "Unauthorized access attempt"
296                );
297            }
298            Self::Forbidden { reason } => {
299                warn!(
300                    request_id = %request_id,
301                    error_code = %self.error_code(),
302                    reason = %reason,
303                    "Forbidden access"
304                );
305            }
306            Self::NotFound { resource } => {
307                warn!(
308                    request_id = %request_id,
309                    error_code = %self.error_code(),
310                    resource = %resource,
311                    "Resource not found"
312                );
313            }
314            Self::ContentTooLarge { size, max } => {
315                warn!(
316                    request_id = %request_id,
317                    error_code = %self.error_code(),
318                    size = %size,
319                    max = %max,
320                    "Content too large"
321                );
322            }
323            Self::RateLimited { retry_after_secs } => {
324                warn!(
325                    request_id = %request_id,
326                    error_code = %self.error_code(),
327                    retry_after_secs = %retry_after_secs,
328                    "Rate limited"
329                );
330            }
331            Self::ProcessingError(err) => {
332                error!(
333                    request_id = %request_id,
334                    error_code = %self.error_code(),
335                    error = %err,
336                    "Processing error"
337                );
338            }
339            Self::InternalError { message } => {
340                error!(
341                    request_id = %request_id,
342                    error_code = %self.error_code(),
343                    message = %message,
344                    "Internal error"
345                );
346            }
347            Self::ServiceUnavailable { reason } => {
348                error!(
349                    request_id = %request_id,
350                    error_code = %self.error_code(),
351                    reason = %reason,
352                    "Service unavailable"
353                );
354            }
355            Self::GatewayTimeout { timeout_ms } => {
356                error!(
357                    request_id = %request_id,
358                    error_code = %self.error_code(),
359                    timeout_ms = %timeout_ms,
360                    "Gateway timeout"
361                );
362            }
363        }
364    }
365
366    /// Check if this error should be retried
367    pub fn is_retryable(&self) -> bool {
368        matches!(
369            self,
370            Self::RateLimited { .. }
371                | Self::ServiceUnavailable { .. }
372                | Self::GatewayTimeout { .. }
373        )
374    }
375
376    /// Get retry-after header value if applicable
377    pub fn retry_after(&self) -> Option<u64> {
378        match self {
379            Self::RateLimited { retry_after_secs } => Some(*retry_after_secs),
380            _ => None,
381        }
382    }
383}
384
385/// Structured JSON error response for serialization
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct ErrorResponse {
388    /// Human-readable error message
389    pub error: String,
390    /// Machine-readable error code
391    pub code: String,
392    /// Optional request ID for correlation
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub request_id: Option<String>,
395    /// Optional additional details
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub details: Option<serde_json::Value>,
398}
399
400impl ErrorResponse {
401    /// Create a new error response
402    pub fn new(error: impl Into<String>, code: impl Into<String>) -> Self {
403        Self {
404            error: error.into(),
405            code: code.into(),
406            request_id: None,
407            details: None,
408        }
409    }
410
411    /// Set the request ID
412    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
413        self.request_id = Some(request_id.into());
414        self
415    }
416
417    /// Set additional details
418    pub fn with_details(mut self, details: serde_json::Value) -> Self {
419        self.details = Some(details);
420        self
421    }
422}
423
424impl From<&WebError> for ErrorResponse {
425    fn from(err: &WebError) -> Self {
426        Self {
427            error: err.to_string(),
428            code: err.error_code().to_string(),
429            request_id: None,
430            details: None,
431        }
432    }
433}
434
435// ============================================================================
436// Internal Error - Comprehensive domain errors
437// ============================================================================
438
439/// The main error type for ReasonKit Web internal operations
440#[derive(Error, Debug)]
441pub enum Error {
442    /// HTTP/Web-level errors
443    #[error("{0}")]
444    Web(#[from] WebError),
445
446    /// Browser-related errors
447    #[error("Browser error: {0}")]
448    Browser(#[from] BrowserError),
449
450    /// MCP protocol errors
451    #[error("MCP error: {0}")]
452    Mcp(#[from] McpError),
453
454    /// Content extraction errors
455    #[error("Extraction error: {0}")]
456    Extraction(#[from] ExtractionError),
457
458    /// Navigation errors
459    #[error("Navigation error: {0}")]
460    Navigation(#[from] NavigationError),
461
462    /// Capture errors (screenshot, PDF, etc.)
463    #[error("Capture error: {0}")]
464    Capture(#[from] CaptureError),
465
466    /// I/O errors
467    #[error("I/O error: {0}")]
468    Io(#[from] std::io::Error),
469
470    /// JSON serialization/deserialization errors
471    #[error("JSON error: {0}")]
472    Json(#[from] serde_json::Error),
473
474    /// ChromiumOxide errors
475    #[error("CDP error: {0}")]
476    Cdp(String),
477
478    /// Generic error with message
479    #[error("{0}")]
480    Generic(String),
481}
482
483/// Browser lifecycle and control errors
484#[derive(Error, Debug)]
485pub enum BrowserError {
486    /// Failed to launch browser
487    #[error("Failed to launch browser: {0}")]
488    LaunchFailed(String),
489
490    /// Browser configuration error
491    #[error("Invalid browser configuration: {0}")]
492    ConfigError(String),
493
494    /// Browser connection lost
495    #[error("Browser connection lost")]
496    ConnectionLost,
497
498    /// Failed to create new page/tab
499    #[error("Failed to create page: {0}")]
500    PageCreationFailed(String),
501
502    /// Browser already closed
503    #[error("Browser already closed")]
504    AlreadyClosed,
505
506    /// Timeout waiting for browser
507    #[error("Browser operation timed out after {0}ms")]
508    Timeout(u64),
509}
510
511/// MCP protocol errors
512#[derive(Error, Debug)]
513pub enum McpError {
514    /// Invalid JSON-RPC request
515    #[error("Invalid JSON-RPC request: {0}")]
516    InvalidRequest(String),
517
518    /// Unknown method
519    #[error("Unknown method: {0}")]
520    UnknownMethod(String),
521
522    /// Invalid parameters
523    #[error("Invalid parameters: {0}")]
524    InvalidParams(String),
525
526    /// Tool not found
527    #[error("Tool not found: {0}")]
528    ToolNotFound(String),
529
530    /// Tool execution failed
531    #[error("Tool execution failed: {0}")]
532    ToolExecutionFailed(String),
533
534    /// Protocol version mismatch
535    #[error("Protocol version mismatch: expected {expected}, got {actual}")]
536    VersionMismatch {
537        /// Expected version
538        expected: String,
539        /// Actual version received
540        actual: String,
541    },
542
543    /// Parse error
544    #[error("Parse error: {0}")]
545    ParseError(String),
546}
547
548/// Content extraction errors
549#[derive(Error, Debug)]
550pub enum ExtractionError {
551    /// Element not found
552    #[error("Element not found: {0}")]
553    ElementNotFound(String),
554
555    /// Invalid selector
556    #[error("Invalid selector: {0}")]
557    InvalidSelector(String),
558
559    /// Extraction failed
560    #[error("Extraction failed: {0}")]
561    ExtractionFailed(String),
562
563    /// Content parsing failed
564    #[error("Content parsing failed: {0}")]
565    ParsingFailed(String),
566
567    /// JavaScript execution failed
568    #[error("JavaScript execution failed: {0}")]
569    JsExecutionFailed(String),
570}
571
572/// Navigation errors
573#[derive(Error, Debug)]
574pub enum NavigationError {
575    /// Invalid URL
576    #[error("Invalid URL: {0}")]
577    InvalidUrl(String),
578
579    /// Navigation timeout
580    #[error("Navigation timed out after {0}ms")]
581    Timeout(u64),
582
583    /// Page load failed
584    #[error("Page load failed: {0}")]
585    LoadFailed(String),
586
587    /// SSL/TLS error
588    #[error("SSL/TLS error: {0}")]
589    SslError(String),
590
591    /// Network error
592    #[error("Network error: {0}")]
593    NetworkError(String),
594
595    /// HTTP error
596    #[error("HTTP error {status}: {message}")]
597    HttpError {
598        /// HTTP status code
599        status: u16,
600        /// Error message
601        message: String,
602    },
603}
604
605/// Capture errors (screenshots, PDFs, etc.)
606#[derive(Error, Debug)]
607pub enum CaptureError {
608    /// Screenshot failed
609    #[error("Screenshot capture failed: {0}")]
610    ScreenshotFailed(String),
611
612    /// PDF generation failed
613    #[error("PDF generation failed: {0}")]
614    PdfFailed(String),
615
616    /// MHTML capture failed
617    #[error("MHTML capture failed: {0}")]
618    MhtmlFailed(String),
619
620    /// HTML capture failed
621    #[error("HTML capture failed: {0}")]
622    HtmlFailed(String),
623
624    /// Invalid capture format
625    #[error("Invalid capture format: {0}")]
626    InvalidFormat(String),
627
628    /// Capture timeout
629    #[error("Capture timed out after {0}ms")]
630    Timeout(u64),
631}
632
633/// Result type alias for ReasonKit Web operations
634pub type Result<T> = std::result::Result<T, Error>;
635
636/// Result type alias for WebError operations
637pub type WebResult<T> = std::result::Result<T, WebError>;
638
639impl Error {
640    /// Create a generic error from a string
641    pub fn generic<S: Into<String>>(msg: S) -> Self {
642        Error::Generic(msg.into())
643    }
644
645    /// Create a CDP error from a string
646    pub fn cdp<S: Into<String>>(msg: S) -> Self {
647        Error::Cdp(msg.into())
648    }
649
650    /// Convert internal error to WebError for HTTP responses
651    pub fn into_web_error(self) -> WebError {
652        match self {
653            Error::Web(e) => e,
654            Error::Browser(e) => match e {
655                BrowserError::Timeout(ms) => WebError::GatewayTimeout { timeout_ms: ms },
656                BrowserError::ConnectionLost => {
657                    WebError::service_unavailable("Browser connection lost")
658                }
659                BrowserError::AlreadyClosed => {
660                    WebError::service_unavailable("Browser session closed")
661                }
662                _ => WebError::internal(e.to_string()),
663            },
664            Error::Mcp(e) => match e {
665                McpError::InvalidRequest(msg) => WebError::invalid_request(msg),
666                McpError::InvalidParams(msg) => WebError::invalid_request(msg),
667                McpError::ToolNotFound(tool) => {
668                    WebError::not_found(format!("Tool not found: {}", tool))
669                }
670                _ => WebError::internal(e.to_string()),
671            },
672            Error::Navigation(e) => match e {
673                NavigationError::InvalidUrl(url) => {
674                    WebError::invalid_request(format!("Invalid URL: {}", url))
675                }
676                NavigationError::Timeout(ms) => WebError::GatewayTimeout { timeout_ms: ms },
677                NavigationError::HttpError { status, message } => {
678                    if status == 404 {
679                        WebError::not_found(message)
680                    } else if status == 401 {
681                        WebError::unauthorized(message)
682                    } else if status == 403 {
683                        WebError::forbidden(message)
684                    } else if status == 429 {
685                        WebError::rate_limited(60) // Default retry
686                    } else {
687                        WebError::internal(format!("HTTP {}: {}", status, message))
688                    }
689                }
690                _ => WebError::internal(e.to_string()),
691            },
692            Error::Extraction(e) => match e {
693                ExtractionError::ElementNotFound(selector) => {
694                    WebError::not_found(format!("Element not found: {}", selector))
695                }
696                ExtractionError::InvalidSelector(sel) => {
697                    WebError::invalid_request(format!("Invalid selector: {}", sel))
698                }
699                _ => WebError::internal(e.to_string()),
700            },
701            Error::Capture(e) => match e {
702                CaptureError::Timeout(ms) => WebError::GatewayTimeout { timeout_ms: ms },
703                CaptureError::InvalidFormat(fmt) => {
704                    WebError::invalid_request(format!("Invalid format: {}", fmt))
705                }
706                _ => WebError::internal(e.to_string()),
707            },
708            Error::Io(e) => WebError::internal(format!("I/O error: {}", e)),
709            Error::Json(e) => WebError::invalid_request(format!("JSON error: {}", e)),
710            Error::Cdp(msg) => WebError::internal(format!("CDP error: {}", msg)),
711            Error::Generic(msg) => WebError::internal(msg),
712        }
713    }
714}
715
716/// Convert chromiumoxide errors
717impl From<chromiumoxide::error::CdpError> for Error {
718    fn from(err: chromiumoxide::error::CdpError) -> Self {
719        Error::Cdp(err.to_string())
720    }
721}
722
723// ============================================================================
724// Conversion implementations for WebError
725// ============================================================================
726
727impl From<std::io::Error> for WebError {
728    fn from(err: std::io::Error) -> Self {
729        WebError::internal(format!("I/O error: {}", err))
730    }
731}
732
733impl From<serde_json::Error> for WebError {
734    fn from(err: serde_json::Error) -> Self {
735        WebError::invalid_request(format!("JSON error: {}", err))
736    }
737}
738
739impl From<anyhow::Error> for WebError {
740    fn from(err: anyhow::Error) -> Self {
741        WebError::ProcessingError(err.to_string())
742    }
743}
744
745// ============================================================================
746// Request ID generation
747// ============================================================================
748
749/// Generate a unique request ID for tracing
750pub fn generate_request_id() -> String {
751    use rand::Rng;
752    let mut rng = rand::rng();
753    let id: u64 = rng.random();
754    format!("req_{:016x}", id)
755}
756
757/// Request context for error handling
758#[derive(Debug, Clone)]
759pub struct RequestContext {
760    /// Unique request ID
761    pub request_id: String,
762    /// Request start time
763    pub start_time: std::time::Instant,
764}
765
766impl RequestContext {
767    /// Create a new request context with a generated ID
768    pub fn new() -> Self {
769        Self {
770            request_id: generate_request_id(),
771            start_time: std::time::Instant::now(),
772        }
773    }
774
775    /// Create a new request context with a specific ID
776    pub fn with_id(request_id: impl Into<String>) -> Self {
777        Self {
778            request_id: request_id.into(),
779            start_time: std::time::Instant::now(),
780        }
781    }
782
783    /// Get elapsed time in milliseconds
784    pub fn elapsed_ms(&self) -> u64 {
785        self.start_time.elapsed().as_millis() as u64
786    }
787
788    /// Log an error with this request context
789    pub fn log_error(&self, error: &WebError) {
790        error.log(Some(&self.request_id));
791    }
792}
793
794impl Default for RequestContext {
795    fn default() -> Self {
796        Self::new()
797    }
798}
799
800impl fmt::Display for RequestContext {
801    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
802        write!(f, "[{}]", self.request_id)
803    }
804}
805
806// ============================================================================
807// Tests
808// ============================================================================
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn test_web_error_status_codes() {
816        assert_eq!(WebError::invalid_request("test").status_code(), 400);
817        assert_eq!(WebError::unauthorized("test").status_code(), 401);
818        assert_eq!(WebError::forbidden("test").status_code(), 403);
819        assert_eq!(WebError::not_found("test").status_code(), 404);
820        assert_eq!(WebError::content_too_large(100, 50).status_code(), 413);
821        assert_eq!(WebError::rate_limited(60).status_code(), 429);
822        assert_eq!(WebError::internal("test").status_code(), 500);
823        assert_eq!(WebError::service_unavailable("test").status_code(), 503);
824        assert_eq!(WebError::gateway_timeout(5000).status_code(), 504);
825    }
826
827    #[test]
828    fn test_web_error_codes() {
829        assert_eq!(
830            WebError::invalid_request("test").error_code(),
831            "INVALID_REQUEST"
832        );
833        assert_eq!(WebError::unauthorized("test").error_code(), "UNAUTHORIZED");
834        assert_eq!(WebError::forbidden("test").error_code(), "FORBIDDEN");
835        assert_eq!(WebError::not_found("test").error_code(), "NOT_FOUND");
836        assert_eq!(
837            WebError::content_too_large(100, 50).error_code(),
838            "CONTENT_TOO_LARGE"
839        );
840        assert_eq!(WebError::rate_limited(60).error_code(), "RATE_LIMITED");
841        assert_eq!(WebError::internal("test").error_code(), "INTERNAL_ERROR");
842    }
843
844    #[test]
845    fn test_web_error_json() {
846        let err = WebError::invalid_request("Missing name field");
847        let json = err.to_json();
848
849        assert_eq!(json["code"], "INVALID_REQUEST");
850        assert!(json["error"]
851            .as_str()
852            .unwrap()
853            .contains("Missing name field"));
854    }
855
856    #[test]
857    fn test_web_error_json_with_request_id() {
858        let err = WebError::rate_limited(120);
859        let json = err.to_json_with_request_id("req_abc123");
860
861        assert_eq!(json["code"], "RATE_LIMITED");
862        assert_eq!(json["request_id"], "req_abc123");
863    }
864
865    #[test]
866    fn test_web_error_factory_methods() {
867        let err = WebError::missing_field("email");
868        assert!(err.to_string().contains("Missing required field: email"));
869
870        let err = WebError::invalid_field("age", "must be positive");
871        assert!(err.to_string().contains("Invalid value for field 'age'"));
872    }
873
874    #[test]
875    fn test_web_error_retryable() {
876        assert!(!WebError::invalid_request("test").is_retryable());
877        assert!(!WebError::unauthorized("test").is_retryable());
878        assert!(WebError::rate_limited(60).is_retryable());
879        assert!(WebError::service_unavailable("test").is_retryable());
880        assert!(WebError::gateway_timeout(5000).is_retryable());
881    }
882
883    #[test]
884    fn test_web_error_retry_after() {
885        assert_eq!(WebError::rate_limited(120).retry_after(), Some(120));
886        assert_eq!(WebError::invalid_request("test").retry_after(), None);
887    }
888
889    #[test]
890    fn test_error_display() {
891        let err = Error::Browser(BrowserError::LaunchFailed("no chrome".to_string()));
892        assert!(err.to_string().contains("Failed to launch browser"));
893        assert!(err.to_string().contains("no chrome"));
894    }
895
896    #[test]
897    fn test_mcp_error() {
898        let err = McpError::ToolNotFound("unknown_tool".to_string());
899        assert_eq!(err.to_string(), "Tool not found: unknown_tool");
900    }
901
902    #[test]
903    fn test_extraction_error() {
904        let err = ExtractionError::ElementNotFound("#missing".to_string());
905        assert!(err.to_string().contains("Element not found"));
906    }
907
908    #[test]
909    fn test_navigation_error() {
910        let err = NavigationError::HttpError {
911            status: 404,
912            message: "Not Found".to_string(),
913        };
914        assert!(err.to_string().contains("404"));
915        assert!(err.to_string().contains("Not Found"));
916    }
917
918    #[test]
919    fn test_generic_error() {
920        let err = Error::generic("something went wrong");
921        assert_eq!(err.to_string(), "something went wrong");
922    }
923
924    #[test]
925    fn test_error_into_web_error() {
926        let err = Error::Navigation(NavigationError::InvalidUrl("bad-url".to_string()));
927        let web_err = err.into_web_error();
928        assert_eq!(web_err.status_code(), 400);
929        assert!(web_err.to_string().contains("Invalid URL"));
930
931        let err = Error::Navigation(NavigationError::Timeout(5000));
932        let web_err = err.into_web_error();
933        assert_eq!(web_err.status_code(), 504);
934    }
935
936    #[test]
937    fn test_error_response_serialization() {
938        let response = ErrorResponse::new("Test error", "TEST_ERROR")
939            .with_request_id("req_123")
940            .with_details(json!({"field": "name"}));
941
942        let json = serde_json::to_string(&response).unwrap();
943        assert!(json.contains("TEST_ERROR"));
944        assert!(json.contains("req_123"));
945        assert!(json.contains("name"));
946    }
947
948    #[test]
949    fn test_request_context() {
950        let ctx = RequestContext::new();
951        assert!(ctx.request_id.starts_with("req_"));
952        assert_eq!(ctx.request_id.len(), 20); // "req_" + 16 hex chars
953
954        let ctx = RequestContext::with_id("custom-id-123");
955        assert_eq!(ctx.request_id, "custom-id-123");
956    }
957
958    #[test]
959    fn test_generate_request_id() {
960        let id1 = generate_request_id();
961        let id2 = generate_request_id();
962        assert_ne!(id1, id2);
963        assert!(id1.starts_with("req_"));
964    }
965
966    #[test]
967    fn test_web_error_from_io_error() {
968        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
969        let web_err: WebError = io_err.into();
970        assert_eq!(web_err.status_code(), 500);
971    }
972
973    #[test]
974    fn test_content_too_large_display() {
975        let err = WebError::content_too_large(1024 * 1024 * 10, 1024 * 1024);
976        assert!(err.to_string().contains("10485760"));
977        assert!(err.to_string().contains("1048576"));
978    }
979}