Skip to main content

writ_client/
error.rs

1//! The three-kind error model shared by every Writ agent SDK (DESIGN.md §5).
2
3use serde_json::Value;
4
5/// Convenience alias used across the crate.
6pub type Result<T> = std::result::Result<T, WritError>;
7
8/// Maximum length of a plain-text error body promoted into `message`.
9const MESSAGE_CAP: usize = 500;
10
11/// Every failure surfaced by this SDK.
12///
13/// - [`WritError::Api`] — the daemon answered with a non-2xx HTTP status.
14/// - [`WritError::Connection`] — the daemon could not be reached / the request
15///   or stream timed out / the response body could not be decoded.
16/// - [`WritError::Discovery`] — no live daemon could be found at construction.
17///
18/// The tiered Writ Cloud surface ([`crate::CloudClient`]) adds three more
19/// non-2xx shapes on top of `Api`: [`WritError::RateLimited`] (429),
20/// [`WritError::ApiKeyRequired`] (402 `api_key_required`, or a keyless
21/// whole-site crawl refused before any network call), and
22/// [`WritError::InsufficientCredits`] (any other 402).
23#[derive(Debug, thiserror::Error)]
24pub enum WritError {
25    /// Non-2xx HTTP response from the daemon.
26    #[error("writ api error {status} [{code}]: {message}")]
27    Api {
28        /// HTTP status code.
29        status: u16,
30        /// Stable machine code: the daemon's JSON `code` field when present,
31        /// otherwise derived from the status
32        /// (`400→bad_request, 401→unauthorized, 403→forbidden, 404→not_found,
33        /// 409→conflict, 422→unprocessable, 429→rate_limited, 5xx→internal`).
34        code: String,
35        /// Human message: JSON `error` → `detail` → `message` → raw text
36        /// (truncated to ~500 chars) → HTTP status text.
37        message: String,
38        /// The parsed JSON body, or the raw text as a JSON string.
39        body: Value,
40    },
41    /// The keyless daily allowance (requests/day or pages/day, per device or IP)
42    /// is exhausted — Writ Cloud answered `429`. `reset_at` is when the allowance
43    /// refills; add an API key for a full metered quota.
44    #[error("writ rate limited [{code}]: {message}")]
45    RateLimited {
46        /// HTTP status (always 429).
47        status: u16,
48        /// Stable machine code (body `detail.code`, else `rate_limited`).
49        code: String,
50        /// Human message (body `detail.message`).
51        message: String,
52        /// The parsed JSON body, or the raw text as a JSON string.
53        body: Value,
54        /// ISO timestamp when the keyless daily allowance resets, if reported.
55        reset_at: Option<String>,
56        /// Keyless requests left today, if reported.
57        requests_remaining: Option<i64>,
58        /// Keyless pages left today, if reported.
59        pages_remaining: Option<i64>,
60    },
61    /// A whole-site crawl was requested on the keyless cloud tier (no API key).
62    /// Crawl is metered and always needs a credential — set an API key (builder
63    /// `api_key` or `WRIT_API_KEY`). Keyless access covers `scrape` + `map`.
64    /// Raised by [`crate::CloudClient::crawl`]/[`crate::CloudClient::crawl_status`]
65    /// **before any network call**, or from a `402 api_key_required` response.
66    #[error("writ api key required [{code}]: {message}")]
67    ApiKeyRequired {
68        /// HTTP status (402).
69        status: u16,
70        /// Stable machine code (`api_key_required`).
71        code: String,
72        /// Human message.
73        message: String,
74        /// The parsed JSON body, or `null` when refused client-side.
75        body: Value,
76    },
77    /// The tenant's crawl-page allotment is spent and the wallet can't cover the
78    /// call — Writ Cloud answered `402` (any code other than `api_key_required`).
79    #[error("writ insufficient credits [{code}]: {message}")]
80    InsufficientCredits {
81        /// HTTP status (402).
82        status: u16,
83        /// Stable machine code.
84        code: String,
85        /// Human message.
86        message: String,
87        /// The parsed JSON body, or the raw text as a JSON string.
88        body: Value,
89    },
90    /// A `run(..., wait)` call whose SERVER-side budget expired (HTTP 504).
91    ///
92    /// NOT a failure of the run: it is still executing and `run_id` still addresses it —
93    /// poll `runs().get(run_id)`, stream `runs().events(run_id)`, or `runs().cancel(run_id)`.
94    /// Retrying the call would start a SECOND run, which is exactly what carrying the id
95    /// here is meant to prevent.
96    #[error(
97        "writ: run {run_id} did not finish within the requested budget and is STILL RUNNING — \
98         observe it with runs().events({run_id}) or runs().get({run_id}); \
99         do not retry, that would start a second run"
100    )]
101    RunTimeout {
102        /// The still-running run.
103        run_id: i64,
104        /// Where to read the outcome once terminal, as reported by the daemon.
105        status_url: Option<String>,
106        /// Live SSE stream for this run.
107        events_url: Option<String>,
108    },
109    /// Network failure / timeout / undecodable response (daemon down mid-session).
110    #[error("writ connection error: {0}")]
111    Connection(String),
112    /// No live daemon found at construction time (see DESIGN.md §4).
113    #[error("writ discovery error: {0}")]
114    Discovery(String),
115}
116
117impl From<reqwest::Error> for WritError {
118    fn from(err: reqwest::Error) -> Self {
119        WritError::Connection(err.to_string())
120    }
121}
122
123/// Derive the stable machine code from an HTTP status (plain-text / code-less bodies).
124pub(crate) fn code_for_status(status: u16) -> String {
125    match status {
126        400 => "bad_request".to_string(),
127        401 => "unauthorized".to_string(),
128        403 => "forbidden".to_string(),
129        404 => "not_found".to_string(),
130        409 => "conflict".to_string(),
131        422 => "unprocessable".to_string(),
132        429 => "rate_limited".to_string(),
133        s if s >= 500 => "internal".to_string(),
134        s => format!("http_{s}"),
135    }
136}
137
138/// Truncate a plain-text body for the `message` field (~500 chars, char-safe).
139fn truncate_message(text: &str) -> String {
140    if text.chars().count() <= MESSAGE_CAP {
141        return text.to_string();
142    }
143    let cut: String = text.chars().take(MESSAGE_CAP).collect();
144    format!("{cut}…")
145}
146
147/// Build a [`WritError::Api`] from a non-2xx response body per DESIGN.md §5:
148/// parse JSON if possible (`code` from the body, `message` from
149/// `error` → `detail` → `message`); otherwise treat the body as plain text.
150pub(crate) fn api_error(status: u16, status_text: &str, text: &str) -> WritError {
151    let derived = code_for_status(status);
152    match serde_json::from_str::<Value>(text) {
153        Ok(body) if body.is_object() => {
154            let code = body
155                .get("code")
156                .and_then(Value::as_str)
157                .map(str::to_string)
158                .unwrap_or(derived);
159            let message = ["error", "detail", "message"]
160                .iter()
161                .find_map(|k| body.get(*k).and_then(Value::as_str))
162                .map(str::to_string)
163                .unwrap_or_else(|| status_text.to_string());
164            WritError::Api {
165                status,
166                code,
167                message,
168                body,
169            }
170        }
171        Ok(body) => {
172            // Valid JSON but not an object (bare string/number/array).
173            let message = if text.trim().is_empty() {
174                status_text.to_string()
175            } else {
176                truncate_message(text)
177            };
178            WritError::Api {
179                status,
180                code: derived,
181                message,
182                body,
183            }
184        }
185        Err(_) => {
186            let message = if text.trim().is_empty() {
187                status_text.to_string()
188            } else {
189                truncate_message(text)
190            };
191            WritError::Api {
192                status,
193                code: derived,
194                message,
195                body: Value::String(text.to_string()),
196            }
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn json_domain_error_maps_code_and_message() {
207        let err = api_error(
208            404,
209            "Not Found",
210            r#"{"error":"not found: workflow 999999","code":"not_found"}"#,
211        );
212        match err {
213            WritError::Api {
214                status,
215                code,
216                message,
217                body,
218            } => {
219                assert_eq!(status, 404);
220                assert_eq!(code, "not_found");
221                assert_eq!(message, "not found: workflow 999999");
222                assert_eq!(body["code"], "not_found");
223            }
224            other => panic!("expected Api, got {other:?}"),
225        }
226    }
227
228    #[test]
229    fn plain_text_body_derives_code_from_status() {
230        let text = "Failed to deserialize the JSON body into the target type: missing field `url`";
231        let err = api_error(422, "Unprocessable Entity", text);
232        match err {
233            WritError::Api {
234                status,
235                code,
236                message,
237                body,
238            } => {
239                assert_eq!(status, 422);
240                assert_eq!(code, "unprocessable");
241                assert_eq!(message, text);
242                assert_eq!(body, Value::String(text.to_string()));
243            }
244            other => panic!("expected Api, got {other:?}"),
245        }
246    }
247
248    #[test]
249    fn message_resolution_falls_through_error_detail_message() {
250        let err = api_error(400, "Bad Request", r#"{"detail":"nope"}"#);
251        match err {
252            WritError::Api { message, code, .. } => {
253                assert_eq!(message, "nope");
254                assert_eq!(code, "bad_request");
255            }
256            other => panic!("expected Api, got {other:?}"),
257        }
258        let err = api_error(500, "Internal Server Error", r#"{"message":"boom"}"#);
259        match err {
260            WritError::Api { message, code, .. } => {
261                assert_eq!(message, "boom");
262                assert_eq!(code, "internal");
263            }
264            other => panic!("expected Api, got {other:?}"),
265        }
266        // Empty body → status text.
267        let err = api_error(429, "Too Many Requests", "");
268        match err {
269            WritError::Api { message, code, .. } => {
270                assert_eq!(message, "Too Many Requests");
271                assert_eq!(code, "rate_limited");
272            }
273            other => panic!("expected Api, got {other:?}"),
274        }
275    }
276
277    #[test]
278    fn long_plain_text_is_truncated_to_about_500_chars() {
279        let text = "x".repeat(2000);
280        let err = api_error(500, "Internal Server Error", &text);
281        match err {
282            WritError::Api { message, body, .. } => {
283                assert!(message.chars().count() <= MESSAGE_CAP + 1);
284                // The full raw text is preserved in `body`.
285                assert_eq!(body, Value::String(text));
286            }
287            other => panic!("expected Api, got {other:?}"),
288        }
289    }
290}