cloacina_client/error.rs
1/*
2 * Copyright 2025-2026 Colliery Software
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Client error model — generalized from `cloacinactl`'s `CliError`
18//! (T-0646). The CLI maps these back onto its ADR-0003 exit codes.
19
20use serde_json::Value;
21
22/// Errors from the cloacina-server client.
23#[derive(Debug, thiserror::Error)]
24pub enum ClientError {
25 /// Transport-level failure: connect, TLS, timeout, DNS.
26 #[error("network: {0}")]
27 Transport(String),
28
29 /// 401 / 403 — missing, invalid, or under-privileged API key.
30 #[error("authentication: {0}")]
31 Auth(String),
32
33 /// 404 — the addressed resource does not exist.
34 #[error("not found: {0}")]
35 NotFound(String),
36
37 /// 400 / 422 — the server rejected the request as malformed.
38 #[error("invalid request: {0}")]
39 InvalidRequest(String),
40
41 /// Any other non-2xx — business-logic rejection or server fault.
42 /// `body` is the raw response JSON (canonically `{error, code}`).
43 #[error("server rejected (HTTP {status}): {}", extract_message(.body))]
44 Server { status: u16, body: Value },
45
46 /// Configuration problems (profile resolution, key schemes).
47 #[error("configuration: {0}")]
48 Config(String),
49
50 /// WebSocket / delivery-protocol failures.
51 #[error("websocket: {0}")]
52 Ws(String),
53
54 /// The server rejected our delivery protocol version (close 4426).
55 /// Reconnecting cannot help — upgrade the client.
56 #[error("server does not speak delivery protocol v{client_version} (close 4426)")]
57 ProtocolVersion { client_version: u32 },
58}
59
60impl ClientError {
61 pub(crate) fn from_reqwest(err: reqwest::Error) -> Self {
62 ClientError::Transport(err.to_string())
63 }
64
65 /// Map an HTTP status + canonical `{error, code}` body to a variant.
66 pub fn from_status(status: u16, body: Value) -> Self {
67 match status {
68 401 | 403 => ClientError::Auth(extract_message(&body)),
69 404 => ClientError::NotFound(extract_message(&body)),
70 400 | 422 => ClientError::InvalidRequest(extract_message(&body)),
71 _ => ClientError::Server { status, body },
72 }
73 }
74
75 /// Machine-readable `code` from the canonical error body, when present.
76 pub fn code(&self) -> Option<&str> {
77 match self {
78 ClientError::Server { body, .. } => body.get("code").and_then(|c| c.as_str()),
79 _ => None,
80 }
81 }
82}
83
84/// CLOACI-T-0595 / API-06: the canonical `ApiError` envelope is
85/// `{ code, error }`; `error` carries the human-readable message. A body
86/// without a string `error` renders raw so the unexpected shape is visible.
87pub(crate) fn extract_message(body: &Value) -> String {
88 body.get("error")
89 .and_then(|m| m.as_str())
90 .map(String::from)
91 .unwrap_or_else(|| body.to_string())
92}