Skip to main content

cbr_client/
error.rs

1use reqwest::StatusCode;
2use serde::de::DeserializeOwned;
3use thiserror::Error;
4
5const BODY_PREVIEW_LIMIT: usize = 512;
6
7/// Ошибки клиента API ЦБ РФ.
8#[derive(Debug, Error)]
9pub enum CbrError {
10    /// Ошибка транспорта (сетевые сбои, таймауты, ошибки TLS и т.п.).
11    #[error("transport error: {0}")]
12    Transport(reqwest::Error),
13    /// Ошибка построения HTTP-клиента.
14    #[error("client build error: {0}")]
15    Build(reqwest::Error),
16    /// API вернул HTTP-статус вне диапазона 2xx.
17    #[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    /// Тело ответа не удалось десериализовать в ожидаемую модель.
24    #[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    /// Legacy-ответ сервиса в формате `{Error:true}`.
33    #[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    // Иногда API возвращает невалидный JSON вида `{Error:true}`.
81    // Обрабатываем его отдельно до проверки статуса/десериализации.
82    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}