use serde_json::Value;
pub type Result<T> = std::result::Result<T, WritError>;
const MESSAGE_CAP: usize = 500;
#[derive(Debug, thiserror::Error)]
pub enum WritError {
#[error("writ api error {status} [{code}]: {message}")]
Api {
status: u16,
code: String,
message: String,
body: Value,
},
#[error("writ rate limited [{code}]: {message}")]
RateLimited {
status: u16,
code: String,
message: String,
body: Value,
reset_at: Option<String>,
requests_remaining: Option<i64>,
pages_remaining: Option<i64>,
},
#[error("writ api key required [{code}]: {message}")]
ApiKeyRequired {
status: u16,
code: String,
message: String,
body: Value,
},
#[error("writ insufficient credits [{code}]: {message}")]
InsufficientCredits {
status: u16,
code: String,
message: String,
body: Value,
},
#[error(
"writ: run {run_id} did not finish within the requested budget and is STILL RUNNING — \
observe it with runs().events({run_id}) or runs().get({run_id}); \
do not retry, that would start a second run"
)]
RunTimeout {
run_id: i64,
status_url: Option<String>,
events_url: Option<String>,
},
#[error("writ connection error: {0}")]
Connection(String),
#[error("writ discovery error: {0}")]
Discovery(String),
}
impl From<reqwest::Error> for WritError {
fn from(err: reqwest::Error) -> Self {
WritError::Connection(err.to_string())
}
}
pub(crate) fn code_for_status(status: u16) -> String {
match status {
400 => "bad_request".to_string(),
401 => "unauthorized".to_string(),
403 => "forbidden".to_string(),
404 => "not_found".to_string(),
409 => "conflict".to_string(),
422 => "unprocessable".to_string(),
429 => "rate_limited".to_string(),
s if s >= 500 => "internal".to_string(),
s => format!("http_{s}"),
}
}
fn truncate_message(text: &str) -> String {
if text.chars().count() <= MESSAGE_CAP {
return text.to_string();
}
let cut: String = text.chars().take(MESSAGE_CAP).collect();
format!("{cut}…")
}
pub(crate) fn api_error(status: u16, status_text: &str, text: &str) -> WritError {
let derived = code_for_status(status);
match serde_json::from_str::<Value>(text) {
Ok(body) if body.is_object() => {
let code = body
.get("code")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or(derived);
let message = ["error", "detail", "message"]
.iter()
.find_map(|k| body.get(*k).and_then(Value::as_str))
.map(str::to_string)
.unwrap_or_else(|| status_text.to_string());
WritError::Api {
status,
code,
message,
body,
}
}
Ok(body) => {
let message = if text.trim().is_empty() {
status_text.to_string()
} else {
truncate_message(text)
};
WritError::Api {
status,
code: derived,
message,
body,
}
}
Err(_) => {
let message = if text.trim().is_empty() {
status_text.to_string()
} else {
truncate_message(text)
};
WritError::Api {
status,
code: derived,
message,
body: Value::String(text.to_string()),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_domain_error_maps_code_and_message() {
let err = api_error(
404,
"Not Found",
r#"{"error":"not found: workflow 999999","code":"not_found"}"#,
);
match err {
WritError::Api {
status,
code,
message,
body,
} => {
assert_eq!(status, 404);
assert_eq!(code, "not_found");
assert_eq!(message, "not found: workflow 999999");
assert_eq!(body["code"], "not_found");
}
other => panic!("expected Api, got {other:?}"),
}
}
#[test]
fn plain_text_body_derives_code_from_status() {
let text = "Failed to deserialize the JSON body into the target type: missing field `url`";
let err = api_error(422, "Unprocessable Entity", text);
match err {
WritError::Api {
status,
code,
message,
body,
} => {
assert_eq!(status, 422);
assert_eq!(code, "unprocessable");
assert_eq!(message, text);
assert_eq!(body, Value::String(text.to_string()));
}
other => panic!("expected Api, got {other:?}"),
}
}
#[test]
fn message_resolution_falls_through_error_detail_message() {
let err = api_error(400, "Bad Request", r#"{"detail":"nope"}"#);
match err {
WritError::Api { message, code, .. } => {
assert_eq!(message, "nope");
assert_eq!(code, "bad_request");
}
other => panic!("expected Api, got {other:?}"),
}
let err = api_error(500, "Internal Server Error", r#"{"message":"boom"}"#);
match err {
WritError::Api { message, code, .. } => {
assert_eq!(message, "boom");
assert_eq!(code, "internal");
}
other => panic!("expected Api, got {other:?}"),
}
let err = api_error(429, "Too Many Requests", "");
match err {
WritError::Api { message, code, .. } => {
assert_eq!(message, "Too Many Requests");
assert_eq!(code, "rate_limited");
}
other => panic!("expected Api, got {other:?}"),
}
}
#[test]
fn long_plain_text_is_truncated_to_about_500_chars() {
let text = "x".repeat(2000);
let err = api_error(500, "Internal Server Error", &text);
match err {
WritError::Api { message, body, .. } => {
assert!(message.chars().count() <= MESSAGE_CAP + 1);
assert_eq!(body, Value::String(text));
}
other => panic!("expected Api, got {other:?}"),
}
}
}