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    /// Jina 的 `x-assert-status-code`:页面状态码和断言的不一样。
25    AssertionFailed,
26    UpstreamError,
27    UnsupportedOption,
28    InvalidRequest,
29    Unauthorized,
30    NotFound,
31    Internal,
32    /// 域名解析失败:可以稍后重试,不同于 `SSRF_BLOCKED`(永远不许抓)。
33    DnsFailure,
34    /// 连接失败、被重置、TLS 握手失败,或者浏览器导航时的网络错误。
35    NetworkError,
36    /// TLS 证书已过期或尚未生效。
37    TlsCertExpired,
38    /// TLS 证书的主机名和请求域名不一致。
39    TlsHostnameMismatch,
40    /// TLS 证书链的根证书不受信任。
41    TlsUntrustedRoot,
42    /// 其他 TLS 握手错误。
43    TlsError,
44    /// 在请求的时限内等不到空闲的浏览器。
45    Busy,
46    /// 资源属于另一个账号。
47    Forbidden,
48    /// 保留的浏览器会话已经结束(关闭、回收、服务重启)。
49    SessionGone,
50}
51
52impl ErrorCode {
53    pub fn as_str(self) -> &'static str {
54        match self {
55            ErrorCode::RobotsDisallowed => "ROBOTS_DISALLOWED",
56            ErrorCode::AccessBarrier => "ACCESS_BARRIER",
57            ErrorCode::RateLimited => "RATE_LIMITED",
58            ErrorCode::HostCooldown => "HOST_COOLDOWN",
59            ErrorCode::RateLimitExceeded => "RATE_LIMIT_EXCEEDED",
60            ErrorCode::ConcurrencyLimitExceeded => "CONCURRENCY_LIMIT_EXCEEDED",
61            ErrorCode::QuotaExceeded => "QUOTA_EXCEEDED",
62            ErrorCode::Timeout => "TIMEOUT",
63            ErrorCode::SsrfBlocked => "SSRF_BLOCKED",
64            ErrorCode::TooLarge => "TOO_LARGE",
65            ErrorCode::AssertionFailed => "ASSERTION_FAILED",
66            ErrorCode::UpstreamError => "UPSTREAM_ERROR",
67            ErrorCode::UnsupportedOption => "UNSUPPORTED_OPTION",
68            ErrorCode::InvalidRequest => "INVALID_REQUEST",
69            ErrorCode::Unauthorized => "UNAUTHORIZED",
70            ErrorCode::NotFound => "NOT_FOUND",
71            ErrorCode::Internal => "INTERNAL_ERROR",
72            ErrorCode::DnsFailure => "DNS_FAILURE",
73            ErrorCode::NetworkError => "NETWORK_ERROR",
74            ErrorCode::TlsCertExpired => "TLS_CERT_EXPIRED",
75            ErrorCode::TlsHostnameMismatch => "TLS_HOSTNAME_MISMATCH",
76            ErrorCode::TlsUntrustedRoot => "TLS_UNTRUSTED_ROOT",
77            ErrorCode::TlsError => "TLS_ERROR",
78            ErrorCode::Busy => "BUSY",
79            ErrorCode::Forbidden => "FORBIDDEN",
80            ErrorCode::SessionGone => "SESSION_GONE",
81        }
82    }
83
84    pub fn status(self) -> StatusCode {
85        match self {
86            ErrorCode::RobotsDisallowed | ErrorCode::AccessBarrier | ErrorCode::Forbidden => {
87                StatusCode::FORBIDDEN
88            }
89            ErrorCode::SessionGone => StatusCode::GONE,
90            ErrorCode::RateLimited
91            | ErrorCode::RateLimitExceeded
92            | ErrorCode::ConcurrencyLimitExceeded
93            | ErrorCode::QuotaExceeded => StatusCode::TOO_MANY_REQUESTS,
94            ErrorCode::HostCooldown | ErrorCode::Busy => StatusCode::SERVICE_UNAVAILABLE,
95            ErrorCode::Timeout => StatusCode::REQUEST_TIMEOUT,
96            ErrorCode::SsrfBlocked | ErrorCode::UnsupportedOption | ErrorCode::InvalidRequest => {
97                StatusCode::BAD_REQUEST
98            }
99            ErrorCode::TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
100            // [协议] Jina:断言失败回 422
101            ErrorCode::AssertionFailed => StatusCode::UNPROCESSABLE_ENTITY,
102            ErrorCode::UpstreamError | ErrorCode::DnsFailure | ErrorCode::NetworkError => {
103                StatusCode::BAD_GATEWAY
104            }
105            ErrorCode::TlsCertExpired
106            | ErrorCode::TlsHostnameMismatch
107            | ErrorCode::TlsUntrustedRoot
108            | ErrorCode::TlsError => StatusCode::BAD_GATEWAY,
109            ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED,
110            ErrorCode::NotFound => StatusCode::NOT_FOUND,
111            ErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
112        }
113    }
114}
115
116#[derive(Debug, Clone, PartialEq)]
117pub struct ApiError {
118    pub code: ErrorCode,
119    pub message: String,
120    /// 放进响应的 `details`,比如拦截页的厂商和置信度。
121    pub details: Option<Value>,
122    /// 429 时带上 `Retry-After`(秒)。
123    pub retry_after: Option<u64>,
124}
125
126impl ApiError {
127    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
128        Self {
129            code,
130            message: message.into(),
131            details: None,
132            retry_after: None,
133        }
134    }
135
136    pub fn with_details(mut self, details: Value) -> Self {
137        self.details = Some(details);
138        self
139    }
140
141    pub fn with_retry_after(mut self, secs: u64) -> Self {
142        self.retry_after = Some(secs);
143        self
144    }
145
146    pub fn unsupported(what: impl Into<String>) -> Self {
147        Self::new(ErrorCode::UnsupportedOption, what)
148    }
149
150    pub fn invalid(what: impl Into<String>) -> Self {
151        Self::new(ErrorCode::InvalidRequest, what)
152    }
153}
154
155impl std::fmt::Display for ApiError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        write!(f, "{}: {}", self.code.as_str(), self.message)
158    }
159}
160
161impl std::error::Error for ApiError {}
162
163impl IntoResponse for ApiError {
164    fn into_response(self) -> Response {
165        let mut body = json!({
166            "success": false,
167            "code": self.code.as_str(),
168            "error": self.message,
169        });
170        if let Some(d) = self.details {
171            body["details"] = d;
172        }
173        let mut resp = (self.code.status(), Json(body)).into_response();
174        // 给监控中间件按错误码计数
175        resp.extensions_mut().insert(self.code);
176        if let Some(secs) = self.retry_after
177            && let Ok(v) = secs.to_string().parse()
178        {
179            resp.headers_mut().insert("retry-after", v);
180        }
181        resp
182    }
183}