Skip to main content

akribes_sdk/
error.rs

1/// Error types for the Akribes SDK.
2
3#[derive(Debug, thiserror::Error)]
4pub enum AkribesError {
5    /// Auth or permission failure (401/403) — retrying will not help.
6    #[error("Fatal error: {message}")]
7    Fatal {
8        message: String,
9        execution_id: Option<String>,
10    },
11    /// Rate-limit or server unavailability (429/500/502/503/504) — safe
12    /// to retry. `retry_after` carries the `Retry-After` header when the
13    /// server sent one as numeric seconds (HTTP-date form ignored,
14    /// matching Python). `status` carries the HTTP status code so callers
15    /// can branch on the specific upstream signal after #1296 split the
16    /// 5xx variants by retry semantics (500/502 short, 503 rate-limit-adjacent,
17    /// 504 long). `None` when the underlying failure has no HTTP status
18    /// (e.g. polling timeout, SSE disconnect).
19    #[error("Transient error: {message}")]
20    Transient {
21        message: String,
22        execution_id: Option<String>,
23        /// Parsed `Retry-After` header in seconds (#1009). `None` when the
24        /// server omitted the header or sent HTTP-date form.
25        retry_after: Option<std::time::Duration>,
26        /// HTTP status code that produced this error. `None` for non-HTTP
27        /// transients (e.g. SSE disconnect, polling deadline). Use
28        /// [`Self::recommended_backoff_ms`] to pick the per-status base
29        /// backoff (#1296).
30        status: Option<u16>,
31    },
32    /// The Akribes script itself failed or was cancelled.
33    #[error("Script error: {message}")]
34    Script {
35        message: String,
36        execution_id: Option<String>,
37    },
38    /// Polling for an execution result exceeded the caller-supplied timeout.
39    #[error("Execution timed out")]
40    Timeout { execution_id: Option<String> },
41    /// Underlying HTTP transport error.
42    #[error(transparent)]
43    Http(#[from] reqwest::Error),
44    /// JSON (de)serialisation error.
45    #[error(transparent)]
46    Json(#[from] serde_json::Error),
47    /// Script schema changed since init() — re-register to continue.
48    #[error("Script \"{script_name}\" schema has changed since init(). Re-register to continue.")]
49    ScriptSchemaChanged { script_name: String },
50    /// Document keys don't match the cached script input schema.
51    #[error("Script \"{script_name}\" input mismatch: missing={missing:?}, extra={extra:?}")]
52    ScriptInputMismatch {
53        script_name: String,
54        missing: Vec<String>,
55        extra: Vec<String>,
56    },
57    /// A project-scoped operation was called on a client built without
58    /// `project_id`. Use `AkribesClient::new(...)` or set `.project_id(...)`
59    /// on the builder.
60    #[error("project_id is required for this operation but was not set on the client")]
61    MissingProjectId,
62    /// HTTP error with a non-success status code.
63    #[error("HTTP {status}: {message}")]
64    HttpStatus { status: u16, message: String },
65    /// HTTP 409 with `error_type=suite_already_exists`. Carries the
66    /// existing row id so callers can redirect the operator.
67    #[error("Already exists: {message} (existing id {existing_id})")]
68    AlreadyExists { message: String, existing_id: i64 },
69    /// Any other client-side error.
70    #[error("{0}")]
71    Other(String),
72}
73
74pub type Result<T> = std::result::Result<T, AkribesError>;
75
76/// One entry in the server's `input_validation_failed` 400 body (#1017).
77/// Mirrors TS `InputValidationErrorEntry` and Python `InputValidationEntry`.
78#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
79pub struct InputValidationEntry {
80    /// Dotted / bracketed path to the offending field, e.g. `"payload.b"`,
81    /// `"items[2].qty"`.
82    pub input: String,
83    /// One of: `"missing" | "wrong_type" | "unknown_field" |
84    /// "unknown_input" | "disallowed_type"`.
85    pub code: String,
86    #[serde(default)]
87    pub expected: Option<String>,
88    #[serde(default)]
89    pub got: Option<String>,
90}
91
92#[derive(serde::Deserialize)]
93struct InputValidationBody {
94    error: String,
95    errors: Vec<InputValidationEntry>,
96}
97
98/// Parse a 400 `input_validation_failed` body off an [`AkribesError::HttpStatus`].
99/// Returns `None` when the error is something else or the body doesn't match.
100///
101/// Mirrors TS `tryParseInputValidationErrors` (#1017). Form-style UIs use
102/// this to map per-field errors back to inputs without regex-matching the
103/// text message.
104pub fn parse_input_validation_errors(err: &AkribesError) -> Option<Vec<InputValidationEntry>> {
105    let body = match err {
106        AkribesError::HttpStatus {
107            status: 400,
108            message,
109        } => message,
110        _ => return None,
111    };
112    // The `message` field on HttpStatus is the raw body when it's not empty
113    // (see `client::send`). If it's an `HTTP 400 Bad Request`-style prefix,
114    // it won't parse as JSON anyway, so the from_str call returns None.
115    // Try the body as JSON first; then strip any `HTTP 400: ` prefix.
116    let json = serde_json::from_str::<InputValidationBody>(body)
117        .ok()
118        .or_else(|| {
119            body.strip_prefix("HTTP 400: ")
120                .and_then(|rest| serde_json::from_str::<InputValidationBody>(rest).ok())
121        })?;
122    if json.error != "input_validation_failed" {
123        return None;
124    }
125    Some(json.errors)
126}
127
128impl AkribesError {
129    /// Method form of [`parse_input_validation_errors`].
130    pub fn parse_input_validation_errors(&self) -> Option<Vec<InputValidationEntry>> {
131        parse_input_validation_errors(self)
132    }
133
134    /// Recommended base backoff (in milliseconds) for a transient HTTP
135    /// status (#1296). Mirrors `ErrorKind::base_backoff_ms` on the core
136    /// side and `recommendedBackoffMs` in the TS SDK, so retry cadences
137    /// agree across the stack:
138    ///
139    /// | Status | Base (ms) | Rationale                            |
140    /// |--------|-----------|--------------------------------------|
141    /// | 429    | 2000      | Rate-limit; honour Retry-After       |
142    /// | 500    | 1000      | Maybe-transient origin error         |
143    /// | 502    | 1000      | Edge fronted failing origin          |
144    /// | 503    | 2000      | Rate-limit-adjacent capacity issue   |
145    /// | 504    | 4000      | Slow upstream — longer base          |
146    ///
147    /// Returns `None` for any status the SDK doesn't classify as
148    /// retriable (or for non-HTTP transients).
149    pub fn recommended_backoff_ms(status: u16) -> Option<u64> {
150        Some(match status {
151            429 => 2_000,
152            500 => 1_000,
153            502 => 1_000,
154            503 => 2_000,
155            504 => 4_000,
156            _ => return None,
157        })
158    }
159
160    /// Return the HTTP status if this error is a transient with a known
161    /// status code (#1296). Returns `None` for non-Transient variants or
162    /// for transients without a status (e.g. SSE disconnect, polling
163    /// timeout).
164    pub fn transient_status(&self) -> Option<u16> {
165        match self {
166            AkribesError::Transient { status, .. } => *status,
167            _ => None,
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::{AkribesError, parse_input_validation_errors};
175
176    #[test]
177    fn already_exists_renders_message_with_id() {
178        let err = AkribesError::AlreadyExists {
179            message: "Script 'foo' already has a suite".to_string(),
180            existing_id: 42,
181        };
182        let s = format!("{err}");
183        assert!(s.contains("foo"));
184        assert!(s.contains("42"));
185    }
186
187    #[test]
188    fn parse_input_validation_errors_decodes_per_field_codes() {
189        let body = r#"{"error":"input_validation_failed","errors":[{"input":"x","code":"missing","expected":"int"},{"input":"y","code":"wrong_type","got":"str"}]}"#;
190        let err = AkribesError::HttpStatus {
191            status: 400,
192            message: body.to_string(),
193        };
194        let parsed = parse_input_validation_errors(&err).expect("parses");
195        assert_eq!(parsed.len(), 2);
196        assert_eq!(parsed[0].input, "x");
197        assert_eq!(parsed[0].code, "missing");
198        assert_eq!(parsed[0].expected.as_deref(), Some("int"));
199        assert_eq!(parsed[1].input, "y");
200        assert_eq!(parsed[1].code, "wrong_type");
201        assert_eq!(parsed[1].got.as_deref(), Some("str"));
202    }
203
204    #[test]
205    fn parse_input_validation_errors_returns_none_on_unrelated_400() {
206        let err = AkribesError::HttpStatus {
207            status: 400,
208            message: r#"{"error":"something_else"}"#.to_string(),
209        };
210        assert!(parse_input_validation_errors(&err).is_none());
211    }
212
213    #[test]
214    fn parse_input_validation_errors_method_form() {
215        let err = AkribesError::HttpStatus {
216            status: 400,
217            message:
218                r#"{"error":"input_validation_failed","errors":[{"input":"a","code":"missing"}]}"#
219                    .to_string(),
220        };
221        assert!(err.parse_input_validation_errors().is_some());
222    }
223}