use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NetworkErrorKind {
Connection,
Dns,
Ssl,
Timeout,
Proxy,
Other,
}
impl NetworkErrorKind {
#[must_use]
pub fn tag(self) -> &'static str {
match self {
Self::Connection => "connection",
Self::Dns => "dns",
Self::Ssl => "ssl",
Self::Timeout => "timeout",
Self::Proxy => "proxy",
Self::Other => "network",
}
}
}
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum CrawlError {
#[error("not_found: {0}")]
NotFound(String),
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error("forbidden: waf/blocked: {message}")]
WafBlocked {
vendor: String,
message: String,
},
#[error("timeout: {0}")]
Timeout(String),
#[error("rate_limited: {0}")]
RateLimited(String),
#[error("server_error: {0}")]
ServerError(String),
#[error("bad_gateway: {0}")]
BadGateway(String),
#[error("gone: {0}")]
Gone(String),
#[error("connection: {0}")]
Connection(String),
#[error("dns: {0}")]
Dns(String),
#[error("ssl: {0}")]
Ssl(String),
#[error("data_loss: {0}")]
DataLoss(String),
#[error("browser: {0}")]
BrowserError(String),
#[error("browser_timeout: {0}")]
BrowserTimeout(String),
#[error("invalid_config: {0}")]
InvalidConfig(String),
#[error("unsupported: {0}")]
Unsupported(String),
#[error("ssrf_policy_violation: {url} - {reason}")]
SsrfPolicyViolation {
url: String,
reason: String,
},
#[error("other: {0}")]
Other(String),
}
impl From<crate::net::ssrf::SsrfError> for CrawlError {
fn from(err: crate::net::ssrf::SsrfError) -> Self {
CrawlError::SsrfPolicyViolation {
url: "unknown".to_string(),
reason: err.to_string(),
}
}
}
pub(crate) fn error_chain_string(e: &reqwest::Error) -> String {
let mut parts = vec![e.to_string()];
let mut current: &dyn std::error::Error = e;
while let Some(src) = current.source() {
parts.push(src.to_string());
current = src;
}
parts.join(" | ").to_lowercase()
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn network_error_kind(e: &reqwest::Error) -> NetworkErrorKind {
let chain = error_chain_string(e);
if e.is_timeout() || chain.contains("timed out") || chain.contains("timeout") {
NetworkErrorKind::Timeout
} else if chain.contains("dns") || chain.contains("resolve") || chain.contains("lookup") {
NetworkErrorKind::Dns
} else if chain.contains("ssl")
|| chain.contains("tls")
|| chain.contains("certificate")
|| chain.contains("record overflow")
|| chain.contains("handshake")
|| chain.contains("corrupt message")
|| chain.contains("alertdescription")
|| chain.contains("invalidcontenttype")
{
NetworkErrorKind::Ssl
} else if chain.contains("proxy") {
NetworkErrorKind::Proxy
} else if e.is_connect() || chain.contains("connection") || chain.contains("connect") {
NetworkErrorKind::Connection
} else {
NetworkErrorKind::Other
}
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn network_error_kind(e: &reqwest::Error) -> NetworkErrorKind {
let chain = error_chain_string(e);
if chain.contains("timed out") || chain.contains("timeout") {
NetworkErrorKind::Timeout
} else if chain.contains("dns") || chain.contains("resolve") || chain.contains("lookup") {
NetworkErrorKind::Dns
} else if chain.contains("ssl")
|| chain.contains("tls")
|| chain.contains("certificate")
|| chain.contains("handshake")
{
NetworkErrorKind::Ssl
} else if chain.contains("proxy") {
NetworkErrorKind::Proxy
} else if chain.contains("connection") || chain.contains("connect") {
NetworkErrorKind::Connection
} else {
NetworkErrorKind::Other
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn classify_reqwest_error(e: &reqwest::Error) -> CrawlError {
let chain = error_chain_string(e);
let kind = network_error_kind(e);
let tag = kind.tag();
match kind {
NetworkErrorKind::Timeout => CrawlError::Timeout(format!("[network:{tag}] {e}")),
NetworkErrorKind::Dns => CrawlError::Dns(format!("[network:{tag}] {e}")),
NetworkErrorKind::Ssl => CrawlError::Ssl(format!("[network:{tag}] {e}")),
NetworkErrorKind::Proxy | NetworkErrorKind::Connection => {
CrawlError::Connection(format!("[network:{tag}] {e}"))
}
NetworkErrorKind::Other => {
if e.is_body()
|| chain.contains("content-length")
|| chain.contains("truncate")
|| chain.contains("incomplete")
|| chain.contains("decoding response body")
|| chain.contains("error decoding")
{
CrawlError::DataLoss(format!("data_loss: {e}"))
} else {
CrawlError::Other(format!("other: {e}"))
}
}
}
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn classify_reqwest_error(e: &reqwest::Error) -> CrawlError {
let chain = error_chain_string(e);
let kind = network_error_kind(e);
let tag = kind.tag();
match kind {
NetworkErrorKind::Timeout => CrawlError::Timeout(format!("[network:{tag}] {e}")),
NetworkErrorKind::Dns => CrawlError::Dns(format!("[network:{tag}] {e}")),
NetworkErrorKind::Ssl => CrawlError::Ssl(format!("[network:{tag}] {e}")),
NetworkErrorKind::Proxy | NetworkErrorKind::Connection => {
CrawlError::Connection(format!("[network:{tag}] {e}"))
}
NetworkErrorKind::Other => {
if chain.contains("content-length") || chain.contains("truncate") || chain.contains("incomplete") {
CrawlError::DataLoss(format!("data_loss: {e}"))
} else {
CrawlError::Other(format!("other: {e}"))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_error_kind_tag_connection() {
assert_eq!(NetworkErrorKind::Connection.tag(), "connection");
}
#[test]
fn network_error_kind_tag_dns() {
assert_eq!(NetworkErrorKind::Dns.tag(), "dns");
}
#[test]
fn network_error_kind_tag_ssl() {
assert_eq!(NetworkErrorKind::Ssl.tag(), "ssl");
}
#[test]
fn network_error_kind_tag_timeout() {
assert_eq!(NetworkErrorKind::Timeout.tag(), "timeout");
}
#[test]
fn network_error_kind_tag_proxy() {
assert_eq!(NetworkErrorKind::Proxy.tag(), "proxy");
}
#[test]
fn network_error_kind_tag_other() {
assert_eq!(NetworkErrorKind::Other.tag(), "network");
}
#[cfg(not(target_arch = "wasm32"))]
mod network_integration {
use super::*;
use std::time::Duration;
use tokio::net::TcpListener;
async fn scrape_url(url: &str) -> CrawlError {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.danger_accept_invalid_certs(true)
.build()
.expect("client build must not fail");
classify_reqwest_error(&client.get(url).send().await.expect_err("expected network error"))
}
#[tokio::test]
async fn connection_refused_produces_connection_tag() {
let err = scrape_url("http://127.0.0.1:1/").await;
let msg = err.to_string();
assert!(
msg.contains("[network:connection]"),
"expected [network:connection] in '{msg}'"
);
assert!(msg.contains("connection"), "expected 'connection' in '{msg}'");
}
#[tokio::test]
async fn dns_failure_produces_dns_tag() {
let err = scrape_url("http://this-hostname-does-not-exist-crawlberg-test.invalid/").await;
let msg = err.to_string();
assert!(msg.contains("[network:dns]"), "expected [network:dns] in '{msg}'");
assert!(msg.contains("dns"), "expected 'dns' in '{msg}'");
}
#[tokio::test]
async fn timeout_produces_timeout_tag() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind failed");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
if let Ok((_socket, _)) = listener.accept().await {
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
let err = scrape_url(&format!("http://{addr}/")).await;
let msg = err.to_string();
assert!(
msg.contains("[network:timeout]"),
"expected [network:timeout] in '{msg}'"
);
assert!(msg.contains("timeout"), "expected 'timeout' in '{msg}'");
}
#[tokio::test]
async fn invalid_proxy_produces_connection_tag() {
let client = reqwest::Client::builder()
.proxy(reqwest::Proxy::all("http://127.0.0.1:1").expect("proxy parse"))
.timeout(Duration::from_millis(500))
.build()
.expect("client build");
let raw_err = client
.get("http://example.com/")
.send()
.await
.expect_err("expected proxy error");
let err = classify_reqwest_error(&raw_err);
let msg = err.to_string();
assert!(
msg.contains("[network:connection]") || msg.contains("[network:proxy]"),
"expected [network:connection] or [network:proxy] in '{msg}'"
);
assert!(
msg.contains("connection") || msg.contains("proxy"),
"expected 'connection' or 'proxy' in '{msg}'"
);
}
}
}