Skip to main content

pagesift_api/
error.rs

1//! 错误码和 HTTP 状态码(PRD 6.5.2)。响应体沿用 Firecrawl 的 `{success: false, code, error}`,
2//! 状态码向 Firecrawl 的同类错误看齐,官方 SDK 抛出的异常类型因此不变(比如超时是 408)。
3
4use axum::Json;
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde_json::{Value, json};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ErrorCode {
11    RobotsDisallowed,
12    AccessBarrier,
13    RateLimited,
14    HostCooldown,
15    /// 调用方超过了自己 API key 的每分钟请求数。
16    RateLimitExceeded,
17    /// 调用方同时进行的任务数到了上限。
18    ConcurrencyLimitExceeded,
19    /// 调用方当天的页面配额用完了。
20    QuotaExceeded,
21    Timeout,
22    SsrfBlocked,
23    TooLarge,
24    UpstreamError,
25    UnsupportedOption,
26    InvalidRequest,
27    Unauthorized,
28    NotFound,
29    Internal,
30    /// 域名解析失败:可以稍后重试,不同于 `SSRF_BLOCKED`(永远不许抓)。
31    DnsFailure,
32    /// 连接失败、被重置、TLS 握手失败,或者浏览器导航时的网络错误。
33    NetworkError,
34    /// TLS 证书已过期或尚未生效。
35    TlsCertExpired,
36    /// TLS 证书的主机名和请求域名不一致。
37    TlsHostnameMismatch,
38    /// TLS 证书链的根证书不受信任。
39    TlsUntrustedRoot,
40    /// 其他 TLS 握手错误。
41    TlsError,
42    /// 在请求的时限内等不到空闲的浏览器。
43    Busy,
44}
45
46impl ErrorCode {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            ErrorCode::RobotsDisallowed => "ROBOTS_DISALLOWED",
50            ErrorCode::AccessBarrier => "ACCESS_BARRIER",
51            ErrorCode::RateLimited => "RATE_LIMITED",
52            ErrorCode::HostCooldown => "HOST_COOLDOWN",
53            ErrorCode::RateLimitExceeded => "RATE_LIMIT_EXCEEDED",
54            ErrorCode::ConcurrencyLimitExceeded => "CONCURRENCY_LIMIT_EXCEEDED",
55            ErrorCode::QuotaExceeded => "QUOTA_EXCEEDED",
56            ErrorCode::Timeout => "TIMEOUT",
57            ErrorCode::SsrfBlocked => "SSRF_BLOCKED",
58            ErrorCode::TooLarge => "TOO_LARGE",
59            ErrorCode::UpstreamError => "UPSTREAM_ERROR",
60            ErrorCode::UnsupportedOption => "UNSUPPORTED_OPTION",
61            ErrorCode::InvalidRequest => "INVALID_REQUEST",
62            ErrorCode::Unauthorized => "UNAUTHORIZED",
63            ErrorCode::NotFound => "NOT_FOUND",
64            ErrorCode::Internal => "INTERNAL_ERROR",
65            ErrorCode::DnsFailure => "DNS_FAILURE",
66            ErrorCode::NetworkError => "NETWORK_ERROR",
67            ErrorCode::TlsCertExpired => "TLS_CERT_EXPIRED",
68            ErrorCode::TlsHostnameMismatch => "TLS_HOSTNAME_MISMATCH",
69            ErrorCode::TlsUntrustedRoot => "TLS_UNTRUSTED_ROOT",
70            ErrorCode::TlsError => "TLS_ERROR",
71            ErrorCode::Busy => "BUSY",
72        }
73    }
74
75    pub fn status(self) -> StatusCode {
76        match self {
77            ErrorCode::RobotsDisallowed | ErrorCode::AccessBarrier => StatusCode::FORBIDDEN,
78            ErrorCode::RateLimited
79            | ErrorCode::RateLimitExceeded
80            | ErrorCode::ConcurrencyLimitExceeded
81            | ErrorCode::QuotaExceeded => StatusCode::TOO_MANY_REQUESTS,
82            ErrorCode::HostCooldown | ErrorCode::Busy => StatusCode::SERVICE_UNAVAILABLE,
83            ErrorCode::Timeout => StatusCode::REQUEST_TIMEOUT,
84            ErrorCode::SsrfBlocked | ErrorCode::UnsupportedOption | ErrorCode::InvalidRequest => {
85                StatusCode::BAD_REQUEST
86            }
87            ErrorCode::TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
88            ErrorCode::UpstreamError | ErrorCode::DnsFailure | ErrorCode::NetworkError => {
89                StatusCode::BAD_GATEWAY
90            }
91            ErrorCode::TlsCertExpired
92            | ErrorCode::TlsHostnameMismatch
93            | ErrorCode::TlsUntrustedRoot
94            | ErrorCode::TlsError => StatusCode::BAD_GATEWAY,
95            ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
96            ErrorCode::NotFound => StatusCode::NOT_FOUND,
97            ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
98        }
99    }
100}
101
102#[derive(Debug, Clone, PartialEq)]
103pub struct ApiError {
104    pub code: ErrorCode,
105    pub message: String,
106    /// 放进响应的 `details`,比如拦截页的厂商和置信度。
107    pub details: Option<Value>,
108    /// 429 时带上 `Retry-After`(秒)。
109    pub retry_after: Option<u64>,
110}
111
112impl ApiError {
113    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
114        Self {
115            code,
116            message: message.into(),
117            details: None,
118            retry_after: None,
119        }
120    }
121
122    pub fn with_details(mut self, details: Value) -> Self {
123        self.details = Some(details);
124        self
125    }
126
127    pub fn with_retry_after(mut self, secs: u64) -> Self {
128        self.retry_after = Some(secs);
129        self
130    }
131
132    pub fn unsupported(what: impl Into<String>) -> Self {
133        Self::new(ErrorCode::UnsupportedOption, what)
134    }
135
136    pub fn invalid(what: impl Into<String>) -> Self {
137        Self::new(ErrorCode::InvalidRequest, what)
138    }
139}
140
141impl std::fmt::Display for ApiError {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        write!(f, "{}: {}", self.code.as_str(), self.message)
144    }
145}
146
147impl std::error::Error for ApiError {}
148
149impl IntoResponse for ApiError {
150    fn into_response(self) -> Response {
151        let mut body = json!({
152            "success": false,
153            "code": self.code.as_str(),
154            "error": self.message,
155        });
156        if let Some(d) = self.details {
157            body["details"] = d;
158        }
159        let mut resp = (self.code.status(), Json(body)).into_response();
160        // 给监控中间件按错误码计数
161        resp.extensions_mut().insert(self.code);
162        if let Some(secs) = self.retry_after
163            && let Ok(v) = secs.to_string().parse()
164        {
165            resp.headers_mut().insert("retry-after", v);
166        }
167        resp
168    }
169}