Skip to main content

moex_client/
moex.rs

1//! Клиент для HTTP-взаимодействия с ISS API и ошибки транспортного уровня.
2
3#[cfg(any(feature = "async", feature = "blocking"))]
4mod client;
5mod constants;
6mod convert;
7pub mod decode;
8mod payload;
9mod wire;
10
11use std::num::NonZeroU32;
12use std::time::{Duration, Instant};
13
14#[cfg(any(feature = "async", feature = "blocking"))]
15use reqwest::{StatusCode, header::HeaderMap};
16use thiserror::Error;
17
18use crate::models::{
19    BoardId, EngineName, IndexId, MarketName, ParseBoardError, ParseCandleBorderError,
20    ParseCandleError, ParseEngineError, ParseEventError, ParseHistoryDatesError,
21    ParseHistoryRecordError, ParseIndexAnalyticsError, ParseIndexError, ParseMarketError,
22    ParseOrderbookError, ParseSecStatError, ParseSecurityBoardError, ParseSecurityError,
23    ParseSecuritySnapshotError, ParseSiteNewsError, ParseTradeError, ParseTurnoverError, SecId,
24};
25
26/// Асинхронный ленивый paginator по страницам `history`.
27#[cfg(all(feature = "async", feature = "history"))]
28pub use client::AsyncHistoryPages;
29/// Блокирующий ленивый paginator по страницам `history`.
30#[cfg(all(feature = "blocking", feature = "history"))]
31pub use client::HistoryPages;
32/// Асинхронные HTTP-клиенты ISS API.
33#[cfg(feature = "async")]
34pub use client::{
35    AsyncCandlesPages, AsyncGlobalSecuritiesPages, AsyncIndexAnalyticsPages,
36    AsyncMarketSecuritiesPages, AsyncMarketTradesPages, AsyncMoexClient, AsyncMoexClientBuilder,
37    AsyncOwnedBoardScope, AsyncOwnedEngineScope, AsyncOwnedIndexScope, AsyncOwnedMarketScope,
38    AsyncOwnedMarketSecurityScope, AsyncOwnedSecurityResourceScope, AsyncOwnedSecurityScope,
39    AsyncRawIssRequestBuilder, AsyncSecStatsPages, AsyncSecuritiesPages, AsyncTradesPages,
40};
41#[cfg(all(feature = "async", feature = "news"))]
42/// Асинхронные paginator-ы новостных endpoint-ов.
43pub use client::{AsyncEventsPages, AsyncSiteNewsPages};
44/// Блокирующие HTTP-клиенты ISS API.
45#[cfg(feature = "blocking")]
46pub use client::{
47    CandlesPages, GlobalSecuritiesPages, IndexAnalyticsPages, MarketSecuritiesPages,
48    MarketTradesPages, OwnedBoardScope, OwnedEngineScope, OwnedIndexScope, OwnedMarketScope,
49    OwnedMarketSecurityScope, OwnedSecurityResourceScope, OwnedSecurityScope, RawIssRequestBuilder,
50    SecStatsPages, SecuritiesPages, TradesPages,
51};
52#[cfg(all(feature = "blocking", feature = "news"))]
53/// Блокирующие paginator-ы новостных endpoint-ов.
54pub use client::{EventsPages, SiteNewsPages};
55
56/// Явное имя блокирующего ISS-клиента.
57#[cfg(feature = "blocking")]
58pub type BlockingMoexClient = client::BlockingMoexClient;
59/// Явное имя builder-а блокирующего ISS-клиента.
60#[cfg(feature = "blocking")]
61pub type BlockingMoexClientBuilder = client::BlockingMoexClientBuilder;
62
63/// Типизированный идентификатор ISS endpoint-а для raw-запросов.
64///
65/// Позволяет строить raw-запросы без ручной сборки path-строк.
66#[derive(Debug, Clone, Copy)]
67pub enum IssEndpoint<'a> {
68    /// `/iss/statistics/engines/stock/markets/index/analytics.json` (`indices`).
69    Indexes,
70    /// `/iss/statistics/engines/stock/markets/index/analytics/{indexid}.json` (`analytics`).
71    IndexAnalytics { indexid: &'a IndexId },
72    /// `/iss/turnovers.json` (`turnovers`).
73    Turnovers,
74    /// `/iss/engines/{engine}/turnovers.json` (`turnovers`).
75    EngineTurnovers { engine: &'a EngineName },
76    /// `/iss/engines.json` (`engines`).
77    Engines,
78    /// `/iss/engines/{engine}/markets.json` (`markets`).
79    Markets { engine: &'a EngineName },
80    /// `/iss/engines/{engine}/markets/{market}/boards.json` (`boards`).
81    Boards {
82        engine: &'a EngineName,
83        market: &'a MarketName,
84    },
85    /// `/iss/securities.json` (`securities`).
86    GlobalSecurities,
87    /// `/iss/securities/{secid}.json` (`securities`).
88    SecurityInfo { security: &'a SecId },
89    /// `/iss/securities/{secid}.json` (`boards`).
90    SecurityBoards { security: &'a SecId },
91    /// `/iss/engines/{engine}/markets/{market}/securities.json` (`securities`).
92    MarketSecurities {
93        engine: &'a EngineName,
94        market: &'a MarketName,
95    },
96    /// `/iss/engines/{engine}/markets/{market}/securities/{secid}.json` (`securities`).
97    MarketSecurityInfo {
98        engine: &'a EngineName,
99        market: &'a MarketName,
100        security: &'a SecId,
101    },
102    /// `/iss/engines/{engine}/markets/{market}/orderbook.json` (`orderbook`).
103    MarketOrderbook {
104        engine: &'a EngineName,
105        market: &'a MarketName,
106    },
107    /// `/iss/engines/{engine}/markets/{market}/trades.json` (`trades`).
108    MarketTrades {
109        engine: &'a EngineName,
110        market: &'a MarketName,
111    },
112    /// `/iss/engines/{engine}/markets/{market}/secstats.json` (`secstats`).
113    SecStats {
114        engine: &'a EngineName,
115        market: &'a MarketName,
116    },
117    /// `/iss/engines/{engine}/markets/{market}/boards/{board}/securities.json` (`securities`).
118    Securities {
119        engine: &'a EngineName,
120        market: &'a MarketName,
121        board: &'a BoardId,
122    },
123    /// `/iss/engines/{engine}/markets/{market}/boards/{board}/securities.json` (`securities,marketdata`).
124    BoardSecuritySnapshots {
125        engine: &'a EngineName,
126        market: &'a MarketName,
127        board: &'a BoardId,
128    },
129    /// `/iss/engines/{engine}/markets/{market}/boards/{board}/securities/{secid}/orderbook.json` (`orderbook`).
130    Orderbook {
131        engine: &'a EngineName,
132        market: &'a MarketName,
133        board: &'a BoardId,
134        security: &'a SecId,
135    },
136    /// `/iss/engines/{engine}/markets/{market}/boards/{board}/securities/{secid}/trades.json` (`trades`).
137    Trades {
138        engine: &'a EngineName,
139        market: &'a MarketName,
140        board: &'a BoardId,
141        security: &'a SecId,
142    },
143    /// `/iss/engines/{engine}/markets/{market}/boards/{board}/securities/{secid}/candles.json` (`candles`).
144    Candles {
145        engine: &'a EngineName,
146        market: &'a MarketName,
147        board: &'a BoardId,
148        security: &'a SecId,
149    },
150    /// `/iss/engines/{engine}/markets/{market}/securities/{secid}/candleborders.json` (`borders`).
151    CandleBorders {
152        engine: &'a EngineName,
153        market: &'a MarketName,
154        security: &'a SecId,
155    },
156    /// `/iss/sitenews.json` (`sitenews`).
157    #[cfg(feature = "news")]
158    SiteNews,
159    /// `/iss/events.json` (`events`).
160    #[cfg(feature = "news")]
161    Events,
162    /// `/iss/history/engines/{engine}/markets/{market}/boards/{board}/securities/{secid}/dates.json` (`dates`).
163    #[cfg(feature = "history")]
164    HistoryDates {
165        engine: &'a EngineName,
166        market: &'a MarketName,
167        board: &'a BoardId,
168        security: &'a SecId,
169    },
170    /// `/iss/history/engines/{engine}/markets/{market}/boards/{board}/securities/{secid}.json` (`history`).
171    #[cfg(feature = "history")]
172    History {
173        engine: &'a EngineName,
174        market: &'a MarketName,
175        board: &'a BoardId,
176        security: &'a SecId,
177    },
178}
179
180impl IssEndpoint<'_> {
181    /// Построить относительный endpoint-path (`*.json`) для raw-запроса.
182    pub fn path(self) -> String {
183        match self {
184            Self::Indexes => constants::INDEXES_ENDPOINT.to_owned(),
185            Self::IndexAnalytics { indexid } => constants::index_analytics_endpoint(indexid),
186            Self::Turnovers => constants::TURNOVERS_ENDPOINT.to_owned(),
187            Self::EngineTurnovers { engine } => constants::engine_turnovers_endpoint(engine),
188            Self::Engines => constants::ENGINES_ENDPOINT.to_owned(),
189            Self::Markets { engine } => constants::markets_endpoint(engine),
190            Self::Boards { engine, market } => constants::boards_endpoint(engine, market),
191            Self::GlobalSecurities => constants::GLOBAL_SECURITIES_ENDPOINT.to_owned(),
192            Self::SecurityInfo { security } | Self::SecurityBoards { security } => {
193                constants::security_endpoint(security)
194            }
195            Self::MarketSecurities { engine, market } => {
196                constants::market_securities_endpoint(engine, market)
197            }
198            Self::MarketSecurityInfo {
199                engine,
200                market,
201                security,
202            } => constants::market_security_endpoint(engine, market, security),
203            Self::MarketOrderbook { engine, market } => {
204                constants::market_orderbook_endpoint(engine, market)
205            }
206            Self::MarketTrades { engine, market } => {
207                constants::market_trades_endpoint(engine, market)
208            }
209            Self::SecStats { engine, market } => constants::secstats_endpoint(engine, market),
210            Self::Securities {
211                engine,
212                market,
213                board,
214            }
215            | Self::BoardSecuritySnapshots {
216                engine,
217                market,
218                board,
219            } => constants::securities_endpoint(engine, market, board),
220            Self::Orderbook {
221                engine,
222                market,
223                board,
224                security,
225            } => constants::orderbook_endpoint(engine, market, board, security),
226            Self::Trades {
227                engine,
228                market,
229                board,
230                security,
231            } => constants::trades_endpoint(engine, market, board, security),
232            Self::Candles {
233                engine,
234                market,
235                board,
236                security,
237            } => constants::candles_endpoint(engine, market, board, security),
238            Self::CandleBorders {
239                engine,
240                market,
241                security,
242            } => constants::candleborders_endpoint(engine, market, security),
243            #[cfg(feature = "news")]
244            Self::SiteNews => constants::SITENEWS_ENDPOINT.to_owned(),
245            #[cfg(feature = "news")]
246            Self::Events => constants::EVENTS_ENDPOINT.to_owned(),
247            #[cfg(feature = "history")]
248            Self::HistoryDates {
249                engine,
250                market,
251                board,
252                security,
253            } => constants::history_dates_endpoint(engine, market, board, security),
254            #[cfg(feature = "history")]
255            Self::History {
256                engine,
257                market,
258                board,
259                security,
260            } => constants::history_endpoint(engine, market, board, security),
261        }
262    }
263
264    /// Таблица по умолчанию для `iss.only`, если endpoint описывает единственную цель выборки.
265    pub fn default_table(self) -> Option<&'static str> {
266        match self {
267            Self::Indexes => Some("indices"),
268            Self::IndexAnalytics { .. } => Some("analytics"),
269            Self::Turnovers | Self::EngineTurnovers { .. } => Some("turnovers"),
270            Self::Engines => Some("engines"),
271            Self::Markets { .. } => Some("markets"),
272            Self::Boards { .. } | Self::SecurityBoards { .. } => Some("boards"),
273            Self::GlobalSecurities
274            | Self::SecurityInfo { .. }
275            | Self::MarketSecurities { .. }
276            | Self::MarketSecurityInfo { .. }
277            | Self::Securities { .. } => Some("securities"),
278            Self::BoardSecuritySnapshots { .. } => Some("securities,marketdata"),
279            Self::Orderbook { .. } | Self::MarketOrderbook { .. } => Some("orderbook"),
280            Self::Trades { .. } | Self::MarketTrades { .. } => Some("trades"),
281            Self::Candles { .. } => Some("candles"),
282            Self::CandleBorders { .. } => Some("borders"),
283            Self::SecStats { .. } => Some("secstats"),
284            #[cfg(feature = "news")]
285            Self::SiteNews => Some("sitenews"),
286            #[cfg(feature = "news")]
287            Self::Events => Some("events"),
288            #[cfg(feature = "history")]
289            Self::HistoryDates { .. } => Some("dates"),
290            #[cfg(feature = "history")]
291            Self::History { .. } => Some("history"),
292        }
293    }
294}
295
296/// Политика повторных попыток для операций с [`MoexError`].
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub struct RetryPolicy {
299    max_attempts: NonZeroU32,
300    delay: Duration,
301}
302
303impl RetryPolicy {
304    /// Создать политику повторов с числом попыток и delay по умолчанию.
305    ///
306    /// Значение delay по умолчанию — `400ms`.
307    pub fn new(max_attempts: NonZeroU32) -> Self {
308        Self {
309            max_attempts,
310            delay: Duration::from_millis(400),
311        }
312    }
313
314    /// Установить фиксированную паузу между попытками.
315    pub fn with_delay(mut self, delay: Duration) -> Self {
316        self.delay = delay;
317        self
318    }
319
320    /// Максимальное число попыток (включая первую).
321    pub fn max_attempts(self) -> NonZeroU32 {
322        self.max_attempts
323    }
324
325    /// Пауза между попытками.
326    pub fn delay(self) -> Duration {
327        self.delay
328    }
329}
330
331impl Default for RetryPolicy {
332    fn default() -> Self {
333        Self::new(NonZeroU32::new(3).expect("retry policy default attempts must be non-zero"))
334    }
335}
336
337/// Выполнить blocking-операцию с повтором retryable-ошибок.
338///
339/// Повтор выполняется только для [`MoexError::is_retryable`].
340pub fn with_retry<T, F>(policy: RetryPolicy, mut action: F) -> Result<T, MoexError>
341where
342    F: FnMut() -> Result<T, MoexError>,
343{
344    let mut attempts_left = policy.max_attempts().get();
345    loop {
346        match action() {
347            Ok(value) => return Ok(value),
348            Err(error) if attempts_left > 1 && error.is_retryable() => {
349                attempts_left -= 1;
350                std::thread::sleep(policy.delay());
351            }
352            Err(error) => return Err(error),
353        }
354    }
355}
356
357/// Выполнить async-операцию с повтором retryable-ошибок.
358///
359/// `sleep` задаётся вызывающим кодом, чтобы библиотека не навязывала runtime.
360#[cfg(feature = "async")]
361pub async fn with_retry_async<T, F, Fut, S, SleepFut>(
362    policy: RetryPolicy,
363    mut action: F,
364    mut sleep: S,
365) -> Result<T, MoexError>
366where
367    F: FnMut() -> Fut,
368    Fut: std::future::Future<Output = Result<T, MoexError>>,
369    S: FnMut(Duration) -> SleepFut,
370    SleepFut: std::future::Future<Output = ()>,
371{
372    let mut attempts_left = policy.max_attempts().get();
373    loop {
374        match action().await {
375            Ok(value) => return Ok(value),
376            Err(error) if attempts_left > 1 && error.is_retryable() => {
377                attempts_left -= 1;
378                sleep(policy.delay()).await;
379            }
380            Err(error) => return Err(error),
381        }
382    }
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386/// Ограничение частоты запросов.
387///
388/// Хранит минимальный интервал между последовательными запросами.
389pub struct RateLimit {
390    min_interval: Duration,
391}
392
393impl RateLimit {
394    /// Создать limit из минимального интервала между запросами.
395    pub fn every(min_interval: Duration) -> Self {
396        Self { min_interval }
397    }
398
399    /// Создать limit из числа запросов в секунду.
400    ///
401    /// Интервал округляется вверх до целого числа наносекунд.
402    pub fn per_second(requests_per_second: NonZeroU32) -> Self {
403        let per_second_nanos: u128 = 1_000_000_000;
404        let requests = u128::from(requests_per_second.get());
405        let nanos = per_second_nanos.div_ceil(requests);
406        let nanos = u64::try_from(nanos).unwrap_or(u64::MAX);
407        Self::every(Duration::from_nanos(nanos))
408    }
409
410    /// Минимальный интервал между запросами.
411    pub fn min_interval(self) -> Duration {
412        self.min_interval
413    }
414}
415
416#[derive(Debug, Clone)]
417/// Состояние rate-limit для последовательности запросов.
418pub struct RateLimiter {
419    limit: RateLimit,
420    next_allowed_at: Option<Instant>,
421}
422
423impl RateLimiter {
424    /// Создать новый limiter с заданным ограничением.
425    pub fn new(limit: RateLimit) -> Self {
426        Self {
427            limit,
428            next_allowed_at: None,
429        }
430    }
431
432    /// Текущая конфигурация ограничения.
433    pub fn limit(&self) -> RateLimit {
434        self.limit
435    }
436
437    /// Рассчитать задержку до следующего запроса и зарезервировать слот.
438    pub fn reserve_delay(&mut self) -> Duration {
439        self.reserve_delay_at(Instant::now())
440    }
441
442    fn reserve_delay_at(&mut self, now: Instant) -> Duration {
443        let scheduled_at = match self.next_allowed_at {
444            Some(next_allowed_at) if next_allowed_at > now => next_allowed_at,
445            _ => now,
446        };
447        let delay = scheduled_at.saturating_duration_since(now);
448        self.next_allowed_at = Some(scheduled_at + self.limit.min_interval);
449        delay
450    }
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
454/// Универсальный переключатель ISS-параметров со значениями `on/off`.
455pub enum IssToggle {
456    /// Значение `off`.
457    #[default]
458    Off,
459    /// Значение `on`.
460    On,
461}
462
463impl IssToggle {
464    /// Вернуть wire-значение параметра (`on`/`off`).
465    pub const fn as_query_value(self) -> &'static str {
466        match self {
467            Self::Off => "off",
468            Self::On => "on",
469        }
470    }
471}
472
473impl From<bool> for IssToggle {
474    fn from(value: bool) -> Self {
475        if value { Self::On } else { Self::Off }
476    }
477}
478
479#[derive(Debug, Clone, Default, PartialEq, Eq)]
480/// Системные опции ISS-запроса (`iss.*`) для raw endpoint-ов.
481pub struct IssRequestOptions {
482    metadata: Option<IssToggle>,
483    data: Option<IssToggle>,
484    version: Option<IssToggle>,
485    json: Option<Box<str>>,
486}
487
488impl IssRequestOptions {
489    /// Создать пустой набор опций.
490    pub fn new() -> Self {
491        Self::default()
492    }
493
494    /// Установить `iss.meta`.
495    pub fn metadata(mut self, metadata: IssToggle) -> Self {
496        self.metadata = Some(metadata);
497        self
498    }
499
500    /// Установить `iss.data`.
501    pub fn data(mut self, data: IssToggle) -> Self {
502        self.data = Some(data);
503        self
504    }
505
506    /// Установить `iss.version`.
507    pub fn version(mut self, version: IssToggle) -> Self {
508        self.version = Some(version);
509        self
510    }
511
512    /// Установить `iss.json`.
513    pub fn json(mut self, json: impl Into<String>) -> Self {
514        self.json = Some(json.into().into_boxed_str());
515        self
516    }
517
518    /// Текущее значение `iss.meta`, если задано.
519    pub fn metadata_value(&self) -> Option<IssToggle> {
520        self.metadata
521    }
522
523    /// Текущее значение `iss.data`, если задано.
524    pub fn data_value(&self) -> Option<IssToggle> {
525        self.data
526    }
527
528    /// Текущее значение `iss.version`, если задано.
529    pub fn version_value(&self) -> Option<IssToggle> {
530        self.version
531    }
532
533    /// Текущее значение `iss.json`, если задано.
534    pub fn json_value(&self) -> Option<&str> {
535        self.json.as_deref()
536    }
537}
538
539#[derive(Debug, Clone)]
540/// HTTP-ответ raw ISS-запроса без дополнительной валидации статуса/формата.
541#[cfg(any(feature = "async", feature = "blocking"))]
542pub struct RawIssResponse {
543    status: StatusCode,
544    headers: HeaderMap,
545    body: String,
546}
547
548#[cfg(any(feature = "async", feature = "blocking"))]
549impl RawIssResponse {
550    pub(crate) fn new(status: StatusCode, headers: HeaderMap, body: String) -> Self {
551        Self {
552            status,
553            headers,
554            body,
555        }
556    }
557
558    /// HTTP-статус ответа.
559    pub fn status(&self) -> StatusCode {
560        self.status
561    }
562
563    /// HTTP-заголовки ответа.
564    pub fn headers(&self) -> &HeaderMap {
565        &self.headers
566    }
567
568    /// Полное тело ответа как строка.
569    pub fn body(&self) -> &str {
570        &self.body
571    }
572
573    /// Разобрать ответ на части (`status`, `headers`, `body`).
574    pub fn into_parts(self) -> (StatusCode, HeaderMap, String) {
575        (self.status, self.headers, self.body)
576    }
577}
578
579/// Выполнить blocking-операцию c соблюдением [`RateLimiter`].
580pub fn with_rate_limit<T, F>(limiter: &mut RateLimiter, action: F) -> T
581where
582    F: FnOnce() -> T,
583{
584    let delay = limiter.reserve_delay();
585    if !delay.is_zero() {
586        std::thread::sleep(delay);
587    }
588    action()
589}
590
591/// Выполнить async-операцию c соблюдением [`RateLimiter`].
592///
593/// `sleep` задаётся приложением, чтобы библиотека не требовала конкретный runtime.
594#[cfg(feature = "async")]
595pub async fn with_rate_limit_async<T, F, Fut, S, SleepFut>(
596    limiter: &mut RateLimiter,
597    action: F,
598    mut sleep: S,
599) -> T
600where
601    F: FnOnce() -> Fut,
602    Fut: std::future::Future<Output = T>,
603    S: FnMut(Duration) -> SleepFut,
604    SleepFut: std::future::Future<Output = ()>,
605{
606    let delay = limiter.reserve_delay();
607    if !delay.is_zero() {
608        sleep(delay).await;
609    }
610    action().await
611}
612
613#[derive(Debug, Error)]
614/// Ошибки выполнения запросов к ISS и конвертации wire-ответов в доменные типы.
615pub enum MoexError {
616    /// Некорректно задан базовый URL ISS.
617    #[error("invalid base URL '{base_url}': {reason}")]
618    InvalidBaseUrl {
619        /// Строка базового URL, которую не удалось разобрать.
620        base_url: &'static str,
621        /// Подробность ошибки парсинга URL.
622        reason: String,
623    },
624    /// Ошибка сборки `reqwest::blocking::Client`.
625    #[cfg(any(feature = "async", feature = "blocking"))]
626    #[error("failed to build HTTP client: {source}")]
627    BuildHttpClient {
628        /// Исходная ошибка HTTP-клиента.
629        #[source]
630        source: reqwest::Error,
631    },
632    /// Для async rate-limit не задана функция `sleep`.
633    #[error(
634        "async rate limit requires sleep function; set AsyncMoexClientBuilder::rate_limit_sleep(...)"
635    )]
636    MissingAsyncRateLimitSleep,
637    /// Не удалось построить URL конкретного endpoint.
638    #[error("failed to build URL for endpoint '{endpoint}': {reason}")]
639    EndpointUrl {
640        /// Относительный путь endpoint.
641        endpoint: Box<str>,
642        /// Подробность ошибки построения URL.
643        reason: String,
644    },
645    /// Для raw-запроса не задан endpoint-path.
646    #[error("raw request path is not set")]
647    MissingRawPath,
648    /// Некорректно задан endpoint-path в raw-запросе.
649    #[error("invalid raw request path '{path}': {reason}")]
650    InvalidRawPath {
651        /// Исходный endpoint-path.
652        path: Box<str>,
653        /// Деталь ошибки валидации path.
654        reason: Box<str>,
655    },
656    /// В raw JSON-ответе отсутствует запрошенная таблица ISS.
657    #[error("raw endpoint '{endpoint}' does not contain table '{table}'")]
658    MissingRawTable {
659        /// Относительный путь endpoint.
660        endpoint: Box<str>,
661        /// Имя таблицы ISS (`history`, `trades`, `securities` и т.д.).
662        table: Box<str>,
663    },
664    /// Строка raw-таблицы содержит число значений, отличное от числа колонок.
665    #[error(
666        "raw table '{table}' from endpoint '{endpoint}' has invalid row width at row {row}: expected {expected}, got {actual}"
667    )]
668    InvalidRawTableRowWidth {
669        /// Относительный путь endpoint.
670        endpoint: Box<str>,
671        /// Имя таблицы ISS.
672        table: Box<str>,
673        /// Индекс строки в таблице ISS.
674        row: usize,
675        /// Число колонок таблицы.
676        expected: usize,
677        /// Число значений в конкретной строке.
678        actual: usize,
679    },
680    /// Не удалось декодировать строку raw-таблицы в пользовательский тип.
681    #[error("failed to decode raw table '{table}' row {row} from endpoint '{endpoint}': {source}")]
682    InvalidRawTableRow {
683        /// Относительный путь endpoint.
684        endpoint: Box<str>,
685        /// Имя таблицы ISS.
686        table: Box<str>,
687        /// Индекс строки в таблице ISS.
688        row: usize,
689        /// Исходная ошибка JSON-декодера.
690        #[source]
691        source: serde_json::Error,
692    },
693    /// Ошибка отправки HTTP-запроса до получения ответа.
694    #[cfg(any(feature = "async", feature = "blocking"))]
695    #[error("request to endpoint '{endpoint}' failed: {source}")]
696    Request {
697        /// Относительный путь endpoint.
698        endpoint: Box<str>,
699        /// Исходная ошибка HTTP-клиента.
700        #[source]
701        source: reqwest::Error,
702    },
703    /// Endpoint вернул HTTP-статус вне диапазона `2xx`.
704    #[cfg(any(feature = "async", feature = "blocking"))]
705    #[error(
706        "endpoint '{endpoint}' returned HTTP {status} (content-type={content_type:?}, prefix={body_prefix:?})"
707    )]
708    HttpStatus {
709        /// Относительный путь endpoint.
710        endpoint: Box<str>,
711        /// HTTP-статус ответа.
712        status: StatusCode,
713        /// Значение HTTP `content-type`, если присутствует.
714        content_type: Option<Box<str>>,
715        /// Начало тела ответа для диагностики.
716        body_prefix: Box<str>,
717    },
718    /// Ошибка чтения тела HTTP-ответа.
719    #[cfg(any(feature = "async", feature = "blocking"))]
720    #[error("failed to read endpoint '{endpoint}' response body: {source}")]
721    ReadBody {
722        /// Относительный путь endpoint.
723        endpoint: Box<str>,
724        /// Исходная ошибка HTTP-клиента.
725        #[source]
726        source: reqwest::Error,
727    },
728    /// Ошибка десериализации JSON-пейлоада ISS.
729    #[error("failed to decode endpoint '{endpoint}' JSON payload: {source}")]
730    Decode {
731        /// Относительный путь endpoint.
732        endpoint: Box<str>,
733        /// Исходная ошибка JSON-декодера.
734        #[source]
735        source: serde_json::Error,
736    },
737    /// Endpoint вернул payload, не похожий на JSON.
738    #[error(
739        "endpoint '{endpoint}' returned non-JSON payload (content-type={content_type:?}, prefix={body_prefix:?})"
740    )]
741    NonJsonPayload {
742        /// Относительный путь endpoint.
743        endpoint: Box<str>,
744        /// Значение HTTP `content-type`, если присутствует.
745        content_type: Option<Box<str>>,
746        /// Начало тела ответа для диагностики.
747        body_prefix: Box<str>,
748    },
749    /// В endpoint `securities/{secid}` пришло больше одной строки `securities`.
750    #[error("endpoint '{endpoint}' returned unexpected security rows count: {row_count}")]
751    UnexpectedSecurityRows {
752        /// Относительный путь endpoint.
753        endpoint: Box<str>,
754        /// Фактическое число строк в таблице `securities`.
755        row_count: usize,
756    },
757    /// В history endpoint `.../dates` пришло больше одной строки `dates`.
758    #[error("endpoint '{endpoint}' returned unexpected history dates rows count: {row_count}")]
759    UnexpectedHistoryDatesRows {
760        /// Относительный путь endpoint.
761        endpoint: Box<str>,
762        /// Фактическое число строк в таблице `dates`.
763        row_count: usize,
764    },
765    /// Ошибка преобразования строки таблицы `indices`.
766    #[error("invalid index row {row} from endpoint '{endpoint}': {source}")]
767    InvalidIndex {
768        /// Относительный путь endpoint.
769        endpoint: Box<str>,
770        /// Индекс строки в таблице ISS.
771        row: usize,
772        /// Деталь ошибки парсинга доменной сущности.
773        #[source]
774        source: ParseIndexError,
775    },
776    /// Ошибка преобразования строки таблицы `dates` из history endpoint.
777    #[error("invalid history dates row {row} from endpoint '{endpoint}': {source}")]
778    InvalidHistoryDates {
779        /// Относительный путь endpoint.
780        endpoint: Box<str>,
781        /// Индекс строки в таблице ISS.
782        row: usize,
783        /// Деталь ошибки парсинга доменной сущности.
784        #[source]
785        source: ParseHistoryDatesError,
786    },
787    /// Ошибка преобразования строки таблицы `history`.
788    #[error("invalid history row {row} from endpoint '{endpoint}': {source}")]
789    InvalidHistory {
790        /// Относительный путь endpoint.
791        endpoint: Box<str>,
792        /// Индекс строки в таблице ISS.
793        row: usize,
794        /// Деталь ошибки парсинга доменной сущности.
795        #[source]
796        source: ParseHistoryRecordError,
797    },
798    /// Ошибка преобразования строки таблицы `turnovers`.
799    #[error("invalid turnover row {row} from endpoint '{endpoint}': {source}")]
800    InvalidTurnover {
801        /// Относительный путь endpoint.
802        endpoint: Box<str>,
803        /// Индекс строки в таблице ISS.
804        row: usize,
805        /// Деталь ошибки парсинга доменной сущности.
806        #[source]
807        source: ParseTurnoverError,
808    },
809    /// Ошибка преобразования строки таблицы `sitenews`.
810    #[error("invalid sitenews row {row} from endpoint '{endpoint}': {source}")]
811    InvalidSiteNews {
812        /// Относительный путь endpoint.
813        endpoint: Box<str>,
814        /// Индекс строки в таблице ISS.
815        row: usize,
816        /// Деталь ошибки парсинга доменной сущности.
817        #[source]
818        source: ParseSiteNewsError,
819    },
820    /// Ошибка преобразования строки таблицы `events`.
821    #[error("invalid events row {row} from endpoint '{endpoint}': {source}")]
822    InvalidEvent {
823        /// Относительный путь endpoint.
824        endpoint: Box<str>,
825        /// Индекс строки в таблице ISS.
826        row: usize,
827        /// Деталь ошибки парсинга доменной сущности.
828        #[source]
829        source: ParseEventError,
830    },
831    /// Ошибка преобразования строки таблицы `secstats`.
832    #[error("invalid secstats row {row} from endpoint '{endpoint}': {source}")]
833    InvalidSecStat {
834        /// Относительный путь endpoint.
835        endpoint: Box<str>,
836        /// Индекс строки в таблице ISS.
837        row: usize,
838        /// Деталь ошибки парсинга доменной сущности.
839        #[source]
840        source: ParseSecStatError,
841    },
842    /// Ошибка преобразования строки таблицы `analytics`.
843    #[error("invalid index analytics row {row} from endpoint '{endpoint}': {source}")]
844    InvalidIndexAnalytics {
845        /// Относительный путь endpoint.
846        endpoint: Box<str>,
847        /// Индекс строки в таблице ISS.
848        row: usize,
849        /// Деталь ошибки парсинга доменной сущности.
850        #[source]
851        source: ParseIndexAnalyticsError,
852    },
853    /// Ошибка преобразования строки таблицы `engines`.
854    #[error("invalid engine row {row} from endpoint '{endpoint}': {source}")]
855    InvalidEngine {
856        /// Относительный путь endpoint.
857        endpoint: Box<str>,
858        /// Индекс строки в таблице ISS.
859        row: usize,
860        /// Деталь ошибки парсинга доменной сущности.
861        #[source]
862        source: ParseEngineError,
863    },
864    /// Ошибка преобразования строки таблицы `markets`.
865    #[error("invalid market row {row} from endpoint '{endpoint}': {source}")]
866    InvalidMarket {
867        /// Относительный путь endpoint.
868        endpoint: Box<str>,
869        /// Индекс строки в таблице ISS.
870        row: usize,
871        /// Деталь ошибки парсинга доменной сущности.
872        #[source]
873        source: ParseMarketError,
874    },
875    /// Ошибка преобразования строки таблицы `boards`.
876    #[error("invalid board row {row} from endpoint '{endpoint}': {source}")]
877    InvalidBoard {
878        /// Относительный путь endpoint.
879        endpoint: Box<str>,
880        /// Индекс строки в таблице ISS.
881        row: usize,
882        /// Деталь ошибки парсинга доменной сущности.
883        #[source]
884        source: ParseBoardError,
885    },
886    /// Ошибка преобразования строки таблицы `boards` в endpoint `securities/{secid}`.
887    #[error("invalid security board row {row} from endpoint '{endpoint}': {source}")]
888    InvalidSecurityBoard {
889        /// Относительный путь endpoint.
890        endpoint: Box<str>,
891        /// Индекс строки в таблице ISS.
892        row: usize,
893        /// Деталь ошибки парсинга доменной сущности.
894        #[source]
895        source: ParseSecurityBoardError,
896    },
897    /// Ошибка преобразования строки таблицы `securities`.
898    #[error("invalid security row {row} from endpoint '{endpoint}': {source}")]
899    InvalidSecurity {
900        /// Относительный путь endpoint.
901        endpoint: Box<str>,
902        /// Индекс строки в таблице ISS.
903        row: usize,
904        /// Деталь ошибки парсинга доменной сущности.
905        #[source]
906        source: ParseSecurityError,
907    },
908    /// Ошибка преобразования строки таблиц `securities`/`marketdata` в снимок инструмента.
909    #[error("invalid security snapshot {table} row {row} from endpoint '{endpoint}': {source}")]
910    InvalidSecuritySnapshot {
911        /// Относительный путь endpoint.
912        endpoint: Box<str>,
913        /// Имя таблицы ISS (`securities` или `marketdata`).
914        table: &'static str,
915        /// Индекс строки в таблице ISS.
916        row: usize,
917        /// Деталь ошибки парсинга доменной сущности.
918        #[source]
919        source: ParseSecuritySnapshotError,
920    },
921    /// Ошибка преобразования строки таблицы `orderbook`.
922    #[error("invalid orderbook row {row} from endpoint '{endpoint}': {source}")]
923    InvalidOrderbook {
924        /// Относительный путь endpoint.
925        endpoint: Box<str>,
926        /// Индекс строки в таблице ISS.
927        row: usize,
928        /// Деталь ошибки парсинга доменной сущности.
929        #[source]
930        source: ParseOrderbookError,
931    },
932    /// Ошибка преобразования строки таблицы `borders`.
933    #[error("invalid candle border row {row} from endpoint '{endpoint}': {source}")]
934    InvalidCandleBorder {
935        /// Относительный путь endpoint.
936        endpoint: Box<str>,
937        /// Индекс строки в таблице ISS.
938        row: usize,
939        /// Деталь ошибки парсинга доменной сущности.
940        #[source]
941        source: ParseCandleBorderError,
942    },
943    /// Ошибка преобразования строки таблицы `candles`.
944    #[error("invalid candle row {row} from endpoint '{endpoint}': {source}")]
945    InvalidCandle {
946        /// Относительный путь endpoint.
947        endpoint: Box<str>,
948        /// Индекс строки в таблице ISS.
949        row: usize,
950        /// Деталь ошибки парсинга доменной сущности.
951        #[source]
952        source: ParseCandleError,
953    },
954    /// Ошибка преобразования строки таблицы `trades`.
955    #[error("invalid trade row {row} from endpoint '{endpoint}': {source}")]
956    InvalidTrade {
957        /// Относительный путь endpoint.
958        endpoint: Box<str>,
959        /// Индекс строки в таблице ISS.
960        row: usize,
961        /// Деталь ошибки парсинга доменной сущности.
962        #[source]
963        source: ParseTradeError,
964    },
965    /// Переполнение счётчика `start` при авто-пагинации ISS.
966    #[error(
967        "pagination overflow for endpoint '{endpoint}': start={start}, limit={limit} exceeds u32"
968    )]
969    PaginationOverflow {
970        /// Относительный путь endpoint.
971        endpoint: Box<str>,
972        /// Текущее значение `start`.
973        start: u32,
974        /// Размер страницы `limit`.
975        limit: u32,
976    },
977    /// Обнаружен зацикленный ответ при авто-пагинации ISS.
978    #[error(
979        "pagination is stuck for endpoint '{endpoint}': repeated page at start={start}, limit={limit}"
980    )]
981    PaginationStuck {
982        /// Относительный путь endpoint.
983        endpoint: Box<str>,
984        /// Текущее значение `start`.
985        start: u32,
986        /// Размер страницы `limit`.
987        limit: u32,
988    },
989}
990
991impl MoexError {
992    /// Признак, что операцию обычно имеет смысл повторить с backoff.
993    pub fn is_retryable(&self) -> bool {
994        match self {
995            #[cfg(any(feature = "async", feature = "blocking"))]
996            Self::BuildHttpClient { .. } => false,
997            #[cfg(any(feature = "async", feature = "blocking"))]
998            Self::Request { source, .. } => {
999                source.is_timeout()
1000                    || source.is_connect()
1001                    || source.status().is_some_and(is_retryable_status)
1002            }
1003            #[cfg(any(feature = "async", feature = "blocking"))]
1004            Self::ReadBody { .. } => true,
1005            #[cfg(any(feature = "async", feature = "blocking"))]
1006            Self::HttpStatus { status, .. } => is_retryable_status(*status),
1007            _ => false,
1008        }
1009    }
1010
1011    /// HTTP-статус, если ошибка была получена после ответа сервера.
1012    #[cfg(any(feature = "async", feature = "blocking"))]
1013    pub fn status_code(&self) -> Option<StatusCode> {
1014        match self {
1015            Self::Request { source, .. } => source.status(),
1016            Self::HttpStatus { status, .. } => Some(*status),
1017            _ => None,
1018        }
1019    }
1020
1021    /// Диагностический префикс тела ответа, если он сохранён в ошибке.
1022    pub fn response_body_prefix(&self) -> Option<&str> {
1023        match self {
1024            #[cfg(any(feature = "async", feature = "blocking"))]
1025            Self::HttpStatus { body_prefix, .. } | Self::NonJsonPayload { body_prefix, .. } => {
1026                Some(body_prefix)
1027            }
1028            #[cfg(not(any(feature = "async", feature = "blocking")))]
1029            Self::NonJsonPayload { body_prefix, .. } => Some(body_prefix),
1030            _ => None,
1031        }
1032    }
1033}
1034
1035#[cfg(any(feature = "async", feature = "blocking"))]
1036fn is_retryable_status(status: StatusCode) -> bool {
1037    status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
1038}
1039
1040#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1041enum RepeatPagePolicy {
1042    Error,
1043}
1044
1045#[cfg(test)]
1046mod tests;