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}