Skip to main content

forge_jobs_api/
error.rs

1//! Handler errors that map cleanly to both HTTP and Tauri IPC.
2//!
3//! Same tagged-enum shape the Tauri plugin already uses on the
4//! frontend side, so callers branch on `kind` (`validation` /
5//! `not_found` / `rate_limited` / `internal` / `storage`) instead of
6//! parsing strings. Axum's `IntoResponse` impl picks the status code.
7
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use forge_jobs::StorageError;
11use serde::Serialize;
12
13#[derive(Debug, Serialize)]
14#[serde(tag = "kind", rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum Error {
17    /// Operator-supplied input was wrong. 400.
18    Validation { field: String, msg: String },
19    /// Resource not found. 404.
20    NotFound { msg: String },
21    /// Conflict (dedupe, concurrent modification, busy lock). 409.
22    Conflict { msg: String },
23    /// Rate-limited upstream — operator should back off. 429.
24    RateLimited { retry_after_secs: u32 },
25    /// Anything else from the storage layer. 500.
26    Storage { msg: String },
27    /// Catch-all bug / panic-equivalent. 500.
28    Internal { msg: String },
29}
30
31impl Error {
32    #[must_use]
33    pub fn validation(field: impl Into<String>, msg: impl Into<String>) -> Self {
34        Self::Validation {
35            field: field.into(),
36            msg: msg.into(),
37        }
38    }
39
40    #[must_use]
41    pub fn not_found(msg: impl Into<String>) -> Self {
42        Self::NotFound { msg: msg.into() }
43    }
44
45    #[must_use]
46    pub fn internal(msg: impl Into<String>) -> Self {
47        Self::Internal { msg: msg.into() }
48    }
49}
50
51impl From<StorageError> for Error {
52    fn from(e: StorageError) -> Self {
53        match e {
54            StorageError::NotFound(msg) => Self::NotFound { msg },
55            StorageError::InvalidInput(msg) => Self::Validation {
56                field: "input".into(),
57                msg,
58            },
59            StorageError::Conflict(msg) => Self::Conflict { msg },
60            // Transient lock / pool-timeout: a retryable 409, not a 500.
61            // A scraper/HPA seeing 500 would treat a momentary busy
62            // writer as a hard failure.
63            other if other.is_transient_conflict() => Self::Conflict {
64                msg: other.to_string(),
65            },
66            // M5: don't echo raw backend error text (driver internals,
67            // connection detail, SQL fragments, file paths) to the caller
68            // in a 500 body. Keep the full string in the server log; hand
69            // the client a generic message.
70            other => {
71                tracing::error!(error = %other, "storage backend error (500)");
72                Self::Storage {
73                    msg: "storage backend error".to_owned(),
74                }
75            }
76        }
77    }
78}
79
80impl From<serde_json::Error> for Error {
81    fn from(e: serde_json::Error) -> Self {
82        Self::Validation {
83            field: "json".into(),
84            msg: e.to_string(),
85        }
86    }
87}
88
89impl std::fmt::Display for Error {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::Validation { field, msg } => write!(f, "validation({field}): {msg}"),
93            Self::NotFound { msg } => write!(f, "not_found: {msg}"),
94            Self::Conflict { msg } => write!(f, "conflict: {msg}"),
95            Self::RateLimited { retry_after_secs } => {
96                write!(f, "rate_limited (retry in {retry_after_secs}s)")
97            }
98            Self::Storage { msg } => write!(f, "storage: {msg}"),
99            Self::Internal { msg } => write!(f, "internal: {msg}"),
100        }
101    }
102}
103
104impl std::error::Error for Error {}
105
106impl IntoResponse for Error {
107    fn into_response(self) -> Response {
108        let status = match &self {
109            Self::Validation { .. } => StatusCode::BAD_REQUEST,
110            Self::NotFound { .. } => StatusCode::NOT_FOUND,
111            Self::Conflict { .. } => StatusCode::CONFLICT,
112            Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
113            Self::Storage { .. } | Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
114        };
115        let body = axum::Json(&self);
116        (status, body).into_response()
117    }
118}