Skip to main content

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}