Skip to main content

moex_client/
moex.rs

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