Skip to main content

codoseo_web/agent/
error.rs

1//! What can go wrong in the agent API, with the status, code and message REST answers with
2//! (`{"error":{"code":"...","message":"..."}}`). The MCP server shows `message()` as a tool error.
3
4use axum::Json;
5use axum::http::{HeaderValue, StatusCode, header};
6use axum::response::{IntoResponse, Response};
7use serde_json::json;
8
9/// Said for an unknown site id and for another account's site alike, so the two can't be told
10/// apart.
11pub const SITE_NOT_FOUND: &str = "No such site. List your sites to see their ids.";
12
13#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14pub enum AgentError {
15    /// No key, a malformed key, an unknown key or a revoked one.
16    #[error("{0}")]
17    Unauthorized(String),
18    #[error("{0}")]
19    NotFound(String),
20    #[error("{0}")]
21    BadRequest(String),
22    /// The path exists but not for this HTTP method (REST only).
23    #[error("{0}")]
24    MethodNotAllowed(String),
25    /// The site already has a crawl queued or running.
26    #[error("{0}")]
27    CrawlInProgress(String),
28    /// The plan's manual crawl allowance is used up.
29    #[error("{0}")]
30    PlanLimit(String),
31    /// Today's API call allowance is spent. `retry_after_secs` counts to the next 00:00 UTC.
32    #[error("You have used all {limit} API calls for today. The count starts again at 00:00 UTC.")]
33    QuotaExceeded { limit: u32, retry_after_secs: u64 },
34    /// Postgres is unreachable.
35    #[error("CodoSEO can't reach its database right now. Try again in a moment.")]
36    Unavailable,
37    #[error("Something went wrong on our side. Try again in a moment.")]
38    Internal(String),
39}
40
41impl AgentError {
42    pub fn status(&self) -> StatusCode {
43        match self {
44            AgentError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
45            AgentError::NotFound(_) => StatusCode::NOT_FOUND,
46            AgentError::BadRequest(_) => StatusCode::BAD_REQUEST,
47            AgentError::MethodNotAllowed(_) => StatusCode::METHOD_NOT_ALLOWED,
48            AgentError::CrawlInProgress(_) => StatusCode::CONFLICT,
49            AgentError::PlanLimit(_) => StatusCode::FORBIDDEN,
50            AgentError::QuotaExceeded { .. } => StatusCode::TOO_MANY_REQUESTS,
51            AgentError::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
52            AgentError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
53        }
54    }
55
56    /// The stable machine name.
57    pub fn code(&self) -> &'static str {
58        match self {
59            AgentError::Unauthorized(_) => "unauthorized",
60            AgentError::NotFound(_) => "not_found",
61            AgentError::BadRequest(_) | AgentError::MethodNotAllowed(_) => "bad_request",
62            AgentError::CrawlInProgress(_) => "crawl_in_progress",
63            AgentError::PlanLimit(_) => "plan_limit",
64            AgentError::QuotaExceeded { .. } => "quota_exceeded",
65            AgentError::Unavailable => "unavailable",
66            AgentError::Internal(_) => "internal",
67        }
68    }
69
70    pub fn message(&self) -> String {
71        self.to_string()
72    }
73
74    pub fn site_not_found() -> AgentError {
75        AgentError::NotFound(SITE_NOT_FOUND.to_owned())
76    }
77}
78
79impl From<sqlx::Error> for AgentError {
80    fn from(e: sqlx::Error) -> AgentError {
81        if crate::error::is_unavailable(&e) {
82            AgentError::Unavailable
83        } else {
84            AgentError::Internal(e.to_string())
85        }
86    }
87}
88
89/// The JSON error response. It never carries `ErrorInfo`, so the HTML error pages leave it alone.
90impl IntoResponse for AgentError {
91    fn into_response(self) -> Response {
92        if let AgentError::Internal(detail) = &self {
93            tracing::error!(%detail, "api request failed");
94        }
95        let body = json!({ "error": { "code": self.code(), "message": self.message() } });
96        let mut res = (self.status(), Json(body)).into_response();
97        let h = res.headers_mut();
98        h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
99        match &self {
100            AgentError::Unauthorized(_) => {
101                h.insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
102            }
103            AgentError::QuotaExceeded {
104                limit,
105                retry_after_secs,
106            } => {
107                h.insert(header::RETRY_AFTER, HeaderValue::from(*retry_after_secs));
108                h.insert("x-ratelimit-limit", HeaderValue::from(*limit));
109                h.insert("x-ratelimit-remaining", HeaderValue::from(0u32));
110            }
111            AgentError::Unavailable => {
112                h.insert(header::RETRY_AFTER, HeaderValue::from_static("10"));
113            }
114            _ => {}
115        }
116        res
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn a_missing_row_is_our_bug_not_a_missing_site() {
126        let e = AgentError::from(sqlx::Error::RowNotFound);
127        assert_eq!(e.code(), "internal");
128        assert_eq!(e.status(), StatusCode::INTERNAL_SERVER_ERROR);
129        assert!(!e.message().contains("site"));
130    }
131}