1use reqwest::StatusCode;
2use serde::de::DeserializeOwned;
3use thiserror::Error;
4
5const BODY_PREVIEW_LIMIT: usize = 512;
6
7#[derive(Debug, Error)]
9pub enum CbrError {
10 #[error("transport error: {0}")]
12 Transport(reqwest::Error),
13 #[error("client build error: {0}")]
15 Build(reqwest::Error),
16 #[error("api returned status {status} (body {body_size} bytes): {body_preview}")]
18 Status {
19 status: StatusCode,
20 body_preview: String,
21 body_size: usize,
22 },
23 #[error(
25 "failed to deserialize response (body {body_size} bytes): {source}; preview: {body_preview}"
26 )]
27 Deserialize {
28 source: serde_json::Error,
29 body_preview: String,
30 body_size: usize,
31 },
32 #[error("api returned legacy error payload ({payload_size} bytes): {payload_preview}")]
34 LegacyErrorResponse {
35 payload_preview: String,
36 payload_size: usize,
37 },
38}
39
40impl CbrError {
41 pub(crate) fn transport(source: reqwest::Error) -> Self {
42 Self::Transport(source)
43 }
44
45 pub(crate) fn build(source: reqwest::Error) -> Self {
46 Self::Build(source)
47 }
48
49 pub(crate) fn status(status: StatusCode, body: &[u8]) -> Self {
50 let (body_preview, body_size) = summarize_body(body);
51 Self::Status {
52 status,
53 body_preview,
54 body_size,
55 }
56 }
57
58 pub(crate) fn deserialize(source: serde_json::Error, body: &[u8]) -> Self {
59 let (body_preview, body_size) = summarize_body(body);
60 Self::Deserialize {
61 source,
62 body_preview,
63 body_size,
64 }
65 }
66
67 pub(crate) fn legacy_error_payload(body: &[u8]) -> Self {
68 let (payload_preview, payload_size) = summarize_body(body);
69 Self::LegacyErrorResponse {
70 payload_preview,
71 payload_size,
72 }
73 }
74}
75
76pub(crate) fn parse_json_body<T>(status: StatusCode, body: &[u8]) -> Result<T, CbrError>
77where
78 T: DeserializeOwned,
79{
80 if is_legacy_error_payload(body) {
83 return Err(CbrError::legacy_error_payload(body));
84 }
85
86 if !status.is_success() {
87 return Err(CbrError::status(status, body));
88 }
89
90 serde_json::from_slice(body).map_err(|source| CbrError::deserialize(source, body))
91}
92
93fn summarize_body(body: &[u8]) -> (String, usize) {
94 let total_size = body.len();
95 let preview_size = total_size.min(BODY_PREVIEW_LIMIT);
96 let mut preview = String::from_utf8_lossy(&body[..preview_size]).into_owned();
97
98 if total_size > BODY_PREVIEW_LIMIT {
99 preview.push_str("...<truncated>");
100 }
101
102 (preview, total_size)
103}
104
105fn is_legacy_error_payload(body: &[u8]) -> bool {
106 let Ok(text) = std::str::from_utf8(body) else {
107 return false;
108 };
109 let trimmed = text.trim();
110 let Some(inner) = trimmed
111 .strip_prefix('{')
112 .and_then(|value| value.strip_suffix('}'))
113 else {
114 return false;
115 };
116
117 let mut parts = inner.trim().split(':');
118 let Some(raw_key) = parts.next() else {
119 return false;
120 };
121 let Some(raw_value) = parts.next() else {
122 return false;
123 };
124 if parts.next().is_some() {
125 return false;
126 }
127
128 let key = raw_key.trim().trim_matches(|c| c == '"' || c == '\'');
129 let value = raw_value
130 .trim()
131 .trim_end_matches(',')
132 .trim()
133 .trim_matches(|c| c == '"' || c == '\'');
134
135 key.eq_ignore_ascii_case("error") && value.eq_ignore_ascii_case("true")
136}