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}